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

+124
View File
@@ -0,0 +1,124 @@
import { prisma } from './db.js';
import { ApiError } from './http.js';
import type { TokenPayload } from './jwt.js';
/**
* 权限(RBAC)规则:
* - ADMIN :可访问全部班级
* - TEACHER :仅可访问自己创建(teacherId)或被分配(ClassTeacher)的班级
* - STUDENT :仅可查看自己所在班级的数据,且无任何写权限
*/
export function isAdmin(user: TokenPayload): boolean {
return user.role === 'ADMIN';
}
export function isTeacher(user: TokenPayload): boolean {
return user.role === 'TEACHER';
}
export function isStudent(user: TokenPayload): boolean {
return user.role === 'STUDENT';
}
export function isStaff(user: TokenPayload): boolean {
return user.role === 'ADMIN' || user.role === 'TEACHER';
}
/** 当前用户可访问的全部班级 id */
export async function getAccessibleClassIds(user: TokenPayload): Promise<string[]> {
if (isAdmin(user)) {
const classes = await prisma.class.findMany({ select: { id: true } });
return classes.map((item) => item.id);
}
if (isTeacher(user)) {
const classes = await prisma.class.findMany({
where: { OR: [{ teacherId: user.sub }, { teachers: { some: { teacherId: user.sub } } }] },
select: { id: true },
});
return classes.map((item) => item.id);
}
const ids = new Set<string>();
if (user.classId) ids.add(user.classId);
const enrollments = await prisma.enrollment.findMany({
where: { userId: user.sub },
select: { classId: true },
});
for (const item of enrollments) ids.add(item.classId);
return [...ids];
}
export async function canAccessClass(user: TokenPayload, classId: string): Promise<boolean> {
if (isAdmin(user)) return true;
if (isTeacher(user)) {
const count = await prisma.class.count({
where: {
id: classId,
OR: [{ teacherId: user.sub }, { teachers: { some: { teacherId: user.sub } } }],
},
});
return count > 0;
}
if (user.classId === classId) return true;
const enrollment = await prisma.enrollment.findFirst({
where: { userId: user.sub, classId },
select: { id: true },
});
return enrollment !== null;
}
/** 读权限断言 */
export async function assertClassAccess(user: TokenPayload, classId: string): Promise<void> {
const exists = await prisma.class.findUnique({ where: { id: classId }, select: { id: true } });
if (!exists) throw ApiError.notFound('班级不存在');
if (!(await canAccessClass(user, classId))) throw ApiError.forbidden('无权访问该班级的数据');
}
/** 写权限断言:学生一律拒绝 */
export async function assertClassWritable(user: TokenPayload, classId: string): Promise<void> {
if (!isStaff(user)) throw ApiError.forbidden('学生账号没有管理权限');
await assertClassAccess(user, classId);
}
/** 学生的主班级,未分配班级时抛 403 */
export function requireStudentClassId(user: TokenPayload): string {
if (!user.classId) throw ApiError.forbidden('当前账号尚未被分配到任何班级,请联系班主任');
return user.classId;
}
/**
* 查询范围解析:
* - 传入 classId:校验后只查该班级
* - 未传:教师/学生只能查自己可访问的班级;管理员不加限制
*/
export type ClassScope = { mode: 'all' } | { mode: 'in'; classIds: string[] };
export async function resolveClassScope(user: TokenPayload, requestedClassId?: string): Promise<ClassScope> {
if (requestedClassId) {
await assertClassAccess(user, requestedClassId);
return { mode: 'in', classIds: [requestedClassId] };
}
if (isAdmin(user)) return { mode: 'all' };
const classIds = await getAccessibleClassIds(user);
// 没有任何班级时不抛错,返回空集合让列表接口给出空数组,避免新教师首次登录报错
return { mode: 'in', classIds };
}
/** 把 ClassScope 转换成 Prisma 的 where 片段(子表:classId 字段) */
export function classScopeWhere(scope: ClassScope): { classId?: { in: string[] } } {
return scope.mode === 'all' ? {} : { classId: { in: scope.classIds } };
}
/** Class 表自身的范围过滤:注意这里过滤的是主键 id,而不是子表的 classId */
export function classScopeIdFilter(scope: ClassScope): { id?: { in: string[] } } {
return scope.mode === 'all' ? {} : { id: { in: scope.classIds } };
}
/** 单班级场景:学生强制取自己班级,其他角色沿用请求参数 */
export function resolveEffectiveClassId(user: TokenPayload, requestedClassId?: string): string | undefined {
if (isStudent(user)) return requireStudentClassId(user);
return requestedClassId;
}
+44
View File
@@ -0,0 +1,44 @@
import { PrismaLibSql } from '@prisma/adapter-libsql';
import { PrismaClient } from '../generated/prisma/client.js';
import { env } from '../config/env.js';
import { logger } from './logger.js';
/**
* Prisma 7 要求所有数据库都通过 driver adapter 接入:
* - SQLite -> @prisma/adapter-libsql(libSQL 内嵌模式,npm 预编译二进制,无需本地编译)
* - MySQL -> @prisma/adapter-mariadb(切换数据库时安装,见 docs/mysql.md)
*
* MySQL 分支使用「非字面量模块名」动态导入,这样未安装 mariadb 适配器时
* 也不会影响类型检查与 SQLite 模式的运行。
*/
async function createAdapter(): Promise<unknown> {
if (env.databaseProvider === 'mysql') {
const adapterModule = '@prisma/adapter-mariadb';
const mod = (await import(adapterModule)) as { PrismaMariaDb: new (url: string) => unknown };
logger.info('已加载 MySQL/MariaDB 驱动适配器');
return new mod.PrismaMariaDb(env.databaseUrl);
}
return new PrismaLibSql({ url: env.databaseUrl });
}
const adapter = await createAdapter();
export const prisma = new PrismaClient({ adapter: adapter as never });
logger.info(`Prisma 客户端已初始化(provider=${env.databaseProvider})`);
/** 进程退出时释放连接 */
export async function disconnectPrisma(): Promise<void> {
await prisma.$disconnect();
}
/** 供健康检查使用:确认数据库可连通 */
export async function pingDatabase(): Promise<boolean> {
try {
await prisma.$queryRaw`SELECT 1`;
return true;
} catch (error) {
logger.error('数据库连接检查失败', error);
return false;
}
}
+65
View File
@@ -0,0 +1,65 @@
import type { Response } from 'express';
import type { ApiResponse } from '@classhelper/shared';
/** 成功响应:{ success: true, data, message } */
export function sendOk<T>(res: Response, data: T, message = 'ok', status = 200): void {
const body: ApiResponse<T> = { success: true, data, message };
res.status(status).json(body);
}
/** 创建成功(201) */
export function sendCreated<T>(res: Response, data: T, message = '创建成功'): void {
sendOk(res, data, message, 201);
}
export type ApiErrorCode =
| 'BAD_REQUEST'
| 'VALIDATION_ERROR'
| 'UNAUTHORIZED'
| 'FORBIDDEN'
| 'NOT_FOUND'
| 'CONFLICT'
| 'INTERNAL_ERROR';
/** 业务异常:中间件会把它转换成统一错误响应体 */
export class ApiError extends Error {
readonly status: number;
readonly code: ApiErrorCode;
readonly details?: unknown;
constructor(status: number, code: ApiErrorCode, message: string, details?: unknown) {
super(message);
this.name = 'ApiError';
this.status = status;
this.code = code;
this.details = details;
}
static badRequest(message: string, details?: unknown): ApiError {
return new ApiError(400, 'BAD_REQUEST', message, details);
}
static validation(message: string, details?: unknown): ApiError {
return new ApiError(422, 'VALIDATION_ERROR', message, details);
}
static unauthorized(message = '未登录或登录已过期'): ApiError {
return new ApiError(401, 'UNAUTHORIZED', message);
}
static forbidden(message = '无权访问该资源'): ApiError {
return new ApiError(403, 'FORBIDDEN', message);
}
static notFound(message = '资源不存在'): ApiError {
return new ApiError(404, 'NOT_FOUND', message);
}
static conflict(message = '资源冲突'): ApiError {
return new ApiError(409, 'CONFLICT', message);
}
static internal(message = '服务器内部错误', details?: unknown): ApiError {
return new ApiError(500, 'INTERNAL_ERROR', message, details);
}
}
+52
View File
@@ -0,0 +1,52 @@
import jwt from 'jsonwebtoken';
import type { SignOptions } from 'jsonwebtoken';
import type { UserRole } from '@classhelper/shared';
import { env } from '../config/env.js';
import { ApiError } from './http.js';
/** JWT 载荷(同时作为 req.auth.user 的类型来源) */
export interface TokenPayload {
sub: string;
username: string;
name: string;
role: UserRole;
classId: string | null;
}
export function signToken(payload: TokenPayload): string {
// jsonwebtoken 的 expiresIn 类型为 ms.StringValue,这里由 .env 提供字符串,做一次显式收窄
const options = { expiresIn: env.jwtExpiresIn } as unknown as SignOptions;
return jwt.sign({ ...payload }, env.jwtSecret, options);
}
/** 校验并解析 token,失败统一抛出 401 */
export function verifyToken(token: string): TokenPayload {
try {
const decoded = jwt.verify(token, env.jwtSecret);
if (typeof decoded === 'string') throw new Error('unexpected token payload');
const record = decoded as Record<string, unknown>;
const role = record.role;
if (role !== 'ADMIN' && role !== 'TEACHER' && role !== 'STUDENT') {
throw new Error('unexpected role in token');
}
return {
sub: String(record.sub ?? ''),
username: String(record.username ?? ''),
name: String(record.name ?? ''),
role,
classId: record.classId ? String(record.classId) : null,
};
} catch {
throw ApiError.unauthorized('登录状态已失效,请重新登录');
}
}
/** 从 Authorization 头中提取 Bearer token */
export function extractBearerToken(headerValue: string | undefined): string | null {
if (!headerValue) return null;
const [scheme, token] = headerValue.split(' ');
if (!token || scheme.toLowerCase() !== 'bearer') return null;
return token.trim();
}
+21
View File
@@ -0,0 +1,21 @@
import { env } from '../config/env.js';
const LEVEL_WEIGHT = { debug: 10, info: 20, warn: 30, error: 40 } as const;
export type LogLevel = keyof typeof LEVEL_WEIGHT;
function write(level: LogLevel, message: string, meta?: unknown): void {
if (LEVEL_WEIGHT[level] < LEVEL_WEIGHT[env.logLevel]) return;
const timestamp = new Date().toISOString();
const line = `[${timestamp}] ${level.toUpperCase().padEnd(5)} ${message}`;
const sink = level === 'error' ? console.error : level === 'warn' ? console.warn : console.log;
if (meta === undefined) sink(line);
else sink(line, meta);
}
export const logger = {
debug: (message: string, meta?: unknown): void => write('debug', message, meta),
info: (message: string, meta?: unknown): void => write('info', message, meta),
warn: (message: string, meta?: unknown): void => write('warn', message, meta),
error: (message: string, meta?: unknown): void => write('error', message, meta),
};
+303
View File
@@ -0,0 +1,303 @@
import type {
ClassDto,
ClassTeacherBrief,
CourseBrief,
CourseDto,
GradeDto,
HomeworkDto,
HomeworkStatusDto,
NotificationDto,
NotificationPriority,
ScheduleDto,
StudentDto,
UserDto,
UserRole,
} from '@classhelper/shared';
/**
* 数据库记录 -> API DTO 的映射层。
*
* 这里刻意使用结构化的 `XxxLike` 接口而不是 Prisma 生成的模型类型:
* 1) 生成代码的模型类型命名随版本变化,映射层不应该跟着漂移;
* 2) 只有被 include/select 出来的字段才会出现在结果里,结构化类型能精确表达这一点。
*/
const toIso = (value: Date): string => value.toISOString();
const toIsoOrNull = (value: Date | null | undefined): string | null => (value ? value.toISOString() : null);
export interface UserLike {
id: string;
username: string;
name: string;
role: string;
classId: string | null;
createdAt: Date;
}
export interface TeacherBriefLike {
id: string;
name: string;
username: string;
}
export interface StudentLike extends UserLike {
class?: { name: string; grade: string } | null;
}
export interface CourseBriefLike {
id: string;
name: string;
}
export function toUserDto(user: UserLike): UserDto {
return {
id: user.id,
username: user.username,
name: user.name,
role: user.role as UserRole,
classId: user.classId,
createdAt: toIso(user.createdAt),
};
}
export function toStudentDto(user: StudentLike): StudentDto {
return {
...toUserDto(user),
className: user.class?.name ?? null,
grade: user.class?.grade ?? null,
};
}
export function toTeacherBrief(user: TeacherBriefLike): ClassTeacherBrief {
return { id: user.id, name: user.name, username: user.username };
}
export function toCourseBrief(course: CourseBriefLike): CourseBrief {
return { id: course.id, name: course.name };
}
/* ------------------------------------------------------------------ 班级 */
export interface ClassLike {
id: string;
name: string;
grade: string;
teacherId: string;
createdAt: Date;
teacher?: TeacherBriefLike | null;
_count?: {
students?: number;
courses?: number;
homeworks?: number;
notifications?: number;
enrollments?: number;
};
}
export function toClassDto(item: ClassLike): ClassDto {
return {
id: item.id,
name: item.name,
grade: item.grade,
teacherId: item.teacherId,
createdAt: toIso(item.createdAt),
teacher: item.teacher ? toTeacherBrief(item.teacher) : null,
studentCount: item._count?.students ?? undefined,
courseCount: item._count?.courses ?? undefined,
homeworkCount: item._count?.homeworks ?? undefined,
notificationCount: item._count?.notifications ?? undefined,
};
}
/* ------------------------------------------------------------------ 课程 */
export interface CourseLike {
id: string;
name: string;
teacherId: string;
classId: string;
createdAt: Date;
teacher?: TeacherBriefLike | null;
}
export function toCourseDto(item: CourseLike): CourseDto {
return {
id: item.id,
name: item.name,
teacherId: item.teacherId,
classId: item.classId,
createdAt: toIso(item.createdAt),
teacher: item.teacher ? toTeacherBrief(item.teacher) : null,
};
}
/* ------------------------------------------------------------------ 课表 */
export interface ScheduleLike {
id: string;
classId: string;
courseId: string;
dayOfWeek: number;
startTime: string;
endTime: string;
location: string | null;
weekStart: number;
weekEnd: number;
createdAt: Date;
course?: CourseBriefLike | null;
}
export function toScheduleDto(item: ScheduleLike): ScheduleDto {
return {
id: item.id,
classId: item.classId,
courseId: item.courseId,
dayOfWeek: item.dayOfWeek,
startTime: item.startTime,
endTime: item.endTime,
location: item.location,
weekStart: item.weekStart,
weekEnd: item.weekEnd,
createdAt: toIso(item.createdAt),
course: item.course ? toCourseBrief(item.course) : null,
};
}
/* ------------------------------------------------------------------ 作业 */
export interface HomeworkStatusLike {
id: string;
homeworkId: string;
userId: string;
completed: boolean;
updatedAt: Date;
}
export interface HomeworkLike {
id: string;
classId: string;
courseId: string | null;
title: string;
content: string;
attachmentUrl: string | null;
dueAt: Date | null;
createdBy: string;
createdAt: Date;
course?: CourseBriefLike | null;
creator?: TeacherBriefLike | null;
statuses?: HomeworkStatusLike[];
}
export function toHomeworkStatusDto(item: HomeworkStatusLike): HomeworkStatusDto {
return {
id: item.id,
homeworkId: item.homeworkId,
userId: item.userId,
completed: item.completed,
updatedAt: toIso(item.updatedAt),
};
}
/**
* @param options.userId 指定当前用户,用于填充 completed / homeworkStatus
* @param options.withStatus 是否统计完成人数(教师视角)
*/
export function toHomeworkDto(
item: HomeworkLike,
options: { userId?: string | null; withStatus?: boolean } = {},
): HomeworkDto {
const statuses = item.statuses ?? [];
const own = options.userId ? statuses.find((status) => status.userId === options.userId) : undefined;
return {
id: item.id,
classId: item.classId,
courseId: item.courseId,
title: item.title,
content: item.content,
attachmentUrl: item.attachmentUrl,
dueAt: toIsoOrNull(item.dueAt),
createdBy: item.createdBy,
createdAt: toIso(item.createdAt),
course: item.course ? toCourseBrief(item.course) : null,
creator: item.creator ? toTeacherBrief(item.creator) : null,
completed: own ? own.completed : undefined,
homeworkStatus: own ? toHomeworkStatusDto(own) : null,
completedCount: options.withStatus ? statuses.filter((status) => status.completed).length : undefined,
};
}
/* ------------------------------------------------------------------ 通知 */
export interface NotificationReadLike {
id: string;
notificationId: string;
userId: string;
readAt: Date;
}
export interface NotificationLike {
id: string;
classId: string;
title: string;
content: string;
priority: string;
createdBy: string;
createdAt: Date;
creator?: TeacherBriefLike | null;
reads?: NotificationReadLike[];
}
export function toNotificationDto(
item: NotificationLike,
options: { userId?: string | null; withStatus?: boolean } = {},
): NotificationDto {
const reads = item.reads ?? [];
const own = options.userId ? reads.find((read) => read.userId === options.userId) : undefined;
return {
id: item.id,
classId: item.classId,
title: item.title,
content: item.content,
priority: item.priority as NotificationPriority,
createdBy: item.createdBy,
createdAt: toIso(item.createdAt),
creator: item.creator ? toTeacherBrief(item.creator) : null,
read: own ? true : false,
readAt: toIsoOrNull(own?.readAt),
readCount: options.withStatus ? reads.length : undefined,
};
}
/* ------------------------------------------------------------------ 成绩 */
export interface GradeLike {
id: string;
classId: string;
courseId: string | null;
userId: string;
examName: string;
score: number;
totalScore: number;
publishedAt: Date;
course?: CourseBriefLike | null;
student?: TeacherBriefLike | null;
class?: { name: string } | null;
}
export function toGradeDto(item: GradeLike): GradeDto {
return {
id: item.id,
classId: item.classId,
courseId: item.courseId,
userId: item.userId,
examName: item.examName,
score: item.score,
totalScore: item.totalScore,
publishedAt: toIso(item.publishedAt),
course: item.course ? toCourseBrief(item.course) : null,
student: item.student ? toTeacherBrief(item.student) : null,
className: item.class?.name ?? null,
};
}
+12
View File
@@ -0,0 +1,12 @@
import * as bcrypt from 'bcryptjs';
import { env } from '../config/env.js';
/** 生成密码哈希(bcrypt 算法) */
export function hashPassword(plain: string): Promise<string> {
return bcrypt.hash(plain, env.bcryptRounds);
}
/** 校验明文密码与哈希是否匹配 */
export function verifyPassword(plain: string, passwordHash: string): Promise<boolean> {
return bcrypt.compare(plain, passwordHash);
}
+35
View File
@@ -0,0 +1,35 @@
import { z } from 'zod';
/** 通用路径参数::id */
export const idParamSchema = z.object({ id: z.string().min(1, '缺少 id') });
/** HH:mm 时间 */
export const timeSchema = z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/, '时间格式应为 HH:mm');
/** ISO 日期时间字符串(接受 2026-03-01 或完整 ISO) */
export const dateTimeSchema = z
.string()
.min(1)
.refine((value) => !Number.isNaN(new Date(value).getTime()), '日期时间格式不正确');
/** 可选日期时间:允许 null / 空字符串,表示清空 */
export const optionalDateTimeSchema = z.union([dateTimeSchema, z.null(), z.literal('')]).optional();
/** 把可选日期时间统一转成 Date | null */
export function parseOptionalDate(value: string | null | undefined): Date | null {
if (!value) return null;
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
}
/** 查询参数里的布尔值(true/1) */
export const booleanFlagSchema = z
.union([z.boolean(), z.string()])
.optional()
.transform((value) => value === true || value === 'true' || value === '1');
/** 关键字查询 */
export const keywordSchema = z.string().trim().max(64).optional();
/** 周次:1..30 */
export const weekNumberSchema = z.coerce.number().int().min(1).max(30);
+23
View File
@@ -0,0 +1,23 @@
import { env } from '../config/env.js';
const MS_PER_DAY = 86_400_000;
export const MAX_TERM_WEEK = 30;
/**
* 依据 TERM_START_DATE 计算当前教学周(第 1 周从该日期所在周一开始)。
* 学生端与 Web 端"当前周次"默认值都走这里,保证三端口径一致。
*/
export function resolveCurrentWeek(now: Date = new Date()): number {
if (!env.termStartDate) return 1;
const start = new Date(env.termStartDate);
if (Number.isNaN(start.getTime())) return 1;
// 归一到该日期所在周的周一
const weekday = start.getDay() === 0 ? 7 : start.getDay();
const monday = new Date(start.getTime() - (weekday - 1) * MS_PER_DAY);
monday.setHours(0, 0, 0, 0);
const diffDays = Math.floor((now.getTime() - monday.getTime()) / MS_PER_DAY);
const week = Math.floor(diffDays / 7) + 1;
return Math.min(Math.max(week, 1), MAX_TERM_WEEK);
}