feat(rbac): 落地角色权限模型(需求 7)——班级/人员/名单/成绩收紧到管理员,班主任管本班课表,科任仅作业·叫人·通知

## 权限矩阵(前后端共用一份)

- 新增 `packages/shared/src/permissions.ts`:`ClassRole`(ADMIN/HEAD/SUBJECT/STUDENT/NONE)、
  `resolveClassRole()`、`canManageClasses/AssignTeachers/Roster/Schedule/Courses/PublishContent/ManageGrades/Import`
  与 `PERMISSION_MATRIX`(README 与验收脚本据此展开)。
- 后端 `lib/access.ts`:新增 `resolveUserClassRole()`(`Class.teacherId` → 班主任,`ClassTeacher` → 科任)
  与 `assertCanManageClasses/AssignTeachers/Roster/Schedule/PublishContent/ManageGrades`;
  各 service 在**服务层**断言(路由仅做角色过滤),越权统一 403。
- 收紧项:班级增删改、班主任/科任分配、学生名单与账号 → 仅 ADMIN;
  课表/课程增删改 → ADMIN 或本班班主任;成绩录入/修改/批量 → 仅 ADMIN(按需求清单);
  作业/通知/叫人 → ADMIN、班主任、科任皆可。
- 读权限保持不变:教师仍可查看自己班级的课表/作业/通知/成绩列表与统计。

## 前端按同一矩阵隐藏入口

- 侧边栏:教师端不再显示「班级管理 / 学生管理 / 成绩录入」(管理员仍是 7 项)。
- 班级页:新建/编辑/删除按钮仅管理员可见;课表页:增删改按钮仅"管理员或本班班主任"可见;
  成绩页:录入/批量/删除按钮仅管理员可见。
- **叫人入口调整**:教师需要叫人,而「学生管理」是管理员专属,因此教师入口移到
  「通知发布 → 叫人」(选班级 → 选学生 → 快捷短语/自定义消息;名单走 `/classes/:id/students`,
  不使用管理员专属的 `/students`);管理员在学生管理页仍保留逐行「叫人」。

## 顺手修掉的两个真实缺陷

- `NotificationsView.openCreate()`:班级列表异步加载期间点「发布通知」会导致「目标班级」为空、
  校验失败且**静默不发布**;现在打开弹窗前会确保班级已加载(冒烟同步等待下拉就绪)。
- `desktop-client/scripts/smoke.mjs`:上一次冒烟被强杀后配置目录仍被句柄占用,`rmSync` 报 ENOTEMPTY
  直接让冒烟启动失败;改为按进程号使用独立目录并容错清理。

## 验证

- `pnpm verify:e2e` **82/82** ✅:新增 14 项角色权限断言(班主任/科任建班被拒、班主任删班被拒、
  班主任/科任分配人员被拒、管理员分配与取消成功、科任管理课表被拒、科任可布置作业、
  科任/班主任录入成绩被拒、管理员录入成功),并把历史用例中"仅管理员可执行"的操作切到管理员令牌。
- `pnpm verify:web` **17/17** ✅:新增「教师端隐藏无权入口」断言(可见菜单=仪表盘/课表/作业/通知,
  越权入口=[]),叫人入口改到通知页后仍验证弹窗、8 条快捷短语、自定义输入与可选中态。
- `pnpm verify:desktop` **34/34** ✅;`lint` / `format:check` / `typecheck` 全绿。
- 数据清理:清掉历史遗留的测试班级与 4 条探针课表,演示数据恢复为 3 个班级。
This commit is contained in:
LoveChengke committed 2026-09-12 15:02:02 +08:00
1 parent 71f43042ca
commit 30e4c36809
25 files changed
+760 -228

No files matched your search

