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:
1 parent
71f43042ca
commit
30e4c36809
25 files changed
+760
-228
No files matched your search
@@ -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) => {
|
||||
|
||||
Reference in new issue
Block a user