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,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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user