+85 -3
View File
@@ -1,12 +1,25 @@
import {
canManageGrades,
canManageSchedule,
canPublishContent,
canManageClasses,
canAssignTeachers,
canManageRoster,
resolveClassRole,
type ClassRole,
} from '@classhelper/shared';
import { prisma } from './db.js';
import { ApiError } from './http.js';
import type { TokenPayload } from './jwt.js';
/**
* 权限(RBAC)规则:
* - ADMIN :可访问全部班级
* - TEACHER :仅可访问自己创建(teacherId)或被分配(ClassTeacher)的班级
* - STUDENT :仅可查看自己所在班级的数据,且无任何写权限
* - ADMIN :全部班级;班级增删改、人员分配、成绩、课表
* - HEAD :班主任(Class.teacherId)—— 本班课表 + 作业/通知/叫人
* - SUBJECT :科任老师(ClassTeacher)—— 仅作业/通知/叫人
* - STUDENT :只读;班级账号(classSession)以班级为单位读写自己的数据
*
* 判定逻辑与前端共用 `@classhelper/shared/permissions`,避免前后端口径漂移。
*/
export function isAdmin(user: TokenPayload): boolean {
@@ -82,6 +95,75 @@ export async function assertClassWritable(user: TokenPayload, classId: string):
await assertClassAccess(user, classId);
}
/* ---------------------------------------------------------------- 班级内角色 */
/** 解析当前用户在某班级里的角色(ADMIN / HEAD / SUBJECT / STUDENT / 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'));
const record = await prisma.class.findUnique({
where: { id: classId },
select: {
teacherId: true,
teachers: { 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,
});
}
/** 班级本身(增删改)与人员分配:仅管理员 */
export function assertCanManageClasses(user: TokenPayload): void {
if (!canManageClasses(user.role)) {
throw ApiError.forbidden('只有管理员可以创建、修改或删除班级');
}
}
/** 班主任 / 科任老师分配:仅管理员 */
export function assertCanAssignTeachers(user: TokenPayload): void {
if (!canAssignTeachers(user.role)) {
throw ApiError.forbidden('只有管理员可以分配班主任与科任老师');
}
}
/** 学生名单管理:仅管理员 */
export function assertCanManageRoster(user: TokenPayload): void {
if (!canManageRoster(user.role)) {
throw ApiError.forbidden('只有管理员可以管理学生名单与账号');
}
}
/** 课表管理(含时间配置导入):管理员或本班班主任 */
export async function assertCanManageSchedule(user: TokenPayload, classId: string): Promise<void> {
const classRole = await resolveUserClassRole(user, classId);
if (!canManageSchedule(classRole)) {
throw ApiError.forbidden(
classRole === 'SUBJECT' ? '科任老师不能管理课表,请联系班主任或管理员' : '无权管理该班级的课表',
);
}
}
/** 作业 / 通知 / 叫人:管理员、班主任、科任老师 */
export async function assertCanPublishContent(user: TokenPayload, classId: string): Promise<void> {
await assertClassAccess(user, classId);
const classRole = await resolveUserClassRole(user, classId);
if (!canPublishContent(classRole)) {
throw ApiError.forbidden('当前账号没有发布作业/通知或叫人的权限');
}
}
/** 成绩录入 / 修改 / 导入:仅管理员 */
export function assertCanManageGrades(user: TokenPayload): void {
if (!canManageGrades(user.role)) {
throw ApiError.forbidden('只有管理员可以录入、修改或导入成绩');
}
}
/** 学生的主班级,未分配班级时抛 403 */
export function requireStudentClassId(user: TokenPayload): string {
if (!user.classId) throw ApiError.forbidden('当前账号尚未被分配到任何班级,请联系班主任');
@@ -1,5 +1,5 @@
import { SOCKET_EVENTS, buildCallTitle, type NotificationDto } from '@classhelper/shared';
import { assertClassWritable } from '../../lib/access.js';
import { SOCKET_EVENTS, buildCallTitle, type NotificationDto } from '@classhelper/shared';
import { assertCanPublishContent } from '../../lib/access.js';
import { prisma } from '../../lib/db.js';
import { ApiError } from '../../lib/http.js';
import type { TokenPayload } from '../../lib/jwt.js';
@@ -18,7 +18,7 @@ const creatorSelect = { select: { id: true, name: true, username: true } } as co
* "叫人"类型,无论是否在上课都立即展开显示。
*/
export async function createCall(user: TokenPayload, input: CreateCallInput): Promise<NotificationDto> {
await assertClassWritable(user, input.classId);
await assertCanPublishContent(user, input.classId);
const student = await prisma.user.findUnique({
where: { id: input.studentId },
@@ -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';
@@ -38,7 +38,7 @@ router.get('/:id', validate({ params: idParamSchema }), async (req, res) => {
});
/** POST /api/classes - 创建班级 */
router.post('/', requireRole('ADMIN', 'TEACHER'), validate({ body: createClassSchema }), async (req, res) => {
router.post('/', requireRole('ADMIN'), validate({ body: createClassSchema }), async (req, res) => {
const user = getAuthUser(req);
sendCreated(res, await classService.createClass(user, req.body as CreateClassInput), '班级创建成功');
});
@@ -1,8 +1,10 @@
import type { ClassDetailDto, ClassDto, StudentDto } from '@classhelper/shared';
import type { ClassDetailDto, ClassDto, StudentDto } from '@classhelper/shared';
import { env } from '../../config/env.js';
import {
assertClassAccess,
assertClassWritable,
assertCanAssignTeachers,
assertCanManageClasses,
assertCanManageRoster,
classScopeIdFilter,
resolveClassScope,
} from '../../lib/access.js';
@@ -75,6 +77,7 @@ export async function getClassDetail(user: TokenPayload, classId: string): Promi
/** 创建班级 */
export async function createClass(user: TokenPayload, input: CreateClassInput): Promise<ClassDto> {
assertCanManageClasses(user);
let teacherId = user.sub;
if (input.teacherId && user.role === 'ADMIN') {
const teacher = await prisma.user.findUnique({ where: { id: input.teacherId } });
@@ -101,7 +104,7 @@ export async function updateClass(
classId: string,
input: UpdateClassInput,
): Promise<ClassDto> {
await assertClassWritable(user, classId);
assertCanManageClasses(user);
const updated = await prisma.class.update({
where: { id: classId },
@@ -118,7 +121,7 @@ export async function updateClass(
/** 删除班级(级联删除课表/作业/通知/成绩) */
export async function deleteClass(user: TokenPayload, classId: string): Promise<void> {
await assertClassWritable(user, classId);
assertCanManageClasses(user);
emitToClass(classId, SOCKET_EVENTS.classUpdated, { classId, action: 'deleted' });
await prisma.class.delete({ where: { id: classId } });
}
@@ -144,7 +147,7 @@ export async function addStudent(
classId: string,
input: AddStudentInput,
): Promise<StudentDto> {
await assertClassWritable(user, classId);
assertCanManageRoster(user);
const existing = await prisma.user.findUnique({ where: { username: input.username } });
let studentId: string;
@@ -190,7 +193,7 @@ export async function addStudent(
/** 将学生移出班级(保留账号) */
export async function removeStudent(user: TokenPayload, classId: string, studentId: string): Promise<void> {
await assertClassWritable(user, classId);
assertCanManageRoster(user);
const student = await prisma.user.findUnique({ where: { id: studentId } });
if (!student || student.role !== 'STUDENT') throw ApiError.notFound('学生不存在');
@@ -206,7 +209,7 @@ export async function removeStudent(user: TokenPayload, classId: string, student
/** 分配协作教师 */
export async function assignTeacher(user: TokenPayload, classId: string, teacherId: string): Promise<void> {
await assertClassWritable(user, classId);
assertCanAssignTeachers(user);
const teacher = await prisma.user.findUnique({ where: { id: teacherId } });
if (!teacher || (teacher.role !== 'TEACHER' && teacher.role !== 'ADMIN')) {
@@ -222,7 +225,7 @@ export async function assignTeacher(user: TokenPayload, classId: string, teacher
/** 取消协作教师(班主任不可被移除) */
export async function removeTeacher(user: TokenPayload, classId: string, teacherId: string): Promise<void> {
await assertClassWritable(user, classId);
assertCanAssignTeachers(user);
const target = await prisma.class.findUnique({ where: { id: classId }, select: { teacherId: true } });
if (target?.teacherId === teacherId) throw ApiError.badRequest('班主任不能被移除,请先转移班级');
@@ -1,5 +1,5 @@
import type { CourseDto } from '@classhelper/shared';
import { assertClassWritable, classScopeWhere, resolveClassScope } from '../../lib/access.js';
import type { CourseDto } from '@classhelper/shared';
import { assertCanManageSchedule, classScopeWhere, resolveClassScope } from '../../lib/access.js';
import { prisma } from '../../lib/db.js';
import { ApiError } from '../../lib/http.js';
import type { TokenPayload } from '../../lib/jwt.js';
@@ -19,7 +19,7 @@ export async function listCourses(user: TokenPayload, classId?: string): Promise
}
export async function createCourse(user: TokenPayload, input: CreateCourseInput): Promise<CourseDto> {
await assertClassWritable(user, input.classId);
await assertCanManageSchedule(user, input.classId);
const created = await prisma.course.create({
data: { name: input.name, classId: input.classId, teacherId: user.sub },
@@ -35,7 +35,7 @@ export async function updateCourse(
): Promise<CourseDto> {
const course = await prisma.course.findUnique({ where: { id: courseId } });
if (!course) throw ApiError.notFound('课程不存在');
await assertClassWritable(user, course.classId);
await assertCanManageSchedule(user, course.classId);
const updated = await prisma.course.update({
where: { id: courseId },
@@ -48,6 +48,6 @@ export async function updateCourse(
export async function deleteCourse(user: TokenPayload, courseId: string): Promise<void> {
const course = await prisma.course.findUnique({ where: { id: courseId } });
if (!course) throw ApiError.notFound('课程不存在');
await assertClassWritable(user, course.classId);
await assertCanManageSchedule(user, course.classId);
await prisma.course.delete({ where: { id: courseId } });
}
@@ -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';
@@ -52,7 +52,7 @@ router.get(
);
/** POST /api/grades - 录入单条成绩(广播 grade:updated) */
router.post('/', requireRole('ADMIN', 'TEACHER'), validate({ body: createGradeSchema }), async (req, res) => {
router.post('/', requireRole('ADMIN'), validate({ body: createGradeSchema }), async (req, res) => {
const user = getAuthUser(req);
sendCreated(
res,
@@ -62,21 +62,16 @@ router.post('/', requireRole('ADMIN', 'TEACHER'), validate({ body: createGradeSc
});
/** POST /api/grades/bulk - 批量录入 / 导入成绩 */
router.post(
'/bulk',
requireRole('ADMIN', 'TEACHER'),
validate({ body: bulkCreateGradeSchema }),
async (req, res) => {
const user = getAuthUser(req);
const grades = await gradeService.bulkCreateGrades(user, req.body as BulkCreateGradeInput);
sendCreated(res, { count: grades.length, items: grades }, `已录入 ${grades.length} 条成绩`);
},
);
router.post('/bulk', requireRole('ADMIN'), validate({ body: bulkCreateGradeSchema }), async (req, res) => {
const user = getAuthUser(req);
const grades = await gradeService.bulkCreateGrades(user, req.body as BulkCreateGradeInput);
sendCreated(res, { count: grades.length, items: grades }, `已录入 ${grades.length} 条成绩`);
});
/** PATCH /api/grades/:id - 修改成绩 */
router.patch(
'/:id',
requireRole('ADMIN', 'TEACHER'),
requireRole('ADMIN'),
validate({ params: idParamSchema, body: updateGradeSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -86,17 +81,12 @@ router.patch(
);
/** DELETE /api/grades/:id - 删除成绩 */
router.delete(
'/:id',
requireRole('ADMIN', 'TEACHER'),
validate({ params: idParamSchema }),
async (req, res) => {
const user = getAuthUser(req);
const { id } = validatedParams<{ id: string }>(req);
await gradeService.deleteGrade(user, id);
sendOk(res, { id }, '成绩已删除');
},
);
router.delete('/:id', requireRole('ADMIN'), validate({ params: idParamSchema }), async (req, res) => {
const user = getAuthUser(req);
const { id } = validatedParams<{ id: string }>(req);
await gradeService.deleteGrade(user, id);
sendOk(res, { id }, '成绩已删除');
});
export const gradesModule = defineModule({
name: 'grades',
@@ -1,4 +1,4 @@
import {
import {
SOCKET_EVENTS,
gradeLevel,
gradePercent,
@@ -6,7 +6,7 @@ import {
type GradeLevel,
type GradeStats,
} from '@classhelper/shared';
import { assertClassWritable, classScopeWhere, resolveClassScope } from '../../lib/access.js';
import { assertCanManageGrades, classScopeWhere, resolveClassScope } from '../../lib/access.js';
import { prisma } from '../../lib/db.js';
import { ApiError } from '../../lib/http.js';
import type { TokenPayload } from '../../lib/jwt.js';
@@ -98,7 +98,7 @@ export async function getGradeStats(user: TokenPayload, options: ListGradeOption
}
export async function createGrade(user: TokenPayload, input: CreateGradeInput): Promise<GradeDto> {
await assertClassWritable(user, input.classId);
assertCanManageGrades(user);
await assertStudentInClass(input.userId, input.classId);
if (input.courseId) await assertCourseInClass(input.courseId, input.classId);
@@ -119,7 +119,7 @@ export async function createGrade(user: TokenPayload, input: CreateGradeInput):
/** 批量录入:同一个考试一次提交整班成绩 */
export async function bulkCreateGrades(user: TokenPayload, input: BulkCreateGradeInput): Promise<GradeDto[]> {
await assertClassWritable(user, input.classId);
assertCanManageGrades(user);
if (input.courseId) await assertCourseInClass(input.courseId, input.classId);
const publishedAt = parseOptionalDate(input.publishedAt) ?? new Date();
@@ -155,7 +155,7 @@ export async function updateGrade(
): Promise<GradeDto> {
const current = await prisma.grade.findUnique({ where: { id: gradeId } });
if (!current) throw ApiError.notFound('成绩记录不存在');
await assertClassWritable(user, current.classId);
assertCanManageGrades(user);
if (input.courseId) await assertCourseInClass(input.courseId, current.classId);
const updated = await prisma.grade.update({
@@ -181,7 +181,7 @@ export async function updateGrade(
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 assertClassWritable(user, current.classId);
assertCanManageGrades(user);
await prisma.grade.delete({ where: { id: gradeId } });
emitToClass(current.classId, SOCKET_EVENTS.gradeUpdated, {
...toGradeDto({ ...current, course: null, student: null, class: null }),
@@ -1,7 +1,7 @@
import { SOCKET_EVENTS, type HomeworkDto, type HomeworkStatusDto } from '@classhelper/shared';
import { SOCKET_EVENTS, type HomeworkDto, type HomeworkStatusDto } from '@classhelper/shared';
import {
assertCanPublishContent,
assertClassAccess,
assertClassWritable,
classScopeWhere,
isStudent,
resolveClassScope,
@@ -82,7 +82,7 @@ export async function getHomework(user: TokenPayload, homeworkId: string): Promi
/** 发布作业并实时推送到班级房间 */
export async function createHomework(user: TokenPayload, input: CreateHomeworkInput): Promise<HomeworkDto> {
await assertClassWritable(user, input.classId);
await assertCanPublishContent(user, input.classId);
if (input.courseId) await assertCourseInClass(input.courseId, input.classId);
const created = await prisma.homework.create({
@@ -110,7 +110,7 @@ export async function updateHomework(
): Promise<HomeworkDto> {
const current = await prisma.homework.findUnique({ where: { id: homeworkId } });
if (!current) throw ApiError.notFound('作业不存在');
await assertClassWritable(user, current.classId);
await assertCanPublishContent(user, current.classId);
if (input.courseId) await assertCourseInClass(input.courseId, current.classId);
@@ -137,7 +137,7 @@ export async function deleteHomework(user: TokenPayload, homeworkId: string): Pr
include: { course: courseSelect, creator: creatorSelect },
});
if (!current) throw ApiError.notFound('作业不存在');
await assertClassWritable(user, current.classId);
await assertCanPublishContent(user, current.classId);
await prisma.homework.delete({ where: { id: homeworkId } });
emitToClass(current.classId, SOCKET_EVENTS.homeworkUpdated, {
@@ -1,7 +1,7 @@
import { SOCKET_EVENTS, type NotificationDto } from '@classhelper/shared';
import { SOCKET_EVENTS, type NotificationDto } from '@classhelper/shared';
import {
assertClassAccess,
assertClassWritable,
assertCanPublishContent,
classScopeWhere,
resolveClassScope,
} from '../../lib/access.js';
@@ -61,7 +61,7 @@ export async function createNotification(
user: TokenPayload,
input: CreateNotificationInput,
): Promise<NotificationDto> {
await assertClassWritable(user, input.classId);
await assertCanPublishContent(user, input.classId);
const priority = input.priority ?? 'NORMAL';
if (priority === 'URGENT' && input.confirmDuringClass !== true) {
@@ -99,7 +99,7 @@ export async function createNotification(
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 assertClassWritable(user, current.classId);
await assertCanPublishContent(user, current.classId);
await prisma.notification.delete({ where: { id: notificationId } });
}
@@ -1,4 +1,4 @@
import { Router } from 'express';
import { Router } from 'express';
import { resolveCurrentWeek } from '@classhelper/shared';
import { env } from '../../config/env.js';
import { sendCreated, sendOk } from '../../lib/http.js';
@@ -1,4 +1,4 @@
import {
import {
SOCKET_EVENTS,
buildScheduleWeekView,
resolveClassStatus,
@@ -9,7 +9,7 @@ import {
} from '@classhelper/shared';
import {
assertClassAccess,
assertClassWritable,
assertCanManageSchedule,
classScopeWhere,
isStudent,
requireStudentClassId,
@@ -102,7 +102,7 @@ export async function getClassStatus(
}
export async function createSchedule(user: TokenPayload, input: CreateScheduleInput): Promise<ScheduleDto> {
await assertClassWritable(user, input.classId);
await assertCanManageSchedule(user, input.classId);
await assertCourseInClass(input.courseId, input.classId);
const created = await prisma.schedule.create({
@@ -135,7 +135,7 @@ export async function updateSchedule(
): Promise<ScheduleDto> {
const current = await prisma.schedule.findUnique({ where: { id: scheduleId } });
if (!current) throw ApiError.notFound('课表记录不存在');
await assertClassWritable(user, current.classId);
await assertCanManageSchedule(user, current.classId);
if (input.courseId) await assertCourseInClass(input.courseId, current.classId);
@@ -173,7 +173,7 @@ export async function updateSchedule(
export async function deleteSchedule(user: TokenPayload, scheduleId: string): Promise<void> {
const current = await prisma.schedule.findUnique({ where: { id: scheduleId } });
if (!current) throw ApiError.notFound('课表记录不存在');
await assertClassWritable(user, current.classId);
await assertCanManageSchedule(user, current.classId);
await prisma.schedule.delete({ where: { id: scheduleId } });
emitToClass(current.classId, SOCKET_EVENTS.scheduleUpdated, {
@@ -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';
@@ -19,36 +19,26 @@ const router = Router();
router.use(authenticate());
/** GET /api/students?classId=&keyword= - 学生名单(教师/管理员) */
router.get(
'/',
requireRole('ADMIN', 'TEACHER'),
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'), 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), '获取学生列表成功');
});
/** 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),
'学生账号创建成功',
);
},
);
router.post('/', requireRole('ADMIN'), 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', 'TEACHER'),
requireRole('ADMIN'),
validate({ params: idParamSchema, body: updateStudentSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -64,7 +54,7 @@ router.patch(
/** POST /api/students/:id/reset-password - 重置密码 */
router.post(
'/:id/reset-password',
requireRole('ADMIN', 'TEACHER'),
requireRole('ADMIN'),
validate({ params: idParamSchema, body: resetPasswordSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -75,17 +65,12 @@ router.post(
);
/** 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 }, '学生账号已删除');
},
);
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 }, '学生账号已删除');
});
export const studentsModule = defineModule({
name: 'students',
@@ -1,6 +1,6 @@
import type { StudentDto } from '@classhelper/shared';
import type { StudentDto } from '@classhelper/shared';
import { env } from '../../config/env.js';
import { assertClassWritable, classScopeWhere, isAdmin, resolveClassScope } from '../../lib/access.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';
@@ -37,7 +37,7 @@ export async function listStudents(user: TokenPayload, options: ListStudentOptio
/** 新建学生账号(可选直接分班) */
export async function createStudent(user: TokenPayload, input: CreateStudentInput): Promise<StudentDto> {
if (input.classId) await assertClassWritable(user, input.classId);
if (input.classId) assertCanManageRoster(user);
const passwordHash = await hashPassword(input.password ?? env.defaultStudentPassword);
const created = await prisma.user.create({
@@ -75,14 +75,14 @@ export async function updateStudent(
if (!student || student.role !== 'STUDENT') throw ApiError.notFound('学生不存在');
if (student.classId) {
await assertClassWritable(user, student.classId);
assertCanManageRoster(user);
} else if (!isAdmin(user)) {
throw ApiError.forbidden('该学生尚未分班,仅管理员可以直接修改');
}
const nextClassId = input.classId === undefined ? student.classId : (input.classId ?? null);
if (nextClassId && nextClassId !== student.classId) {
await assertClassWritable(user, nextClassId);
assertCanManageRoster(user);
}
const updated = await prisma.user.update({
@@ -119,7 +119,7 @@ export async function deleteStudent(user: TokenPayload, studentId: string): Prom
if (!student || student.role !== 'STUDENT') throw ApiError.notFound('学生不存在');
if (student.classId) {
await assertClassWritable(user, student.classId);
assertCanManageRoster(user);
} else if (!isAdmin(user)) {
throw ApiError.forbidden('该学生尚未分班,仅管理员可以删除');
}
@@ -137,7 +137,7 @@ export async function resetPassword(
if (!student || student.role !== 'STUDENT') throw ApiError.notFound('学生不存在');
if (student.classId) {
await assertClassWritable(user, student.classId);
assertCanManageRoster(user);
} else if (!isAdmin(user)) {
throw ApiError.forbidden('该学生尚未分班,仅管理员可以重置密码');
}
@@ -1,4 +1,4 @@
import { Router } from 'express';
import { Router } from 'express';
import { sendCreated, sendOk } from '../../lib/http.js';
import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js';
import { validate, validatedQuery } from '../../middleware/validate.js';
@@ -10,15 +10,10 @@ const router = Router();
router.use(authenticate());
/** GET /api/teachers?keyword= - 教师列表(分配协作教师时使用) */
router.get(
'/',
requireRole('ADMIN', 'TEACHER'),
validate({ query: listTeachersQuerySchema }),
async (req, res) => {
const { keyword } = validatedQuery<{ keyword?: string }>(req);
sendOk(res, await teacherService.listTeachers(keyword), '获取教师列表成功');
},
);
router.get('/', requireRole('ADMIN'), validate({ query: listTeachersQuerySchema }), async (req, res) => {
const { keyword } = validatedQuery<{ keyword?: string }>(req);
sendOk(res, await teacherService.listTeachers(keyword), '获取教师列表成功');
});
/** POST /api/teachers - 新建教师账号(仅管理员) */
router.post('/', requireRole('ADMIN'), validate({ body: createTeacherSchema }), async (req, res) => {