feat: 班级小助手 阶段1-3(monorepo + 后端 REST/Socket.IO + Web 管理端)
This commit is contained in:
commit
84ab49de10
102 files changed
+14751
No files matched your search
@@ -0,0 +1,134 @@
|
||||
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';
|
||||
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
|
||||
import { defineModule } from '../module.types.js';
|
||||
import {
|
||||
addStudentSchema,
|
||||
assignTeacherSchema,
|
||||
classStudentParamSchema,
|
||||
classTeacherParamSchema,
|
||||
createClassSchema,
|
||||
listClassesQuerySchema,
|
||||
updateClassSchema,
|
||||
type AddStudentInput,
|
||||
type CreateClassInput,
|
||||
type UpdateClassInput,
|
||||
} from './classes.schemas.js';
|
||||
import * as classService from './classes.service.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// 班级相关接口统一需要登录
|
||||
router.use(authenticate());
|
||||
|
||||
/** GET /api/classes - 班级列表(教师/管理员:自己管理的班级;学生:自己所在班级) */
|
||||
router.get('/', validate({ query: listClassesQuerySchema }), async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const { keyword } = validatedQuery<{ keyword?: string }>(req);
|
||||
sendOk(res, await classService.listClasses(user, keyword), '获取班级列表成功');
|
||||
});
|
||||
|
||||
/** GET /api/classes/:id - 班级详情 */
|
||||
router.get('/:id', validate({ params: idParamSchema }), async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const { id } = validatedParams<{ id: string }>(req);
|
||||
sendOk(res, await classService.getClassDetail(user, id), '获取班级详情成功');
|
||||
});
|
||||
|
||||
/** POST /api/classes - 创建班级 */
|
||||
router.post('/', requireRole('ADMIN', 'TEACHER'), validate({ body: createClassSchema }), async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
sendCreated(res, await classService.createClass(user, req.body as CreateClassInput), '班级创建成功');
|
||||
});
|
||||
|
||||
/** PATCH /api/classes/:id - 编辑班级 */
|
||||
router.patch(
|
||||
'/:id',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
validate({ params: idParamSchema, body: updateClassSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const { id } = validatedParams<{ id: string }>(req);
|
||||
sendOk(res, await classService.updateClass(user, id, req.body as UpdateClassInput), '班级更新成功');
|
||||
},
|
||||
);
|
||||
|
||||
/** DELETE /api/classes/:id - 删除班级 */
|
||||
router.delete(
|
||||
'/:id',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
validate({ params: idParamSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const { id } = validatedParams<{ id: string }>(req);
|
||||
await classService.deleteClass(user, id);
|
||||
sendOk(res, { id }, '班级已删除');
|
||||
},
|
||||
);
|
||||
|
||||
/** GET /api/classes/:id/students - 班级学生名单 */
|
||||
router.get('/:id/students', validate({ params: idParamSchema }), async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const { id } = validatedParams<{ id: string }>(req);
|
||||
sendOk(res, await classService.listClassStudents(user, id), '获取学生名单成功');
|
||||
});
|
||||
|
||||
/** POST /api/classes/:id/students - 添加学生(支持已存在账号转入) */
|
||||
router.post(
|
||||
'/:id/students',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
validate({ params: idParamSchema, body: addStudentSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const { id } = validatedParams<{ id: string }>(req);
|
||||
sendCreated(res, await classService.addStudent(user, id, req.body as AddStudentInput), '学生已加入班级');
|
||||
},
|
||||
);
|
||||
|
||||
/** DELETE /api/classes/:id/students/:userId - 移出学生 */
|
||||
router.delete(
|
||||
'/:id/students/:userId',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
validate({ params: classStudentParamSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const { id, userId } = validatedParams<{ id: string; userId: string }>(req);
|
||||
await classService.removeStudent(user, id, userId);
|
||||
sendOk(res, { classId: id, userId }, '学生已移出班级');
|
||||
},
|
||||
);
|
||||
|
||||
/** POST /api/classes/:id/teachers - 分配协作教师 */
|
||||
router.post(
|
||||
'/:id/teachers',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
validate({ params: idParamSchema, body: assignTeacherSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const { id } = validatedParams<{ id: string }>(req);
|
||||
const { teacherId } = req.body as { teacherId: string };
|
||||
await classService.assignTeacher(user, id, teacherId);
|
||||
sendCreated(res, { classId: id, teacherId }, '教师已分配');
|
||||
},
|
||||
);
|
||||
|
||||
/** DELETE /api/classes/:id/teachers/:teacherId - 取消协作教师 */
|
||||
router.delete(
|
||||
'/:id/teachers/:teacherId',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
validate({ params: classTeacherParamSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const { id, teacherId } = validatedParams<{ id: string; teacherId: string }>(req);
|
||||
await classService.removeTeacher(user, id, teacherId);
|
||||
sendOk(res, { classId: id, teacherId }, '已取消教师分配');
|
||||
},
|
||||
);
|
||||
|
||||
export const classesModule = defineModule({
|
||||
name: 'classes',
|
||||
basePath: '/classes',
|
||||
router,
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { z } from 'zod';
|
||||
import { keywordSchema } from '../../lib/schemas.js';
|
||||
|
||||
export const createClassSchema = z.object({
|
||||
name: z.string().trim().min(1, '请输入班级名称').max(64),
|
||||
grade: z.string().trim().min(1, '请输入年级').max(32),
|
||||
/** 仅管理员可指定班主任,教师创建时固定为自己 */
|
||||
teacherId: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
export const updateClassSchema = z.object({
|
||||
name: z.string().trim().min(1).max(64).optional(),
|
||||
grade: z.string().trim().min(1).max(32).optional(),
|
||||
});
|
||||
|
||||
export const listClassesQuerySchema = z.object({
|
||||
keyword: keywordSchema,
|
||||
});
|
||||
|
||||
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({
|
||||
teacherId: z.string().min(1, '请选择教师'),
|
||||
});
|
||||
|
||||
export const classStudentParamSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
userId: z.string().min(1),
|
||||
});
|
||||
|
||||
export const classTeacherParamSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
teacherId: z.string().min(1),
|
||||
});
|
||||
|
||||
export type CreateClassInput = z.infer<typeof createClassSchema>;
|
||||
export type UpdateClassInput = z.infer<typeof updateClassSchema>;
|
||||
export type AddStudentInput = z.infer<typeof addStudentSchema>;
|
||||
@@ -0,0 +1,231 @@
|
||||
import type { ClassDetailDto, ClassDto, StudentDto } from '@classhelper/shared';
|
||||
import { env } from '../../config/env.js';
|
||||
import {
|
||||
assertClassAccess,
|
||||
assertClassWritable,
|
||||
classScopeIdFilter,
|
||||
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 { toClassDto, toCourseDto, 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 { AddStudentInput, CreateClassInput, UpdateClassInput } from './classes.schemas.js';
|
||||
|
||||
const countSelect = {
|
||||
students: true,
|
||||
courses: true,
|
||||
homeworks: true,
|
||||
notifications: true,
|
||||
} as const;
|
||||
|
||||
/** 班级列表(按权限过滤) */
|
||||
export async function listClasses(user: TokenPayload, keyword?: string): Promise<ClassDto[]> {
|
||||
const scope = await resolveClassScope(user);
|
||||
// Class 表按主键 id 过滤(classScopeWhere 针对的是带 classId 字段的子表)
|
||||
const where = {
|
||||
...classScopeIdFilter(scope),
|
||||
...(keyword ? { OR: [{ name: { contains: keyword } }, { grade: { contains: keyword } }] } : {}),
|
||||
};
|
||||
|
||||
const classes = await prisma.class.findMany({
|
||||
where,
|
||||
include: {
|
||||
teacher: { select: { id: true, name: true, username: true } },
|
||||
_count: { select: countSelect },
|
||||
},
|
||||
orderBy: [{ grade: 'asc' }, { name: 'asc' }],
|
||||
});
|
||||
|
||||
return classes.map(toClassDto);
|
||||
}
|
||||
|
||||
/** 班级详情:学生名单 + 课程 + 协作教师 */
|
||||
export async function getClassDetail(user: TokenPayload, classId: string): Promise<ClassDetailDto> {
|
||||
await assertClassAccess(user, classId);
|
||||
|
||||
const item = await prisma.class.findUnique({
|
||||
where: { id: classId },
|
||||
include: {
|
||||
teacher: { select: { id: true, name: true, username: true } },
|
||||
_count: { select: countSelect },
|
||||
students: { orderBy: { username: 'asc' } },
|
||||
courses: {
|
||||
orderBy: { name: 'asc' },
|
||||
include: { teacher: { select: { id: true, name: true, username: true } } },
|
||||
},
|
||||
teachers: {
|
||||
include: { teacher: { select: { id: true, name: true, username: true } } },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!item) throw ApiError.notFound('班级不存在');
|
||||
|
||||
return {
|
||||
...toClassDto(item),
|
||||
students: item.students.map((student) => toStudentDto(student)),
|
||||
courses: item.courses.map(toCourseDto),
|
||||
teachers: item.teachers.map((assignment) => toTeacherBrief(assignment.teacher)),
|
||||
};
|
||||
}
|
||||
|
||||
/** 创建班级 */
|
||||
export async function createClass(user: TokenPayload, input: CreateClassInput): Promise<ClassDto> {
|
||||
let teacherId = user.sub;
|
||||
if (input.teacherId && user.role === 'ADMIN') {
|
||||
const teacher = await prisma.user.findUnique({ where: { id: input.teacherId } });
|
||||
if (!teacher || (teacher.role !== 'TEACHER' && teacher.role !== 'ADMIN')) {
|
||||
throw ApiError.badRequest('指定的班主任不存在或角色不是教师');
|
||||
}
|
||||
teacherId = teacher.id;
|
||||
}
|
||||
|
||||
const created = await prisma.class.create({
|
||||
data: { name: input.name, grade: input.grade, teacherId },
|
||||
include: {
|
||||
teacher: { select: { id: true, name: true, username: true } },
|
||||
_count: { select: countSelect },
|
||||
},
|
||||
});
|
||||
|
||||
return toClassDto(created);
|
||||
}
|
||||
|
||||
/** 编辑班级 */
|
||||
export async function updateClass(
|
||||
user: TokenPayload,
|
||||
classId: string,
|
||||
input: UpdateClassInput,
|
||||
): Promise<ClassDto> {
|
||||
await assertClassWritable(user, classId);
|
||||
|
||||
const updated = await prisma.class.update({
|
||||
where: { id: classId },
|
||||
data: { ...(input.name ? { name: input.name } : {}), ...(input.grade ? { grade: input.grade } : {}) },
|
||||
include: {
|
||||
teacher: { select: { id: true, name: true, username: true } },
|
||||
_count: { select: countSelect },
|
||||
},
|
||||
});
|
||||
|
||||
emitToClass(classId, SOCKET_EVENTS.classUpdated, { classId, action: 'updated' });
|
||||
return toClassDto(updated);
|
||||
}
|
||||
|
||||
/** 删除班级(级联删除课表/作业/通知/成绩) */
|
||||
export async function deleteClass(user: TokenPayload, classId: string): Promise<void> {
|
||||
await assertClassWritable(user, classId);
|
||||
emitToClass(classId, SOCKET_EVENTS.classUpdated, { classId, action: 'deleted' });
|
||||
await prisma.class.delete({ where: { id: classId } });
|
||||
}
|
||||
|
||||
/** 班级学生名单 */
|
||||
export async function listClassStudents(user: TokenPayload, classId: string): Promise<StudentDto[]> {
|
||||
await assertClassAccess(user, classId);
|
||||
const students = await prisma.user.findMany({
|
||||
where: { classId, role: 'STUDENT' },
|
||||
include: { class: { select: { name: true, grade: true } } },
|
||||
orderBy: { username: 'asc' },
|
||||
});
|
||||
return students.map(toStudentDto);
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加学生到班级。
|
||||
* 已存在的学生账号直接转入本班(同时维护 Enrollment,保证"一个学生一个主班级")。
|
||||
* 新账号使用 DEFAULT_STUDENT_PASSWORD 作为初始密码。
|
||||
*/
|
||||
export async function addStudent(
|
||||
user: TokenPayload,
|
||||
classId: string,
|
||||
input: AddStudentInput,
|
||||
): Promise<StudentDto> {
|
||||
await assertClassWritable(user, classId);
|
||||
|
||||
const existing = await prisma.user.findUnique({ where: { username: input.username } });
|
||||
let studentId: string;
|
||||
|
||||
if (existing) {
|
||||
if (existing.role !== 'STUDENT') {
|
||||
throw ApiError.conflict(`用户名 ${input.username} 已被教师/管理员账号占用`);
|
||||
}
|
||||
studentId = existing.id;
|
||||
// 换班:清理其他班级的选课记录
|
||||
await prisma.enrollment.deleteMany({ where: { userId: existing.id, classId: { not: classId } } });
|
||||
await prisma.user.update({
|
||||
where: { id: existing.id },
|
||||
data: { classId, name: input.name },
|
||||
});
|
||||
} else {
|
||||
const created = await prisma.user.create({
|
||||
data: {
|
||||
username: input.username,
|
||||
name: input.name,
|
||||
role: 'STUDENT',
|
||||
classId,
|
||||
passwordHash: await hashPassword(input.password ?? env.defaultStudentPassword),
|
||||
},
|
||||
});
|
||||
studentId = created.id;
|
||||
}
|
||||
|
||||
await prisma.enrollment.upsert({
|
||||
where: { userId_classId: { userId: studentId, classId } },
|
||||
create: { userId: studentId, classId },
|
||||
update: {},
|
||||
});
|
||||
|
||||
const student = await prisma.user.findUniqueOrThrow({
|
||||
where: { id: studentId },
|
||||
include: { class: { select: { name: true, grade: true } } },
|
||||
});
|
||||
|
||||
emitToClass(classId, SOCKET_EVENTS.classUpdated, { classId, action: 'updated' });
|
||||
return toStudentDto(student);
|
||||
}
|
||||
|
||||
/** 将学生移出班级(保留账号) */
|
||||
export async function removeStudent(user: TokenPayload, classId: string, studentId: string): Promise<void> {
|
||||
await assertClassWritable(user, classId);
|
||||
|
||||
const student = await prisma.user.findUnique({ where: { id: studentId } });
|
||||
if (!student || student.role !== 'STUDENT') throw ApiError.notFound('学生不存在');
|
||||
if (student.classId !== classId) throw ApiError.badRequest('该学生不属于此班级');
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.user.update({ where: { id: studentId }, data: { classId: null } }),
|
||||
prisma.enrollment.deleteMany({ where: { userId: studentId, classId } }),
|
||||
]);
|
||||
|
||||
emitToClass(classId, SOCKET_EVENTS.classUpdated, { classId, action: 'updated' });
|
||||
}
|
||||
|
||||
/** 分配协作教师 */
|
||||
export async function assignTeacher(user: TokenPayload, classId: string, teacherId: string): Promise<void> {
|
||||
await assertClassWritable(user, classId);
|
||||
|
||||
const teacher = await prisma.user.findUnique({ where: { id: teacherId } });
|
||||
if (!teacher || (teacher.role !== 'TEACHER' && teacher.role !== 'ADMIN')) {
|
||||
throw ApiError.badRequest('目标账号不存在或角色不是教师');
|
||||
}
|
||||
|
||||
await prisma.classTeacher.upsert({
|
||||
where: { classId_teacherId: { classId, teacherId } },
|
||||
create: { classId, teacherId },
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
|
||||
/** 取消协作教师(班主任不可被移除) */
|
||||
export async function removeTeacher(user: TokenPayload, classId: string, teacherId: string): Promise<void> {
|
||||
await assertClassWritable(user, classId);
|
||||
|
||||
const target = await prisma.class.findUnique({ where: { id: classId }, select: { teacherId: true } });
|
||||
if (target?.teacherId === teacherId) throw ApiError.badRequest('班主任不能被移除,请先转移班级');
|
||||
|
||||
await prisma.classTeacher.deleteMany({ where: { classId, teacherId } });
|
||||
}
|
||||
Reference in new issue
Block a user