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,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('该课程不属于所选班级');
}