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,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('该课程不属于所选班级');
|
||||
}
|
||||
Reference in new issue
Block a user