feat: 班级小助手 阶段1-3(monorepo + 后端 REST/Socket.IO + Web 管理端)

This commit is contained in:
LoveChengke committed 2026-09-11 21:57:38 +08:00
commit 84ab49de10
102 files changed
+14751

No files matched your search

@@ -0,0 +1,48 @@
import { Router } from 'express';
import type { LoginResponse } from '@classhelper/shared';
import { sendOk } from '../../lib/http.js';
import { authenticate, getAuthUser } from '../../middleware/auth.js';
import { validate } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import {
changePasswordSchema,
loginSchema,
type ChangePasswordInput,
type LoginInput,
} from './auth.schemas.js';
import * as authService from './auth.service.js';
const router = Router();
/** POST /api/auth/login - 登录(公开) */
router.post('/login', validate({ body: loginSchema }), async (req, res) => {
const result: LoginResponse = await authService.login(req.body as LoginInput);
sendOk(res, result, '登录成功');
});
/** GET /api/auth/me - 当前用户信息 */
router.get('/me', authenticate(), async (req, res) => {
const user = getAuthUser(req);
sendOk(res, await authService.getProfile(user.sub), '获取当前用户成功');
});
/** PATCH /api/auth/password - 修改密码 */
router.patch('/password', authenticate(), validate({ body: changePasswordSchema }), async (req, res) => {
const user = getAuthUser(req);
await authService.changePassword(user.sub, req.body as ChangePasswordInput);
sendOk(res, { changed: true }, '密码修改成功,请重新登录');
});
/**
* POST /api/auth/logout - 退出登录。
* JWT 为无状态凭证,服务端不做黑名单,客户端丢弃 token 即可。
*/
router.post('/logout', authenticate(), (_req, res) => {
sendOk(res, { loggedOut: true }, '已退出登录');
});
export const authModule = defineModule({
name: 'auth',
basePath: '/auth',
router,
});
@@ -0,0 +1,14 @@
import { z } from 'zod';
export const loginSchema = z.object({
username: z.string().trim().min(1, '请输入用户名').max(64),
password: z.string().min(1, '请输入密码').max(128),
});
export const changePasswordSchema = z.object({
currentPassword: z.string().min(1, '请输入当前密码'),
newPassword: z.string().min(6, '新密码至少 6 位').max(128),
});
export type LoginInput = z.infer<typeof loginSchema>;
export type ChangePasswordInput = z.infer<typeof changePasswordSchema>;
@@ -0,0 +1,50 @@
import type { LoginResponse, StudentDto, UserRole } from '@classhelper/shared';
import { prisma } from '../../lib/db.js';
import { ApiError } from '../../lib/http.js';
import { signToken } from '../../lib/jwt.js';
import { toStudentDto, toUserDto } from '../../lib/mappers.js';
import { hashPassword, verifyPassword } from '../../lib/password.js';
import type { ChangePasswordInput, LoginInput } from './auth.schemas.js';
/** 登录:校验用户名密码并签发 JWT */
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('用户名或密码错误');
const token = signToken({
sub: user.id,
username: user.username,
name: user.name,
role: user.role as UserRole,
classId: user.classId,
});
return { token, user: toUserDto(user) };
}
/** 当前用户信息(学生额外带班级名与年级) */
export async function getProfile(userId: string): Promise<StudentDto> {
const user = await prisma.user.findUnique({
where: { id: userId },
include: { class: { select: { name: true, grade: true } } },
});
if (!user) throw ApiError.unauthorized('账号不存在或已被删除');
return toStudentDto(user);
}
/** 修改自己的密码 */
export async function changePassword(userId: string, input: ChangePasswordInput): Promise<void> {
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) throw ApiError.unauthorized('账号不存在或已被删除');
const matched = await verifyPassword(input.currentPassword, user.passwordHash);
if (!matched) throw ApiError.badRequest('当前密码不正确');
await prisma.user.update({
where: { id: userId },
data: { passwordHash: await hashPassword(input.newPassword) },
});
}
@@ -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 } });
}
@@ -0,0 +1,66 @@
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 {
createCourseSchema,
listCoursesQuerySchema,
updateCourseSchema,
type CreateCourseInput,
type UpdateCourseInput,
} from './courses.schemas.js';
import * as courseService from './courses.service.js';
const router = Router();
router.use(authenticate());
/** GET /api/courses?classId= - 课程列表 */
router.get('/', validate({ query: listCoursesQuerySchema }), async (req, res) => {
const user = getAuthUser(req);
const { classId } = validatedQuery<{ classId?: string }>(req);
sendOk(res, await courseService.listCourses(user, classId), '获取课程列表成功');
});
/** POST /api/courses - 新增课程 */
router.post(
'/',
requireRole('ADMIN', 'TEACHER'),
validate({ body: createCourseSchema }),
async (req, res) => {
const user = getAuthUser(req);
sendCreated(res, await courseService.createCourse(user, req.body as CreateCourseInput), '课程创建成功');
},
);
/** PATCH /api/courses/:id - 修改课程 */
router.patch(
'/:id',
requireRole('ADMIN', 'TEACHER'),
validate({ params: idParamSchema, body: updateCourseSchema }),
async (req, res) => {
const user = getAuthUser(req);
const { id } = validatedParams<{ id: string }>(req);
sendOk(res, await courseService.updateCourse(user, id, req.body as UpdateCourseInput), '课程更新成功');
},
);
/** DELETE /api/courses/:id - 删除课程 */
router.delete(
'/:id',
requireRole('ADMIN', 'TEACHER'),
validate({ params: idParamSchema }),
async (req, res) => {
const user = getAuthUser(req);
const { id } = validatedParams<{ id: string }>(req);
await courseService.deleteCourse(user, id);
sendOk(res, { id }, '课程已删除');
},
);
export const coursesModule = defineModule({
name: 'courses',
basePath: '/courses',
router,
});
@@ -0,0 +1,17 @@
import { z } from 'zod';
export const createCourseSchema = z.object({
name: z.string().trim().min(1, '请输入课程名称').max(64),
classId: z.string().min(1, '请选择班级'),
});
export const updateCourseSchema = z.object({
name: z.string().trim().min(1).max(64).optional(),
});
export const listCoursesQuerySchema = z.object({
classId: z.string().min(1).optional(),
});
export type CreateCourseInput = z.infer<typeof createCourseSchema>;
export type UpdateCourseInput = z.infer<typeof updateCourseSchema>;
@@ -0,0 +1,53 @@
import type { CourseDto } from '@classhelper/shared';
import { assertClassWritable, 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';
import { toCourseDto } from '../../lib/mappers.js';
import type { CreateCourseInput, UpdateCourseInput } from './courses.schemas.js';
const teacherSelect = { select: { id: true, name: true, username: true } } as const;
export async function listCourses(user: TokenPayload, classId?: string): Promise<CourseDto[]> {
const scope = await resolveClassScope(user, classId);
const courses = await prisma.course.findMany({
where: classScopeWhere(scope),
include: { teacher: teacherSelect },
orderBy: [{ classId: 'asc' }, { name: 'asc' }],
});
return courses.map(toCourseDto);
}
export async function createCourse(user: TokenPayload, input: CreateCourseInput): Promise<CourseDto> {
await assertClassWritable(user, input.classId);
const created = await prisma.course.create({
data: { name: input.name, classId: input.classId, teacherId: user.sub },
include: { teacher: teacherSelect },
});
return toCourseDto(created);
}
export async function updateCourse(
user: TokenPayload,
courseId: string,
input: UpdateCourseInput,
): Promise<CourseDto> {
const course = await prisma.course.findUnique({ where: { id: courseId } });
if (!course) throw ApiError.notFound('课程不存在');
await assertClassWritable(user, course.classId);
const updated = await prisma.course.update({
where: { id: courseId },
data: { ...(input.name ? { name: input.name } : {}) },
include: { teacher: teacherSelect },
});
return toCourseDto(updated);
}
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 prisma.course.delete({ where: { id: courseId } });
}
@@ -0,0 +1,26 @@
import { Router } from 'express';
import { sendOk } from '../../lib/http.js';
import { resolveCurrentWeek, MAX_TERM_WEEK } from '../../lib/term.js';
import { authenticate, getAuthUser } from '../../middleware/auth.js';
import { defineModule } from '../module.types.js';
import * as dashboardService from './dashboard.service.js';
const router = Router();
router.use(authenticate());
/** GET /api/dashboard/summary - 首页汇总(按角色权限收敛数据范围) */
router.get('/summary', async (req, res) => {
const user = getAuthUser(req);
sendOk(res, await dashboardService.getDashboardSummary(user), '获取仪表盘数据成功');
});
/** GET /api/dashboard/term - 学期信息(当前教学周) */
router.get('/term', (_req, res) => {
sendOk(res, { currentWeek: resolveCurrentWeek(), maxWeek: MAX_TERM_WEEK }, '获取学期信息成功');
});
export const dashboardModule = defineModule({
name: 'dashboard',
basePath: '/dashboard',
router,
});
@@ -0,0 +1,104 @@
import type { DashboardSummary, HomeworkDto, NotificationDto } from '@classhelper/shared';
import {
classScopeIdFilter,
classScopeWhere,
isStudent,
resolveClassScope,
requireStudentClassId,
} from '../../lib/access.js';
import { prisma } from '../../lib/db.js';
import type { TokenPayload } from '../../lib/jwt.js';
import { toHomeworkDto, toNotificationDto } from '../../lib/mappers.js';
const courseSelect = { select: { id: true, name: true } } as const;
const creatorSelect = { select: { id: true, name: true, username: true } } as const;
/**
* 仪表盘汇总。
* 教师/管理员看到的是"自己管理的班级"的汇总;学生看到自己班级的汇总。
*/
export async function getDashboardSummary(user: TokenPayload): Promise<DashboardSummary> {
const student = isStudent(user);
// 学生强制限定在自己班级,避免越权统计
const scope = await resolveClassScope(user, student ? requireStudentClassId(user) : undefined);
// 子表按 classId 过滤;Class 表本身按主键 id 过滤
const where = classScopeWhere(scope);
const classWhere = classScopeIdFilter(scope);
const now = new Date();
const [
classCount,
studentCount,
courseCount,
scheduleCount,
homeworkCount,
notificationCount,
gradeCount,
unreadNotificationCount,
pendingHomeworkCount,
recentNotifications,
recentHomeworks,
upcoming,
] = await Promise.all([
prisma.class.count({ where: classWhere }),
prisma.user.count({ where: { ...where, role: 'STUDENT' } }),
prisma.course.count({ where }),
prisma.schedule.count({ where }),
prisma.homework.count({ where }),
prisma.notification.count({ where }),
prisma.grade.count({ where }),
prisma.notification.count({ where: { ...where, reads: { none: { userId: user.sub } } } }),
student
? prisma.homework.count({
where: { ...where, statuses: { none: { userId: user.sub, completed: true } } },
})
: prisma.homework.count({ where: { ...where, dueAt: { gte: now } } }),
prisma.notification.findMany({
where,
include: { creator: creatorSelect, reads: { where: { userId: user.sub } } },
orderBy: { createdAt: 'desc' },
take: 5,
}),
prisma.homework.findMany({
where,
include: {
course: courseSelect,
creator: creatorSelect,
statuses: student ? { where: { userId: user.sub } } : true,
},
orderBy: { createdAt: 'desc' },
take: 5,
}),
prisma.homework.findMany({
where: { ...where, dueAt: { gte: now } },
include: {
course: courseSelect,
creator: creatorSelect,
statuses: student ? { where: { userId: user.sub } } : true,
},
orderBy: { dueAt: 'asc' },
take: 5,
}),
]);
return {
classCount,
studentCount,
courseCount,
scheduleCount,
homeworkCount,
notificationCount,
gradeCount,
unreadNotificationCount,
pendingHomeworkCount,
recentNotifications: recentNotifications.map((item): NotificationDto =>
toNotificationDto(item, { userId: user.sub }),
),
recentHomeworks: recentHomeworks.map((item): HomeworkDto =>
toHomeworkDto(item, { userId: student ? user.sub : null, withStatus: !student }),
),
upcomingDeadlines: upcoming.map((item): HomeworkDto =>
toHomeworkDto(item, { userId: student ? user.sub : null, withStatus: !student }),
),
};
}
@@ -0,0 +1,105 @@
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 {
bulkCreateGradeSchema,
createGradeSchema,
listGradesQuerySchema,
statsQuerySchema,
updateGradeSchema,
type BulkCreateGradeInput,
type CreateGradeInput,
type UpdateGradeInput,
} from './grades.schemas.js';
import * as gradeService from './grades.service.js';
const router = Router();
router.use(authenticate());
/** GET /api/grades/my - 学生查看个人成绩(任意角色都可查自己的) */
router.get('/my', async (req, res) => {
const user = getAuthUser(req);
sendOk(res, await gradeService.listMyGrades(user), '获取个人成绩成功');
});
/** GET /api/grades/stats?classId=&courseId=&examName= - 成绩统计(图表) */
router.get(
'/stats',
requireRole('ADMIN', 'TEACHER'),
validate({ query: statsQuerySchema }),
async (req, res) => {
const user = getAuthUser(req);
const query = validatedQuery<{ classId?: string; courseId?: string; examName?: string }>(req);
sendOk(res, await gradeService.getGradeStats(user, query), '获取成绩统计成功');
},
);
/** GET /api/grades?classId=&courseId=&userId=&examName= - 成绩列表(教师) */
router.get(
'/',
requireRole('ADMIN', 'TEACHER'),
validate({ query: listGradesQuerySchema }),
async (req, res) => {
const user = getAuthUser(req);
const query = validatedQuery<{ classId?: string; courseId?: string; userId?: string; examName?: string }>(
req,
);
sendOk(res, await gradeService.listGrades(user, query), '获取成绩列表成功');
},
);
/** POST /api/grades - 录入单条成绩(广播 grade:updated) */
router.post('/', requireRole('ADMIN', 'TEACHER'), validate({ body: createGradeSchema }), async (req, res) => {
const user = getAuthUser(req);
sendCreated(
res,
await gradeService.createGrade(user, req.body as CreateGradeInput),
'成绩录入成功,已实时推送',
);
});
/** 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} 条成绩`);
},
);
/** PATCH /api/grades/:id - 修改成绩 */
router.patch(
'/:id',
requireRole('ADMIN', 'TEACHER'),
validate({ params: idParamSchema, body: updateGradeSchema }),
async (req, res) => {
const user = getAuthUser(req);
const { id } = validatedParams<{ id: string }>(req);
sendOk(res, await gradeService.updateGrade(user, id, req.body as UpdateGradeInput), '成绩更新成功');
},
);
/** 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 }, '成绩已删除');
},
);
export const gradesModule = defineModule({
name: 'grades',
basePath: '/grades',
router,
});
@@ -0,0 +1,54 @@
import { z } from 'zod';
import { optionalDateTimeSchema } from '../../lib/schemas.js';
export const listGradesQuerySchema = z.object({
classId: z.string().min(1).optional(),
courseId: z.string().min(1).optional(),
userId: z.string().min(1).optional(),
examName: z.string().trim().max(64).optional(),
});
export const statsQuerySchema = z.object({
classId: z.string().min(1).optional(),
courseId: z.string().min(1).optional(),
examName: z.string().trim().max(64).optional(),
});
export const createGradeSchema = z.object({
classId: z.string().min(1, '请选择班级'),
courseId: z.string().min(1).nullish(),
userId: z.string().min(1, '请选择学生'),
examName: z.string().trim().min(1, '请输入考试名称').max(64),
score: z.coerce.number().min(0, '分数不能为负数'),
totalScore: z.coerce.number().positive('总分必须大于 0').optional(),
publishedAt: optionalDateTimeSchema,
});
export const updateGradeSchema = z.object({
examName: z.string().trim().min(1).max(64).optional(),
score: z.coerce.number().min(0).optional(),
totalScore: z.coerce.number().positive().optional(),
courseId: z.string().min(1).nullish(),
publishedAt: optionalDateTimeSchema,
});
/** 批量录入 / 导入成绩 */
export const bulkCreateGradeSchema = z.object({
classId: z.string().min(1),
courseId: z.string().min(1).nullish(),
examName: z.string().trim().min(1).max(64),
totalScore: z.coerce.number().positive().optional(),
publishedAt: optionalDateTimeSchema,
items: z
.array(
z.object({
userId: z.string().min(1),
score: z.coerce.number().min(0),
}),
)
.min(1, '至少需要一条成绩记录'),
});
export type CreateGradeInput = z.infer<typeof createGradeSchema>;
export type UpdateGradeInput = z.infer<typeof updateGradeSchema>;
export type BulkCreateGradeInput = z.infer<typeof bulkCreateGradeSchema>;
@@ -0,0 +1,241 @@
import {
SOCKET_EVENTS,
gradeLevel,
gradePercent,
type GradeDto,
type GradeLevel,
type GradeStats,
} from '@classhelper/shared';
import { assertClassWritable, 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';
import { toGradeDto } from '../../lib/mappers.js';
import { parseOptionalDate } from '../../lib/schemas.js';
import { emitToClass, emitToUser } from '../../realtime/bus.js';
import type { BulkCreateGradeInput, CreateGradeInput, UpdateGradeInput } from './grades.schemas.js';
const courseSelect = { select: { id: true, name: true } } as const;
const studentSelect = { select: { id: true, name: true, username: true } } as const;
const classSelect = { select: { name: true } } as const;
const include = { course: courseSelect, student: studentSelect, class: classSelect } as const;
export interface ListGradeOptions {
classId?: string;
courseId?: string;
userId?: string;
examName?: string;
}
/** 学生查看自己的成绩 */
export async function listMyGrades(user: TokenPayload): Promise<GradeDto[]> {
const grades = await prisma.grade.findMany({
where: { userId: user.sub },
include,
orderBy: [{ publishedAt: 'desc' }],
take: 300,
});
return grades.map(toGradeDto);
}
/** 教师查看班级成绩 */
export async function listGrades(user: TokenPayload, options: ListGradeOptions): Promise<GradeDto[]> {
const scope = await resolveClassScope(user, options.classId);
const grades = await prisma.grade.findMany({
where: {
...classScopeWhere(scope),
...(options.courseId ? { courseId: options.courseId } : {}),
...(options.userId ? { userId: options.userId } : {}),
...(options.examName ? { examName: options.examName } : {}),
},
include,
orderBy: [{ publishedAt: 'desc' }, { examName: 'asc' }],
take: 500,
});
return grades.map(toGradeDto);
}
/** 成绩统计(等级分布 + 各课程平均得分率),供 Web 端图表使用 */
export async function getGradeStats(user: TokenPayload, options: ListGradeOptions): Promise<GradeStats> {
const grades = await listGrades(user, options);
const levels: GradeLevel[] = ['A', 'B', 'C', 'D', 'E'];
const distribution = levels.map((level) => ({ level, count: 0 }));
const byCourseMap = new Map<string, { courseId: string | null; courseName: string; percents: number[] }>();
for (const grade of grades) {
const percent = gradePercent(grade.score, grade.totalScore);
const bucket = distribution.find((item) => item.level === gradeLevel(percent));
if (bucket) bucket.count += 1;
const key = grade.courseId ?? '__none__';
const entry = byCourseMap.get(key) ?? {
courseId: grade.courseId,
courseName: grade.course?.name ?? '未关联课程',
percents: [],
};
entry.percents.push(percent);
byCourseMap.set(key, entry);
}
const allPercents = grades.map((grade) => gradePercent(grade.score, grade.totalScore));
return {
total: grades.length,
averagePercent:
allPercents.length === 0
? 0
: Math.round((allPercents.reduce((sum, value) => sum + value, 0) / allPercents.length) * 10) / 10,
byCourse: [...byCourseMap.values()].map((entry) => ({
courseId: entry.courseId,
courseName: entry.courseName,
count: entry.percents.length,
averagePercent:
Math.round((entry.percents.reduce((sum, value) => sum + value, 0) / entry.percents.length) * 10) / 10,
})),
distribution,
};
}
export async function createGrade(user: TokenPayload, input: CreateGradeInput): Promise<GradeDto> {
await assertClassWritable(user, input.classId);
await assertStudentInClass(input.userId, input.classId);
if (input.courseId) await assertCourseInClass(input.courseId, input.classId);
const dto = await upsertGrade({
classId: input.classId,
courseId: input.courseId ?? null,
userId: input.userId,
examName: input.examName,
score: input.score,
totalScore: input.totalScore ?? 100,
publishedAt: parseOptionalDate(input.publishedAt) ?? new Date(),
});
emitToClass(dto.classId, SOCKET_EVENTS.gradeUpdated, dto);
emitToUser(dto.userId, SOCKET_EVENTS.gradeUpdated, dto);
return dto;
}
/** 批量录入:同一个考试一次提交整班成绩 */
export async function bulkCreateGrades(user: TokenPayload, input: BulkCreateGradeInput): Promise<GradeDto[]> {
await assertClassWritable(user, input.classId);
if (input.courseId) await assertCourseInClass(input.courseId, input.classId);
const publishedAt = parseOptionalDate(input.publishedAt) ?? new Date();
const results: GradeDto[] = [];
for (const item of input.items) {
await assertStudentInClass(item.userId, input.classId);
const dto = await upsertGrade({
classId: input.classId,
courseId: input.courseId ?? null,
userId: item.userId,
examName: input.examName,
score: item.score,
totalScore: input.totalScore ?? 100,
publishedAt,
});
results.push(dto);
}
// 一次广播覆盖整班(房间内包含全部学生)
emitToClass(input.classId, SOCKET_EVENTS.gradeUpdated, {
...results[0],
bulk: true,
count: results.length,
});
return results;
}
export async function updateGrade(
user: TokenPayload,
gradeId: string,
input: UpdateGradeInput,
): Promise<GradeDto> {
const current = await prisma.grade.findUnique({ where: { id: gradeId } });
if (!current) throw ApiError.notFound('成绩记录不存在');
await assertClassWritable(user, current.classId);
if (input.courseId) await assertCourseInClass(input.courseId, current.classId);
const updated = await prisma.grade.update({
where: { id: gradeId },
data: {
...(input.examName ? { examName: input.examName } : {}),
...(input.score !== undefined ? { score: input.score } : {}),
...(input.totalScore !== undefined ? { totalScore: input.totalScore } : {}),
...(input.courseId !== undefined ? { courseId: input.courseId ?? null } : {}),
...(input.publishedAt !== undefined
? { publishedAt: parseOptionalDate(input.publishedAt) ?? new Date() }
: {}),
},
include,
});
const dto = toGradeDto(updated);
emitToClass(dto.classId, SOCKET_EVENTS.gradeUpdated, dto);
emitToUser(dto.userId, SOCKET_EVENTS.gradeUpdated, dto);
return dto;
}
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);
await prisma.grade.delete({ where: { id: gradeId } });
emitToClass(current.classId, SOCKET_EVENTS.gradeUpdated, {
...toGradeDto({ ...current, course: null, student: null, class: null }),
deleted: true,
});
}
/**
* 同一学生 + 同一课程 + 同一考试名称视为同一条记录,
* 重复录入时覆盖旧分数,避免教师"改分"产生重复行。
*/
async function upsertGrade(data: {
classId: string;
courseId: string | null;
userId: string;
examName: string;
score: number;
totalScore: number;
publishedAt: Date;
}): Promise<GradeDto> {
const existing = await prisma.grade.findFirst({
where: {
classId: data.classId,
courseId: data.courseId,
userId: data.userId,
examName: data.examName,
},
select: { id: true },
});
if (existing) {
const updated = await prisma.grade.update({
where: { id: existing.id },
data: { score: data.score, totalScore: data.totalScore, publishedAt: data.publishedAt },
include,
});
return toGradeDto(updated);
}
const created = await prisma.grade.create({ data, include });
return toGradeDto(created);
}
async function assertStudentInClass(userId: string, classId: string): Promise<void> {
const student = await prisma.user.findUnique({
where: { id: userId },
select: { role: true, classId: true },
});
if (!student || student.role !== 'STUDENT') throw ApiError.badRequest('学生不存在');
if (student.classId !== classId) throw ApiError.badRequest('该学生不属于所选班级');
}
async function assertCourseInClass(courseId: string, classId: string): Promise<void> {
const course = await prisma.course.findUnique({ where: { id: courseId }, select: { classId: true } });
if (!course) throw ApiError.badRequest('课程不存在');
if (course.classId !== classId) throw ApiError.badRequest('该课程不属于所选班级');
}
@@ -0,0 +1,104 @@
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 {
createHomeworkSchema,
listHomeworksQuerySchema,
updateHomeworkSchema,
updateHomeworkStatusSchema,
type CreateHomeworkInput,
type UpdateHomeworkInput,
type UpdateHomeworkStatusInput,
} from './homeworks.schemas.js';
import * as homeworkService from './homeworks.service.js';
const router = Router();
router.use(authenticate());
/** GET /api/homeworks?classId=&courseId=&pendingOnly=&keyword= - 作业列表 */
router.get('/', validate({ query: listHomeworksQuerySchema }), async (req, res) => {
const user = getAuthUser(req);
const query = validatedQuery<{
classId?: string;
courseId?: string;
pendingOnly: boolean;
keyword?: string;
}>(req);
sendOk(res, await homeworkService.listHomeworks(user, query), '获取作业列表成功');
});
/** GET /api/homeworks/:id - 作业详情 */
router.get('/:id', validate({ params: idParamSchema }), async (req, res) => {
const user = getAuthUser(req);
const { id } = validatedParams<{ id: string }>(req);
sendOk(res, await homeworkService.getHomework(user, id), '获取作业详情成功');
});
/** POST /api/homeworks - 发布作业(发布后广播 homework:new) */
router.post(
'/',
requireRole('ADMIN', 'TEACHER'),
validate({ body: createHomeworkSchema }),
async (req, res) => {
const user = getAuthUser(req);
sendCreated(
res,
await homeworkService.createHomework(user, req.body as CreateHomeworkInput),
'作业发布成功',
);
},
);
/** PATCH /api/homeworks/:id - 修改作业 */
router.patch(
'/:id',
requireRole('ADMIN', 'TEACHER'),
validate({ params: idParamSchema, body: updateHomeworkSchema }),
async (req, res) => {
const user = getAuthUser(req);
const { id } = validatedParams<{ id: string }>(req);
sendOk(
res,
await homeworkService.updateHomework(user, id, req.body as UpdateHomeworkInput),
'作业更新成功',
);
},
);
/** DELETE /api/homeworks/:id - 删除作业 */
router.delete(
'/:id',
requireRole('ADMIN', 'TEACHER'),
validate({ params: idParamSchema }),
async (req, res) => {
const user = getAuthUser(req);
const { id } = validatedParams<{ id: string }>(req);
await homeworkService.deleteHomework(user, id);
sendOk(res, { id }, '作业已删除');
},
);
/** PATCH /api/homeworks/:id/status - 标记完成 / 取消完成 */
router.patch(
'/:id/status',
validate({ params: idParamSchema, body: updateHomeworkStatusSchema }),
async (req, res) => {
const user = getAuthUser(req);
const { id } = validatedParams<{ id: string }>(req);
const result = await homeworkService.updateHomeworkStatus(
user,
id,
req.body as UpdateHomeworkStatusInput,
);
sendOk(res, result, result.completed ? '已标记为完成' : '已取消完成标记');
},
);
export const homeworksModule = defineModule({
name: 'homeworks',
basePath: '/homeworks',
router,
});
@@ -0,0 +1,37 @@
import { z } from 'zod';
import { booleanFlagSchema, optionalDateTimeSchema } from '../../lib/schemas.js';
export const listHomeworksQuerySchema = z.object({
classId: z.string().min(1).optional(),
courseId: z.string().min(1).optional(),
/** 学生端:只看未完成 */
pendingOnly: booleanFlagSchema,
keyword: z.string().trim().max(64).optional(),
});
export const createHomeworkSchema = z.object({
classId: z.string().min(1, '请选择班级'),
courseId: z.string().min(1).nullish(),
title: z.string().trim().min(1, '请输入作业标题').max(120),
content: z.string().min(1, '请输入作业内容').max(5000),
attachmentUrl: z.string().trim().max(500).nullish(),
dueAt: optionalDateTimeSchema,
});
export const updateHomeworkSchema = z.object({
courseId: z.string().min(1).nullish(),
title: z.string().trim().min(1).max(120).optional(),
content: z.string().min(1).max(5000).optional(),
attachmentUrl: z.string().trim().max(500).nullish(),
dueAt: optionalDateTimeSchema,
});
export const updateHomeworkStatusSchema = z.object({
completed: z.boolean(),
/** 教师代学生标记时使用;学生只能标记自己 */
userId: z.string().min(1).optional(),
});
export type CreateHomeworkInput = z.infer<typeof createHomeworkSchema>;
export type UpdateHomeworkInput = z.infer<typeof updateHomeworkSchema>;
export type UpdateHomeworkStatusInput = z.infer<typeof updateHomeworkStatusSchema>;
@@ -0,0 +1,183 @@
import { SOCKET_EVENTS, type HomeworkDto, type HomeworkStatusDto } from '@classhelper/shared';
import {
assertClassAccess,
assertClassWritable,
classScopeWhere,
isStudent,
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 { toHomeworkDto, toHomeworkStatusDto } from '../../lib/mappers.js';
import { parseOptionalDate } from '../../lib/schemas.js';
import { emitToClass } from '../../realtime/bus.js';
import type {
CreateHomeworkInput,
UpdateHomeworkInput,
UpdateHomeworkStatusInput,
} from './homeworks.schemas.js';
const courseSelect = { select: { id: true, name: true } } as const;
const creatorSelect = { select: { id: true, name: true, username: true } } as const;
export interface ListHomeworkOptions {
classId?: string;
courseId?: string;
pendingOnly?: boolean;
keyword?: string;
}
/**
* 作业列表。
* - 学生:附带自己的完成状态,可通过 pendingOnly 只看未完成
* - 教师/管理员:附带完成人数统计
*/
export async function listHomeworks(
user: TokenPayload,
options: ListHomeworkOptions,
): Promise<HomeworkDto[]> {
const scope = await resolveClassScope(user, options.classId);
const student = isStudent(user);
const homeworks = await prisma.homework.findMany({
where: {
...classScopeWhere(scope),
...(options.courseId ? { courseId: options.courseId } : {}),
...(options.keyword
? { OR: [{ title: { contains: options.keyword } }, { content: { contains: options.keyword } }] }
: {}),
...(student && options.pendingOnly
? { statuses: { none: { userId: user.sub, completed: true } } }
: {}),
},
include: {
course: courseSelect,
creator: creatorSelect,
statuses: student ? { where: { userId: user.sub } } : true,
},
orderBy: { createdAt: 'desc' },
take: 200,
});
return homeworks.map((item) =>
toHomeworkDto(item, { userId: student ? user.sub : null, withStatus: !student }),
);
}
export async function getHomework(user: TokenPayload, homeworkId: string): Promise<HomeworkDto> {
const homework = await prisma.homework.findUnique({
where: { id: homeworkId },
include: {
course: courseSelect,
creator: creatorSelect,
statuses: isStudent(user) ? { where: { userId: user.sub } } : true,
},
});
if (!homework) throw ApiError.notFound('作业不存在');
await assertClassAccess(user, homework.classId);
return toHomeworkDto(homework, { userId: isStudent(user) ? user.sub : null, withStatus: !isStudent(user) });
}
/** 发布作业并实时推送到班级房间 */
export async function createHomework(user: TokenPayload, input: CreateHomeworkInput): Promise<HomeworkDto> {
await assertClassWritable(user, input.classId);
if (input.courseId) await assertCourseInClass(input.courseId, input.classId);
const created = await prisma.homework.create({
data: {
classId: input.classId,
courseId: input.courseId ?? null,
title: input.title,
content: input.content,
attachmentUrl: input.attachmentUrl ?? null,
dueAt: parseOptionalDate(input.dueAt),
createdBy: user.sub,
},
include: { course: courseSelect, creator: creatorSelect, statuses: true },
});
const dto = toHomeworkDto(created, { withStatus: true });
emitToClass(created.classId, SOCKET_EVENTS.homeworkNew, dto);
return dto;
}
export async function updateHomework(
user: TokenPayload,
homeworkId: string,
input: UpdateHomeworkInput,
): Promise<HomeworkDto> {
const current = await prisma.homework.findUnique({ where: { id: homeworkId } });
if (!current) throw ApiError.notFound('作业不存在');
await assertClassWritable(user, current.classId);
if (input.courseId) await assertCourseInClass(input.courseId, current.classId);
const updated = await prisma.homework.update({
where: { id: homeworkId },
data: {
...(input.title ? { title: input.title } : {}),
...(input.content ? { content: input.content } : {}),
...(input.courseId !== undefined ? { courseId: input.courseId ?? null } : {}),
...(input.attachmentUrl !== undefined ? { attachmentUrl: input.attachmentUrl ?? null } : {}),
...(input.dueAt !== undefined ? { dueAt: parseOptionalDate(input.dueAt) } : {}),
},
include: { course: courseSelect, creator: creatorSelect, statuses: true },
});
const dto = toHomeworkDto(updated, { withStatus: true });
emitToClass(updated.classId, SOCKET_EVENTS.homeworkUpdated, dto);
return dto;
}
export async function deleteHomework(user: TokenPayload, homeworkId: string): Promise<void> {
const current = await prisma.homework.findUnique({
where: { id: homeworkId },
include: { course: courseSelect, creator: creatorSelect },
});
if (!current) throw ApiError.notFound('作业不存在');
await assertClassWritable(user, current.classId);
await prisma.homework.delete({ where: { id: homeworkId } });
emitToClass(current.classId, SOCKET_EVENTS.homeworkUpdated, {
...toHomeworkDto(current),
deleted: true,
});
}
/** 标记完成 / 取消完成(学生标记自己;教师可代标记) */
export async function updateHomeworkStatus(
user: TokenPayload,
homeworkId: string,
input: UpdateHomeworkStatusInput,
): Promise<HomeworkStatusDto> {
const homework = await prisma.homework.findUnique({ where: { id: homeworkId } });
if (!homework) throw ApiError.notFound('作业不存在');
const targetUserId = isStudent(user) ? user.sub : (input.userId ?? user.sub);
await assertClassAccess(user, homework.classId);
if (!isStudent(user) && input.userId) {
const target = await prisma.user.findUnique({ where: { id: targetUserId }, select: { classId: true } });
if (!target || target.classId !== homework.classId) {
throw ApiError.badRequest('目标学生不属于该作业所在班级');
}
}
const status = await prisma.homeworkStatus.upsert({
where: { homeworkId_userId: { homeworkId, userId: targetUserId } },
create: { homeworkId, userId: targetUserId, completed: input.completed },
update: { completed: input.completed },
});
const dto = toHomeworkStatusDto(status);
emitToClass(homework.classId, SOCKET_EVENTS.homeworkStatus, { ...dto, classId: homework.classId });
return dto;
}
async function assertCourseInClass(courseId: string, classId: string): Promise<void> {
const course = await prisma.course.findUnique({ where: { id: courseId }, select: { classId: true } });
if (!course) throw ApiError.badRequest('课程不存在');
if (course.classId !== classId) throw ApiError.badRequest('该课程不属于所选班级');
}
@@ -0,0 +1,23 @@
import type { Router } from 'express';
/**
* 模块契约。
*
* app.ts 只做一件事:遍历 registry 里启用的模块并挂载到 `${API_PREFIX}${basePath}`。
* 因此「新增/移除一个功能模块」= 在 registry.ts 里增删一行,
* 不需要改动 app.ts、错误处理或实时通道。
*/
export interface ApiModule {
/** 模块名,用于日志 */
name: string;
/** 挂载路径,例如 '/classes' */
basePath: string;
/** Express 路由 */
router: Router;
/** 置为 false 可临时下线该模块(不出现在路由表中) */
enabled?: boolean;
}
export function defineModule(module: ApiModule): ApiModule {
return module;
}
@@ -0,0 +1,80 @@
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 {
createNotificationSchema,
listNotificationsQuerySchema,
notificationScopeQuerySchema,
type CreateNotificationInput,
} from './notifications.schemas.js';
import * as notificationService from './notifications.service.js';
const router = Router();
router.use(authenticate());
/** GET /api/notifications?classId=&priority=&unreadOnly= - 通知列表 */
router.get('/', validate({ query: listNotificationsQuerySchema }), async (req, res) => {
const user = getAuthUser(req);
const query = validatedQuery<{
classId?: string;
priority?: string;
unreadOnly: boolean;
keyword?: string;
}>(req);
sendOk(res, await notificationService.listNotifications(user, query), '获取通知列表成功');
});
/** GET /api/notifications/unread-count?classId= - 未读数(客户端红点) */
router.get('/unread-count', validate({ query: notificationScopeQuerySchema }), async (req, res) => {
const user = getAuthUser(req);
const { classId } = validatedQuery<{ classId?: string }>(req);
sendOk(res, { count: await notificationService.unreadCount(user, classId) }, '获取未读数成功');
});
/** POST /api/notifications/read-all - 全部标为已读 */
router.post('/read-all', validate({ body: notificationScopeQuerySchema }), async (req, res) => {
const user = getAuthUser(req);
const { classId } = (req.body ?? {}) as { classId?: string };
sendOk(res, await notificationService.markAllAsRead(user, classId), '已全部标为已读');
});
/** POST /api/notifications - 发布通知(发布后广播 notification:new) */
router.post(
'/',
requireRole('ADMIN', 'TEACHER'),
validate({ body: createNotificationSchema }),
async (req, res) => {
const user = getAuthUser(req);
const dto = await notificationService.createNotification(user, req.body as CreateNotificationInput);
sendCreated(res, dto, '通知发布成功,已实时推送给学生');
},
);
/** POST /api/notifications/:id/read - 标记已读 */
router.post('/:id/read', validate({ params: idParamSchema }), async (req, res) => {
const user = getAuthUser(req);
const { id } = validatedParams<{ id: string }>(req);
sendOk(res, await notificationService.markAsRead(user, id), '已标记为已读');
});
/** DELETE /api/notifications/:id - 删除通知 */
router.delete(
'/:id',
requireRole('ADMIN', 'TEACHER'),
validate({ params: idParamSchema }),
async (req, res) => {
const user = getAuthUser(req);
const { id } = validatedParams<{ id: string }>(req);
await notificationService.deleteNotification(user, id);
sendOk(res, { id }, '通知已删除');
},
);
export const notificationsModule = defineModule({
name: 'notifications',
basePath: '/notifications',
router,
});
@@ -0,0 +1,25 @@
import { z } from 'zod';
import { booleanFlagSchema } from '../../lib/schemas.js';
export const notificationPrioritySchema = z.enum(['LOW', 'NORMAL', 'HIGH', 'URGENT']);
export const listNotificationsQuerySchema = z.object({
classId: z.string().min(1).optional(),
priority: notificationPrioritySchema.optional(),
unreadOnly: booleanFlagSchema,
keyword: z.string().trim().max(64).optional(),
});
export const createNotificationSchema = z.object({
classId: z.string().min(1, '请选择班级'),
title: z.string().trim().min(1, '请输入通知标题').max(120),
content: z.string().min(1, '请输入通知内容').max(5000),
priority: notificationPrioritySchema.optional(),
});
export const notificationScopeQuerySchema = z.object({
classId: z.string().min(1).optional(),
});
export type CreateNotificationInput = z.infer<typeof createNotificationSchema>;
export type NotificationPriorityInput = z.infer<typeof notificationPrioritySchema>;
@@ -0,0 +1,125 @@
import { SOCKET_EVENTS, type NotificationDto } from '@classhelper/shared';
import {
assertClassAccess,
assertClassWritable,
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';
import { toNotificationDto } from '../../lib/mappers.js';
import { emitToClass } from '../../realtime/bus.js';
import type { CreateNotificationInput } from './notifications.schemas.js';
const creatorSelect = { select: { id: true, name: true, username: true } } as const;
export interface ListNotificationOptions {
classId?: string;
priority?: string;
unreadOnly?: boolean;
keyword?: string;
}
export async function listNotifications(
user: TokenPayload,
options: ListNotificationOptions,
): Promise<NotificationDto[]> {
const scope = await resolveClassScope(user, options.classId);
const isStaff = user.role === 'ADMIN' || user.role === 'TEACHER';
const notifications = await prisma.notification.findMany({
where: {
...classScopeWhere(scope),
...(options.priority ? { priority: options.priority } : {}),
...(options.keyword
? { OR: [{ title: { contains: options.keyword } }, { content: { contains: options.keyword } }] }
: {}),
...(options.unreadOnly ? { reads: { none: { userId: user.sub } } } : {}),
},
include: {
creator: creatorSelect,
reads: isStaff ? true : { where: { userId: user.sub } },
},
orderBy: [{ createdAt: 'desc' }],
take: 200,
});
return notifications.map((item) => toNotificationDto(item, { userId: user.sub, withStatus: isStaff }));
}
/** 发布通知并实时广播到 class:{classId} 房间 */
export async function createNotification(
user: TokenPayload,
input: CreateNotificationInput,
): Promise<NotificationDto> {
await assertClassWritable(user, input.classId);
const created = await prisma.notification.create({
data: {
classId: input.classId,
title: input.title,
content: input.content,
priority: input.priority ?? 'NORMAL',
createdBy: user.sub,
},
include: { creator: creatorSelect, reads: true },
});
const dto = toNotificationDto(created, { userId: user.sub, withStatus: true });
emitToClass(created.classId, SOCKET_EVENTS.notificationNew, dto);
return dto;
}
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 prisma.notification.delete({ where: { id: notificationId } });
}
/** 标记单条通知已读 */
export async function markAsRead(
user: TokenPayload,
notificationId: string,
): Promise<{ notificationId: string; readAt: string }> {
const notification = await prisma.notification.findUnique({ where: { id: notificationId } });
if (!notification) throw ApiError.notFound('通知不存在');
await assertClassAccess(user, notification.classId);
const read = await prisma.notificationRead.upsert({
where: { notificationId_userId: { notificationId, userId: user.sub } },
create: { notificationId, userId: user.sub },
update: {},
});
return { notificationId, readAt: read.readAt.toISOString() };
}
/** 全部标为已读(可按班级限定) */
export async function markAllAsRead(user: TokenPayload, classId?: string): Promise<{ marked: number }> {
const scope = await resolveClassScope(user, classId);
const pending = await prisma.notification.findMany({
where: { ...classScopeWhere(scope), reads: { none: { userId: user.sub } } },
select: { id: true },
});
for (const item of pending) {
await prisma.notificationRead.upsert({
where: { notificationId_userId: { notificationId: item.id, userId: user.sub } },
create: { notificationId: item.id, userId: user.sub },
update: {},
});
}
return { marked: pending.length };
}
/** 未读数量(客户端红点) */
export async function unreadCount(user: TokenPayload, classId?: string): Promise<number> {
const scope = await resolveClassScope(user, classId);
return prisma.notification.count({
where: { ...classScopeWhere(scope), reads: { none: { userId: user.sub } } },
});
}
+31
View File
@@ -0,0 +1,31 @@
import { authModule } from './auth/auth.module.js';
import { classesModule } from './classes/classes.module.js';
import { coursesModule } from './courses/courses.module.js';
import { dashboardModule } from './dashboard/dashboard.module.js';
import { gradesModule } from './grades/grades.module.js';
import { homeworksModule } from './homeworks/homeworks.module.js';
import { notificationsModule } from './notifications/notifications.module.js';
import { schedulesModule } from './schedules/schedules.module.js';
import { studentsModule } from './students/students.module.js';
import { teachersModule } from './teachers/teachers.module.js';
import type { ApiModule } from './module.types.js';
/**
* 模块注册表 —— 模块化设计的唯一入口。
*
* 新增功能:新建 modules/<name>/ 目录并在这里加一行;
* 移除功能:删除对应一行(router 不再挂载,相关接口立即消失);
* 临时下线:把 enabled 置为 false。
*/
export const apiModules: ApiModule[] = [
authModule,
classesModule,
coursesModule,
schedulesModule,
homeworksModule,
notificationsModule,
gradesModule,
studentsModule,
teachersModule,
dashboardModule,
].filter((module) => module.enabled !== false);
@@ -0,0 +1,87 @@
import { Router } from 'express';
import { sendCreated, sendOk } from '../../lib/http.js';
import { idParamSchema } from '../../lib/schemas.js';
import { resolveCurrentWeek } from '../../lib/term.js';
import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js';
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import {
createScheduleSchema,
gridQuerySchema,
listSchedulesQuerySchema,
updateScheduleSchema,
type CreateScheduleInput,
type UpdateScheduleInput,
} from './schedules.schemas.js';
import * as scheduleService from './schedules.service.js';
const router = Router();
router.use(authenticate());
/** GET /api/schedules?classId=&week=&dayOfWeek= - 课表列表 */
router.get('/', validate({ query: listSchedulesQuerySchema }), async (req, res) => {
const user = getAuthUser(req);
const query = validatedQuery<{ classId?: string; week?: number; dayOfWeek?: number }>(req);
sendOk(res, await scheduleService.listSchedules(user, query), '获取课表成功');
});
/** GET /api/schedules/grid?classId=&week= - 周视图(7 列结构) */
router.get('/grid', validate({ query: gridQuerySchema }), async (req, res) => {
const user = getAuthUser(req);
const { classId, week } = validatedQuery<{ classId?: string; week?: number }>(req);
sendOk(
res,
await scheduleService.getScheduleGrid(user, { classId, week: week ?? resolveCurrentWeek() }),
'获取周视图成功',
);
});
/** POST /api/schedules - 新增课表 */
router.post(
'/',
requireRole('ADMIN', 'TEACHER'),
validate({ body: createScheduleSchema }),
async (req, res) => {
const user = getAuthUser(req);
sendCreated(
res,
await scheduleService.createSchedule(user, req.body as CreateScheduleInput),
'课表创建成功',
);
},
);
/** PATCH /api/schedules/:id - 修改课表 */
router.patch(
'/:id',
requireRole('ADMIN', 'TEACHER'),
validate({ params: idParamSchema, body: updateScheduleSchema }),
async (req, res) => {
const user = getAuthUser(req);
const { id } = validatedParams<{ id: string }>(req);
sendOk(
res,
await scheduleService.updateSchedule(user, id, req.body as UpdateScheduleInput),
'课表更新成功',
);
},
);
/** DELETE /api/schedules/:id - 删除课表 */
router.delete(
'/:id',
requireRole('ADMIN', 'TEACHER'),
validate({ params: idParamSchema }),
async (req, res) => {
const user = getAuthUser(req);
const { id } = validatedParams<{ id: string }>(req);
await scheduleService.deleteSchedule(user, id);
sendOk(res, { id }, '课表已删除');
},
);
export const schedulesModule = defineModule({
name: 'schedules',
basePath: '/schedules',
router,
});
@@ -0,0 +1,52 @@
import { z } from 'zod';
import { timeSchema, weekNumberSchema } from '../../lib/schemas.js';
export const listSchedulesQuerySchema = z.object({
classId: z.string().min(1).optional(),
/** 不传则按"当前教学周"过滤 */
week: weekNumberSchema.optional(),
dayOfWeek: z.coerce.number().int().min(1).max(7).optional(),
});
export const gridQuerySchema = z.object({
classId: z.string().min(1).optional(),
week: weekNumberSchema.optional(),
});
export const createScheduleSchema = z
.object({
classId: z.string().min(1, '请选择班级'),
courseId: z.string().min(1, '请选择课程'),
dayOfWeek: z.coerce.number().int().min(1, '星期范围为 1-7').max(7),
startTime: timeSchema,
endTime: timeSchema,
location: z.string().trim().max(64).nullish(),
weekStart: weekNumberSchema.optional(),
weekEnd: weekNumberSchema.optional(),
})
.refine((data) => data.startTime < data.endTime, {
message: '结束时间必须晚于开始时间',
path: ['endTime'],
})
.refine((data) => (data.weekStart ?? 1) <= (data.weekEnd ?? 20), {
message: '起始周次不能大于结束周次',
path: ['weekEnd'],
});
export const updateScheduleSchema = z
.object({
courseId: z.string().min(1).optional(),
dayOfWeek: z.coerce.number().int().min(1).max(7).optional(),
startTime: timeSchema.optional(),
endTime: timeSchema.optional(),
location: z.string().trim().max(64).nullish(),
weekStart: weekNumberSchema.optional(),
weekEnd: weekNumberSchema.optional(),
})
.refine((data) => !data.startTime || !data.endTime || data.startTime < data.endTime, {
message: '结束时间必须晚于开始时间',
path: ['endTime'],
});
export type CreateScheduleInput = z.infer<typeof createScheduleSchema>;
export type UpdateScheduleInput = z.infer<typeof updateScheduleSchema>;
@@ -0,0 +1,141 @@
import {
SOCKET_EVENTS,
buildScheduleWeekView,
type ScheduleDto,
type ScheduleWeekView,
} from '@classhelper/shared';
import { assertClassWritable, 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';
import { toScheduleDto } from '../../lib/mappers.js';
import { resolveCurrentWeek } from '../../lib/term.js';
import { emitToClass } from '../../realtime/bus.js';
import type { CreateScheduleInput, UpdateScheduleInput } from './schedules.schemas.js';
const courseSelect = { select: { id: true, name: true } } as const;
export interface ListScheduleOptions {
classId?: string;
week?: number;
dayOfWeek?: number;
}
/** 课表列表:按班级权限过滤,week 过滤 weekStart <= week <= weekEnd */
export async function listSchedules(
user: TokenPayload,
options: ListScheduleOptions,
): Promise<ScheduleDto[]> {
const scope = await resolveClassScope(user, options.classId);
const week = options.week;
const schedules = await prisma.schedule.findMany({
where: {
...classScopeWhere(scope),
...(week ? { weekStart: { lte: week }, weekEnd: { gte: week } } : {}),
...(options.dayOfWeek ? { dayOfWeek: options.dayOfWeek } : {}),
},
include: { course: courseSelect },
orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }],
});
return schedules.map(toScheduleDto);
}
/** 周视图:7 列固定结构,供 Web / EXE 直接渲染 */
export async function getScheduleGrid(
user: TokenPayload,
options: { classId?: string; week?: number },
): Promise<ScheduleWeekView> {
const week = options.week ?? resolveCurrentWeek();
const items = await listSchedules(user, { classId: options.classId, week });
return buildScheduleWeekView(items, week);
}
export async function createSchedule(user: TokenPayload, input: CreateScheduleInput): Promise<ScheduleDto> {
await assertClassWritable(user, input.classId);
await assertCourseInClass(input.courseId, input.classId);
const created = await prisma.schedule.create({
data: {
classId: input.classId,
courseId: input.courseId,
dayOfWeek: input.dayOfWeek,
startTime: input.startTime,
endTime: input.endTime,
location: input.location ?? null,
weekStart: input.weekStart ?? 1,
weekEnd: input.weekEnd ?? 20,
},
include: { course: courseSelect },
});
const dto = toScheduleDto(created);
emitToClass(created.classId, SOCKET_EVENTS.scheduleUpdated, {
classId: created.classId,
action: 'created',
schedule: dto,
});
return dto;
}
export async function updateSchedule(
user: TokenPayload,
scheduleId: string,
input: UpdateScheduleInput,
): Promise<ScheduleDto> {
const current = await prisma.schedule.findUnique({ where: { id: scheduleId } });
if (!current) throw ApiError.notFound('课表记录不存在');
await assertClassWritable(user, current.classId);
if (input.courseId) await assertCourseInClass(input.courseId, current.classId);
const startTime = input.startTime ?? current.startTime;
const endTime = input.endTime ?? current.endTime;
if (startTime >= endTime) throw ApiError.badRequest('结束时间必须晚于开始时间');
const weekStart = input.weekStart ?? current.weekStart;
const weekEnd = input.weekEnd ?? current.weekEnd;
if (weekStart > weekEnd) throw ApiError.badRequest('起始周次不能大于结束周次');
const updated = await prisma.schedule.update({
where: { id: scheduleId },
data: {
...(input.courseId ? { courseId: input.courseId } : {}),
...(input.dayOfWeek ? { dayOfWeek: input.dayOfWeek } : {}),
startTime,
endTime,
...(input.location !== undefined ? { location: input.location ?? null } : {}),
weekStart,
weekEnd,
},
include: { course: courseSelect },
});
const dto = toScheduleDto(updated);
emitToClass(updated.classId, SOCKET_EVENTS.scheduleUpdated, {
classId: updated.classId,
action: 'updated',
schedule: dto,
});
return dto;
}
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 prisma.schedule.delete({ where: { id: scheduleId } });
emitToClass(current.classId, SOCKET_EVENTS.scheduleUpdated, {
classId: current.classId,
action: 'deleted',
});
}
/** 校验课程确实属于该班级,避免把 A 班课程排进 B 班课表 */
async function assertCourseInClass(courseId: string, classId: string): Promise<void> {
const course = await prisma.course.findUnique({ where: { id: courseId }, select: { classId: true } });
if (!course) throw ApiError.badRequest('课程不存在');
if (course.classId !== classId) throw ApiError.badRequest('该课程不属于所选班级');
}
@@ -0,0 +1,94 @@
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 {
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= - 学生名单(教师/管理员) */
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), '获取学生列表成功');
},
);
/** 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),
'学生账号创建成功',
);
},
);
/** PATCH /api/students/:id - 修改学生信息 / 调班 */
router.patch(
'/:id',
requireRole('ADMIN', 'TEACHER'),
validate({ params: idParamSchema, body: updateStudentSchema }),
async (req, res) => {
const user = getAuthUser(req);
const { id } = validatedParams<{ id: string }>(req);
sendOk(
res,
await studentService.updateStudent(user, id, req.body as UpdateStudentInput),
'学生信息更新成功',
);
},
);
/** POST /api/students/:id/reset-password - 重置密码 */
router.post(
'/:id/reset-password',
requireRole('ADMIN', 'TEACHER'),
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 - 删除学生账号 */
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 }, '学生账号已删除');
},
);
export const studentsModule = defineModule({
name: 'students',
basePath: '/students',
router,
});
@@ -0,0 +1,28 @@
import { z } from 'zod';
import { keywordSchema } from '../../lib/schemas.js';
export const listStudentsQuerySchema = z.object({
classId: z.string().min(1).optional(),
keyword: keywordSchema,
});
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(),
});
export const updateStudentSchema = z.object({
username: z.string().trim().min(3).max(32).optional(),
name: z.string().trim().min(1).max(32).optional(),
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>;
@@ -0,0 +1,149 @@
import type { StudentDto } from '@classhelper/shared';
import { env } from '../../config/env.js';
import { assertClassWritable, 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';
export interface ListStudentOptions {
classId?: string;
keyword?: string;
}
/** 学生名单(按班级权限过滤) */
export async function listStudents(user: TokenPayload, options: ListStudentOptions): Promise<StudentDto[]> {
const scope = await resolveClassScope(user, options.classId);
const students = await prisma.user.findMany({
where: {
role: 'STUDENT',
...classScopeWhere(scope),
...(options.keyword
? {
OR: [{ name: { contains: options.keyword } }, { username: { contains: options.keyword } }],
}
: {}),
},
include: { class: { select: { name: true, grade: true } } },
orderBy: [{ classId: 'asc' }, { username: 'asc' }],
take: 500,
});
return students.map(toStudentDto);
}
/** 新建学生账号(可选直接分班) */
export async function createStudent(user: TokenPayload, input: CreateStudentInput): Promise<StudentDto> {
if (input.classId) await assertClassWritable(user, input.classId);
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,
},
});
if (input.classId) {
await prisma.enrollment.upsert({
where: { userId_classId: { userId: created.id, classId: input.classId } },
create: { userId: created.id, classId: input.classId },
update: {},
});
}
const student = await prisma.user.findUniqueOrThrow({
where: { id: created.id },
include: { class: { select: { name: true, grade: true } } },
});
return toStudentDto(student);
}
/** 修改学生信息 / 调班 */
export async function updateStudent(
user: TokenPayload,
studentId: string,
input: UpdateStudentInput,
): Promise<StudentDto> {
const student = await prisma.user.findUnique({ where: { id: studentId } });
if (!student || student.role !== 'STUDENT') throw ApiError.notFound('学生不存在');
if (student.classId) {
await assertClassWritable(user, student.classId);
} 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);
}
const updated = await prisma.user.update({
where: { id: studentId },
data: {
...(input.name ? { name: input.name } : {}),
...(input.username ? { username: input.username } : {}),
classId: nextClassId,
},
});
// 同步 Enrollment:换班时清理旧记录
if (nextClassId) {
await prisma.enrollment.deleteMany({ where: { userId: studentId, classId: { not: nextClassId } } });
await prisma.enrollment.upsert({
where: { userId_classId: { userId: studentId, classId: nextClassId } },
create: { userId: studentId, classId: nextClassId },
update: {},
});
} else {
await prisma.enrollment.deleteMany({ where: { userId: studentId } });
}
const result = await prisma.user.findUniqueOrThrow({
where: { id: updated.id },
include: { class: { select: { name: true, grade: true } } },
});
return toStudentDto(result);
}
/** 删除学生账号(级联删除其作业状态、成绩、已读记录) */
export async function deleteStudent(user: TokenPayload, studentId: string): Promise<void> {
const student = await prisma.user.findUnique({ where: { id: studentId } });
if (!student || student.role !== 'STUDENT') throw ApiError.notFound('学生不存在');
if (student.classId) {
await assertClassWritable(user, student.classId);
} else if (!isAdmin(user)) {
throw ApiError.forbidden('该学生尚未分班,仅管理员可以删除');
}
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) {
await assertClassWritable(user, student.classId);
} else if (!isAdmin(user)) {
throw ApiError.forbidden('该学生尚未分班,仅管理员可以重置密码');
}
await prisma.user.update({
where: { id: studentId },
data: { passwordHash: await hashPassword(input.newPassword ?? env.defaultStudentPassword) },
});
}
@@ -0,0 +1,33 @@
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';
import { defineModule } from '../module.types.js';
import { createTeacherSchema, listTeachersQuerySchema, type CreateTeacherInput } from './teachers.schemas.js';
import * as teacherService from './teachers.service.js';
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), '获取教师列表成功');
},
);
/** POST /api/teachers - 新建教师账号(仅管理员) */
router.post('/', requireRole('ADMIN'), validate({ body: createTeacherSchema }), async (req, res) => {
getAuthUser(req);
sendCreated(res, await teacherService.createTeacher(req.body as CreateTeacherInput), '教师账号创建成功');
});
export const teachersModule = defineModule({
name: 'teachers',
basePath: '/teachers',
router,
});
@@ -0,0 +1,15 @@
import { z } from 'zod';
import { keywordSchema } from '../../lib/schemas.js';
export const listTeachersQuerySchema = z.object({
keyword: keywordSchema,
});
export const createTeacherSchema = 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),
role: z.enum(['TEACHER', 'ADMIN']).optional(),
});
export type CreateTeacherInput = z.infer<typeof createTeacherSchema>;
@@ -0,0 +1,33 @@
import type { UserDto } from '@classhelper/shared';
import { prisma } from '../../lib/db.js';
import { toUserDto } from '../../lib/mappers.js';
import { hashPassword } from '../../lib/password.js';
import type { CreateTeacherInput } from './teachers.schemas.js';
/**
* 教师列表:用于"分配协作教师"等需要选择教师的场景。
* 仅返回教师/管理员账号,字段与 UserDto 对齐。
*/
export async function listTeachers(keyword?: string): Promise<UserDto[]> {
const teachers = await prisma.user.findMany({
where: {
role: { in: ['TEACHER', 'ADMIN'] },
...(keyword ? { OR: [{ name: { contains: keyword } }, { username: { contains: keyword } }] } : {}),
},
orderBy: [{ role: 'asc' }, { username: 'asc' }],
take: 200,
});
return teachers.map(toUserDto);
}
export async function createTeacher(input: CreateTeacherInput): Promise<UserDto> {
const created = await prisma.user.create({
data: {
username: input.username,
name: input.name,
role: input.role ?? 'TEACHER',
passwordHash: await hashPassword(input.password),
},
});
return toUserDto(created);
}