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,57 @@
|
||||
import express, { type Express, type Request, type Response } from 'express';
|
||||
import cors from 'cors';
|
||||
import { API_PREFIX } from '@classhelper/shared';
|
||||
import { env } from './config/env.js';
|
||||
import { pingDatabase } from './lib/db.js';
|
||||
import { sendOk } from './lib/http.js';
|
||||
import { logger } from './lib/logger.js';
|
||||
import { errorHandler, notFoundHandler } from './middleware/error.js';
|
||||
import { apiModules } from './modules/registry.js';
|
||||
|
||||
async function healthHandler(_req: Request, res: Response): Promise<void> {
|
||||
sendOk(
|
||||
res,
|
||||
{
|
||||
status: 'ok',
|
||||
version: '0.1.0',
|
||||
uptimeSeconds: Math.round(process.uptime()),
|
||||
database: {
|
||||
provider: env.databaseProvider,
|
||||
connected: await pingDatabase(),
|
||||
},
|
||||
modules: apiModules.map((module) => module.name),
|
||||
},
|
||||
'服务运行正常',
|
||||
);
|
||||
}
|
||||
|
||||
/** 组装 Express 应用:中间件 -> 健康检查 -> 模块路由 -> 404 -> 统一错误处理 */
|
||||
export function createApp(): Express {
|
||||
const app = express();
|
||||
app.disable('x-powered-by');
|
||||
|
||||
app.use(cors({ origin: env.corsOrigins, credentials: true }));
|
||||
app.use(express.json({ limit: '2mb' }));
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
app.use((req, _res, next) => {
|
||||
logger.debug(`${req.method} ${req.originalUrl}`);
|
||||
next();
|
||||
});
|
||||
|
||||
// 健康检查(同时暴露已挂载模块列表,便于确认模块化装配结果)
|
||||
app.get(`${API_PREFIX}/health`, healthHandler);
|
||||
app.get('/health', healthHandler);
|
||||
|
||||
// 模块化装配:注册表里的每个模块挂载到 /api<basePath>
|
||||
for (const module of apiModules) {
|
||||
const mountPath = `${API_PREFIX}${module.basePath}`;
|
||||
app.use(mountPath, module.router);
|
||||
logger.info(`已挂载模块 ${module.name.padEnd(14)} -> ${mountPath}`);
|
||||
}
|
||||
|
||||
app.use(notFoundHandler);
|
||||
app.use(errorHandler);
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { config as loadEnv } from 'dotenv';
|
||||
import { z } from 'zod';
|
||||
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
/**
|
||||
* packages/server 根目录。
|
||||
* 源码运行时为 src/config -> ../..;编译后为 dist/config -> ../..,两者一致。
|
||||
*/
|
||||
export const serverRoot = path.resolve(currentDir, '..', '..');
|
||||
|
||||
// 显式按包根目录加载 .env,避免受启动时 cwd 影响
|
||||
loadEnv({ path: path.join(serverRoot, '.env') });
|
||||
|
||||
const envSchema = z.object({
|
||||
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
|
||||
PORT: z.coerce.number().int().min(1).max(65535).default(4000),
|
||||
DATABASE_PROVIDER: z.enum(['sqlite', 'mysql']).default('sqlite'),
|
||||
DATABASE_URL: z.string().min(1).default('file:./prisma/dev.db'),
|
||||
JWT_SECRET: z.string().min(16),
|
||||
JWT_EXPIRES_IN: z.string().default('7d'),
|
||||
CORS_ORIGIN: z.string().default('*'),
|
||||
BCRYPT_ROUNDS: z.coerce.number().int().min(4).max(15).default(10),
|
||||
DEFAULT_STUDENT_PASSWORD: z.string().min(6).default('123456'),
|
||||
TERM_START_DATE: z.string().default(''),
|
||||
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
|
||||
});
|
||||
|
||||
const parsed = envSchema.safeParse(process.env);
|
||||
|
||||
if (!parsed.success) {
|
||||
const issues = parsed.error.issues
|
||||
.map((issue) => ` - ${issue.path.join('.') || '(root)'}: ${issue.message}`)
|
||||
.join('\n');
|
||||
throw new Error(`环境变量校验失败,请检查 packages/server/.env(可从 .env.example 复制):\n${issues}`);
|
||||
}
|
||||
|
||||
const raw = parsed.data;
|
||||
|
||||
/** 把 file:./prisma/dev.db 解析为绝对路径,保证任何 cwd 下都指向同一个数据库文件 */
|
||||
function resolveSqliteUrl(url: string): string {
|
||||
if (!url.startsWith('file:')) return url;
|
||||
const filePath = url.slice('file:'.length);
|
||||
const absolute = path.isAbsolute(filePath) ? filePath : path.resolve(serverRoot, filePath);
|
||||
return `file:${absolute.replace(/\\/g, '/')}`;
|
||||
}
|
||||
|
||||
const isSqlite = raw.DATABASE_PROVIDER === 'sqlite';
|
||||
|
||||
export const env = {
|
||||
nodeEnv: raw.NODE_ENV,
|
||||
isProduction: raw.NODE_ENV === 'production',
|
||||
isDevelopment: raw.NODE_ENV === 'development',
|
||||
port: raw.PORT,
|
||||
databaseProvider: raw.DATABASE_PROVIDER,
|
||||
/** 已规范化为绝对路径(SQLite)/ 原始连接串(MySQL) */
|
||||
databaseUrl: isSqlite ? resolveSqliteUrl(raw.DATABASE_URL) : raw.DATABASE_URL,
|
||||
rawDatabaseUrl: raw.DATABASE_URL,
|
||||
jwtSecret: raw.JWT_SECRET,
|
||||
jwtExpiresIn: raw.JWT_EXPIRES_IN,
|
||||
/** true 表示允许所有来源,否则为白名单数组 */
|
||||
corsOrigins:
|
||||
raw.CORS_ORIGIN === '*'
|
||||
? true
|
||||
: raw.CORS_ORIGIN.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean),
|
||||
bcryptRounds: raw.BCRYPT_ROUNDS,
|
||||
/** 新增学生时未指定密码时的初始密码 */
|
||||
defaultStudentPassword: raw.DEFAULT_STUDENT_PASSWORD,
|
||||
termStartDate: raw.TERM_START_DATE,
|
||||
logLevel: raw.LOG_LEVEL,
|
||||
serverRoot,
|
||||
} as const;
|
||||
|
||||
export type AppEnv = typeof env;
|
||||
@@ -0,0 +1,46 @@
|
||||
import { createServer } from 'node:http';
|
||||
import { API_PREFIX } from '@classhelper/shared';
|
||||
import { createApp } from './app.js';
|
||||
import { env } from './config/env.js';
|
||||
import { disconnectPrisma, pingDatabase } from './lib/db.js';
|
||||
import { logger } from './lib/logger.js';
|
||||
import { initRealtime } from './realtime/socket.js';
|
||||
|
||||
const app = createApp();
|
||||
const httpServer = createServer(app);
|
||||
const io = initRealtime(httpServer);
|
||||
|
||||
if (!(await pingDatabase())) {
|
||||
logger.warn('数据库连接失败:请先执行 pnpm db:migrate 与 pnpm db:seed');
|
||||
}
|
||||
|
||||
httpServer.listen(env.port, () => {
|
||||
logger.info(`班级小助手后端已启动:http://127.0.0.1:${env.port}${API_PREFIX}`);
|
||||
logger.info(`健康检查:http://127.0.0.1:${env.port}${API_PREFIX}/health`);
|
||||
logger.info(`WebSocket:ws://127.0.0.1:${env.port}/socket.io`);
|
||||
logger.info(`运行环境:${env.nodeEnv},数据库:${env.databaseProvider}`);
|
||||
});
|
||||
|
||||
let shuttingDown = false;
|
||||
|
||||
async function shutdown(signal: string): Promise<void> {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
logger.info(`收到 ${signal},正在优雅关闭...`);
|
||||
|
||||
await new Promise<void>((resolve) => io.close(() => resolve()));
|
||||
await new Promise<void>((resolve) => httpServer.close(() => resolve()));
|
||||
await disconnectPrisma();
|
||||
logger.info('已关闭所有连接');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
void shutdown('SIGINT');
|
||||
});
|
||||
process.on('SIGTERM', () => {
|
||||
void shutdown('SIGTERM');
|
||||
});
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
logger.error('未处理的 Promise 拒绝', reason);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { Request, RequestHandler } from 'express';
|
||||
import type { UserRole } from '@classhelper/shared';
|
||||
import { prisma } from '../lib/db.js';
|
||||
import { ApiError } from '../lib/http.js';
|
||||
import { extractBearerToken, verifyToken, type TokenPayload } from '../lib/jwt.js';
|
||||
|
||||
/**
|
||||
* 认证中间件:解析 Bearer token 并回查数据库,
|
||||
* 保证角色 / 班级变更(例如教师把学生转到别的班)立即生效,不被过期载荷影响。
|
||||
*/
|
||||
export function authenticate(): RequestHandler {
|
||||
return async (req, _res, next) => {
|
||||
try {
|
||||
const token = extractBearerToken(req.headers.authorization);
|
||||
if (!token) throw ApiError.unauthorized('缺少 Authorization 头');
|
||||
|
||||
const payload = verifyToken(token);
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: payload.sub },
|
||||
select: { id: true, username: true, name: true, role: true, classId: true },
|
||||
});
|
||||
if (!user) throw ApiError.unauthorized('账号不存在或已被删除');
|
||||
|
||||
req.auth = {
|
||||
token,
|
||||
user: {
|
||||
sub: user.id,
|
||||
username: user.username,
|
||||
name: user.name,
|
||||
role: user.role as UserRole,
|
||||
classId: user.classId,
|
||||
},
|
||||
};
|
||||
next();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** 角色守卫 */
|
||||
export function requireRole(...roles: UserRole[]): RequestHandler {
|
||||
return (req, _res, next) => {
|
||||
const user = req.auth?.user;
|
||||
if (!user) {
|
||||
next(ApiError.unauthorized());
|
||||
return;
|
||||
}
|
||||
if (!roles.includes(user.role)) {
|
||||
next(ApiError.forbidden(`该操作仅限 ${roles.join(' / ')} 角色使用`));
|
||||
return;
|
||||
}
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
/** 取出当前登录用户,未认证时抛 401 */
|
||||
export function getAuthUser(req: Request): TokenPayload {
|
||||
if (!req.auth?.user) throw ApiError.unauthorized();
|
||||
return req.auth.user;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { ErrorRequestHandler, RequestHandler } from 'express';
|
||||
import { ZodError } from 'zod';
|
||||
import type { ApiErrorResponse } from '@classhelper/shared';
|
||||
import { ApiError } from '../lib/http.js';
|
||||
import { logger } from '../lib/logger.js';
|
||||
import { env } from '../config/env.js';
|
||||
|
||||
function sendError(
|
||||
res: Parameters<ErrorRequestHandler>[2],
|
||||
status: number,
|
||||
message: string,
|
||||
code: string,
|
||||
details?: unknown,
|
||||
): void {
|
||||
const body: ApiErrorResponse = { success: false, data: null, message, code };
|
||||
if (details !== undefined) body.details = details;
|
||||
res.status(status).json(body);
|
||||
}
|
||||
|
||||
/** 404:未匹配到任何路由 */
|
||||
export const notFoundHandler: RequestHandler = (req, res) => {
|
||||
sendError(res, 404, `接口不存在:${req.method} ${req.originalUrl}`, 'NOT_FOUND');
|
||||
};
|
||||
|
||||
/** 统一错误处理:把 Zod / ApiError / Prisma 错误转换成统一响应体 */
|
||||
export const errorHandler: ErrorRequestHandler = (error, req, res, _next) => {
|
||||
if (error instanceof ZodError) {
|
||||
const details = error.issues.map((issue) => ({
|
||||
path: issue.path.join('.'),
|
||||
message: issue.message,
|
||||
}));
|
||||
logger.warn(`参数校验失败:${req.method} ${req.originalUrl}`, details);
|
||||
sendError(res, 422, '请求参数不合法', 'VALIDATION_ERROR', details);
|
||||
return;
|
||||
}
|
||||
|
||||
if (error instanceof ApiError) {
|
||||
if (error.status >= 500) logger.error(error.message, error.details);
|
||||
sendError(res, error.status, error.message, error.code, error.details);
|
||||
return;
|
||||
}
|
||||
|
||||
// Prisma 已知错误码(P2002 唯一约束 / P2025 记录不存在 / P2003 外键约束)
|
||||
const prismaCode = (error as { code?: string }).code;
|
||||
if (prismaCode === 'P2002') {
|
||||
sendError(res, 409, '数据已存在,请检查唯一字段(用户名 / 课程名等)', 'CONFLICT');
|
||||
return;
|
||||
}
|
||||
if (prismaCode === 'P2025') {
|
||||
sendError(res, 404, '目标记录不存在或已被删除', 'NOT_FOUND');
|
||||
return;
|
||||
}
|
||||
if (prismaCode === 'P2003') {
|
||||
sendError(res, 400, '关联数据不存在或仍被引用', 'BAD_REQUEST');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.error(`未处理异常:${req.method} ${req.originalUrl}`, error);
|
||||
sendError(
|
||||
res,
|
||||
500,
|
||||
env.isProduction ? '服务器内部错误' : `服务器内部错误:${(error as Error).message}`,
|
||||
'INTERNAL_ERROR',
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { Request, RequestHandler } from 'express';
|
||||
import type { ZodType } from 'zod';
|
||||
|
||||
interface ValidationTargets<B, Q, P> {
|
||||
body?: ZodType<B>;
|
||||
query?: ZodType<Q>;
|
||||
params?: ZodType<P>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于 zod 的请求校验中间件。
|
||||
* 校验结果写入 req.body / req.validatedQuery / req.validatedParams,
|
||||
* 失败抛出的 ZodError 由统一错误中间件转换成 422。
|
||||
*/
|
||||
export function validate<B = unknown, Q = unknown, P = unknown>(
|
||||
targets: ValidationTargets<B, Q, P>,
|
||||
): RequestHandler {
|
||||
return (req, _res, next) => {
|
||||
try {
|
||||
if (targets.body) req.body = targets.body.parse(req.body) as unknown;
|
||||
if (targets.query) req.validatedQuery = targets.query.parse(req.query) as unknown;
|
||||
if (targets.params) req.validatedParams = targets.params.parse(req.params) as unknown;
|
||||
next();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** 读取已校验的 query(类型由调用方用 zod schema 保证) */
|
||||
export function validatedQuery<T>(req: Request): T {
|
||||
return (req.validatedQuery ?? {}) as T;
|
||||
}
|
||||
|
||||
/** 读取已校验的 params */
|
||||
export function validatedParams<T>(req: Request): T {
|
||||
return (req.validatedParams ?? {}) as T;
|
||||
}
|
||||
@@ -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 } } },
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { SOCKET_ROOMS } from '@classhelper/shared';
|
||||
|
||||
/**
|
||||
* 实时事件总线。
|
||||
*
|
||||
* 业务模块只依赖这里,不直接依赖 Socket.IO 实例,
|
||||
* 因此「移除实时推送」只需让它注册不到 emitter,业务代码无需改动。
|
||||
*/
|
||||
export type RealtimeEmitter = (rooms: string[], event: string, payload: unknown) => void;
|
||||
|
||||
let emitter: RealtimeEmitter | null = null;
|
||||
|
||||
export function registerEmitter(next: RealtimeEmitter): void {
|
||||
emitter = next;
|
||||
}
|
||||
|
||||
export function emitToRooms(rooms: string[], event: string, payload: unknown): void {
|
||||
if (!emitter || rooms.length === 0) return;
|
||||
emitter(rooms, event, payload);
|
||||
}
|
||||
|
||||
/** 按班级房间广播(class:{classId}) */
|
||||
export function emitToClass(classId: string, event: string, payload: unknown): void {
|
||||
emitToRooms([SOCKET_ROOMS.class(classId)], event, payload);
|
||||
}
|
||||
|
||||
/** 定向推送给单个用户 */
|
||||
export function emitToUser(userId: string, event: string, payload: unknown): void {
|
||||
emitToRooms([SOCKET_ROOMS.user(userId)], event, payload);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import type { Server as HttpServer } from 'node:http';
|
||||
import { Server } from 'socket.io';
|
||||
import {
|
||||
SOCKET_EVENTS,
|
||||
SOCKET_ROOMS,
|
||||
type ClientToServerEvents,
|
||||
type ServerToClientEvents,
|
||||
type SocketAuthPayload,
|
||||
type UserRole,
|
||||
} from '@classhelper/shared';
|
||||
import { env } from '../config/env.js';
|
||||
import { getAccessibleClassIds } from '../lib/access.js';
|
||||
import { verifyToken } from '../lib/jwt.js';
|
||||
import { logger } from '../lib/logger.js';
|
||||
import { registerEmitter, type RealtimeEmitter } from './bus.js';
|
||||
|
||||
interface SocketData {
|
||||
user: {
|
||||
id: string;
|
||||
username: string;
|
||||
name: string;
|
||||
role: UserRole;
|
||||
classId: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export type RealtimeServer = Server<
|
||||
ClientToServerEvents,
|
||||
ServerToClientEvents,
|
||||
Record<string, never>,
|
||||
SocketData
|
||||
>;
|
||||
|
||||
/** 绑定 Socket.IO 到 HTTP 服务,并注册到事件总线 */
|
||||
export function initRealtime(httpServer: HttpServer): RealtimeServer {
|
||||
const io: RealtimeServer = new Server(httpServer, {
|
||||
cors: { origin: env.corsOrigins, credentials: true },
|
||||
transports: ['websocket', 'polling'],
|
||||
pingInterval: 25000,
|
||||
pingTimeout: 20000,
|
||||
});
|
||||
|
||||
// 握手鉴权:客户端通过 handshake.auth.token 传入 JWT
|
||||
io.use((socket, next) => {
|
||||
try {
|
||||
const auth = (socket.handshake.auth ?? {}) as Partial<SocketAuthPayload>;
|
||||
const queryToken =
|
||||
typeof socket.handshake.query.token === 'string' ? socket.handshake.query.token : null;
|
||||
const token = auth.token ?? queryToken;
|
||||
if (!token) {
|
||||
next(new Error('缺少认证 token'));
|
||||
return;
|
||||
}
|
||||
const payload = verifyToken(token);
|
||||
socket.data.user = {
|
||||
id: payload.sub,
|
||||
username: payload.username,
|
||||
name: payload.name,
|
||||
role: payload.role,
|
||||
classId: payload.classId,
|
||||
};
|
||||
next();
|
||||
} catch {
|
||||
next(new Error('认证失败,请重新登录'));
|
||||
}
|
||||
});
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
const user = socket.data.user;
|
||||
void (async () => {
|
||||
const rooms = [SOCKET_ROOMS.user(user.id), SOCKET_ROOMS.role(user.role)];
|
||||
|
||||
try {
|
||||
const classIds = await getAccessibleClassIds({
|
||||
sub: user.id,
|
||||
username: user.username,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
classId: user.classId,
|
||||
});
|
||||
for (const classId of classIds) {
|
||||
await socket.join(SOCKET_ROOMS.class(classId));
|
||||
rooms.push(SOCKET_ROOMS.class(classId));
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`加入班级房间失败(user=${user.id})`, error);
|
||||
}
|
||||
|
||||
await socket.join(user.role === 'STUDENT' ? SOCKET_ROOMS.students : SOCKET_ROOMS.teachers);
|
||||
rooms.push(user.role === 'STUDENT' ? SOCKET_ROOMS.students : SOCKET_ROOMS.teachers);
|
||||
|
||||
logger.info(`实时连接建立:${user.name}(${user.role}) sid=${socket.id}`);
|
||||
socket.emit(SOCKET_EVENTS.connected, { userId: user.id, role: user.role, rooms });
|
||||
})();
|
||||
|
||||
// 客户端可显式订阅某个班级(服务端二次校验权限)
|
||||
socket.on('class:join', (classId, ack) => {
|
||||
void (async () => {
|
||||
try {
|
||||
const allowed = await getAccessibleClassIds({
|
||||
sub: user.id,
|
||||
username: user.username,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
classId: user.classId,
|
||||
});
|
||||
if (!allowed.includes(classId)) {
|
||||
ack?.({ ok: false, message: '无权订阅该班级' });
|
||||
return;
|
||||
}
|
||||
await socket.join(SOCKET_ROOMS.class(classId));
|
||||
ack?.({ ok: true });
|
||||
} catch {
|
||||
ack?.({ ok: false, message: '订阅失败' });
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
socket.on('class:leave', (classId) => {
|
||||
void socket.leave(SOCKET_ROOMS.class(classId));
|
||||
});
|
||||
|
||||
socket.on('ping', (ack) => {
|
||||
ack?.();
|
||||
});
|
||||
|
||||
socket.on('disconnect', (reason) => {
|
||||
logger.debug(`实时连接断开:${user.name} (${reason})`);
|
||||
});
|
||||
});
|
||||
|
||||
const emit: RealtimeEmitter = (rooms, event, payload) => {
|
||||
const target = io.to(rooms) as unknown as { emit: (event: string, payload: unknown) => void };
|
||||
target.emit(event, payload);
|
||||
logger.debug(`实时推送 ${event} -> ${rooms.join(',')}`);
|
||||
};
|
||||
registerEmitter(emit);
|
||||
|
||||
logger.info('Socket.IO 实时通道已就绪(房间规则:class:{classId} / user:{id})');
|
||||
return io;
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import type { TokenPayload } from '../lib/jwt.js';
|
||||
|
||||
/**
|
||||
* 扩展 Express 的 Request 类型。
|
||||
* - auth:认证中间件解析出的当前用户
|
||||
* - validatedQuery / validatedParams:校验中间件产出的类型安全数据
|
||||
* (Express 5 中 req.query 是只读 getter,不能直接覆盖,因此单独存放)
|
||||
*/
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
auth?: {
|
||||
token: string;
|
||||
user: TokenPayload;
|
||||
};
|
||||
validatedQuery?: unknown;
|
||||
validatedParams?: unknown;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
Reference in new issue
Block a user