Files
ClassHelper/packages/server/prisma/seed.ts
T
LoveChengke 20fcca0388 feat(server,web,shared,desktop): 上传超限正确报错 + 两个登录入口都限流 + 作业按天深链,文档与验证脚本同步
服务端加固:
- express.json 上限 2mb → 12mb:表格导入把文件以 base64 放进 JSON(约 4/3 倍),
  8MB 的声明上限原先到 1.5MB 就被拦掉;超限现在由 error 中间件映射成
  413 IMPORT_TOO_LARGE(中文提示"请拆分后分批导入"),不再落进 500 兜底分支抛英文原文;
- loginRateLimiter 同时挂 /auth/login 与 /auth/class-login(班级密码默认 123456,
  班级登录曾是唯一没有暴力破解防护的入口),两个路径共享同一计数器;
- CORS_ORIGIN=* 时启动告警(不拦截启动,避免挡住教室机 file:// 的合法部署);
- 删除已无用的 DEFAULT_STUDENT_PASSWORD;班级 termWeeks 上限 40 → 30,
  与 MAX_TERM_WEEK / weekNumberSchema 对齐(原先对外承诺了系统兑现不了的学期长度)。

Web 端:
- 作业页 ?date= 深链在同路由再推 query 时也生效(watch route.query.date);
- 表格导入对话框适配 8MB 上限与 413 提示。

冒烟与文档:
- 新增 scripts/lib/smoke-class.mjs:验证用临时班级,避免探针通知弹到用户自己的客户端与灵动岛;
- README 与 AGENTS.md 同步本轮全部行为变更(含 schedule source、学生账号清理、凭据门禁)。
2026-10-02 00:25:21 +08:00

393 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 种子数据:pnpm db:seed
*
* 会清空业务表后重建一套完整演示数据:
* 1 管理员 / 2 教师 / 3 个班级 / 15 名学生 / 课程 / 课表 / 作业 / 通知 / 成绩
*
* 演示账号(密码见文件末尾打印结果):
* admin / admin123 - 系统管理员
* teacher1 / teacher123 - 张老师(高一(1)班、高二(3)班班主任)
* teacher2 / teacher123 - 李老师(高一(2)班班主任,高二(3)班协作教师)
* student01..student15 / student123
*/
import { SUBJECT_CATALOG } from '@classhelper/shared';
import { disconnectPrisma, prisma } from '../src/lib/db.js';
import { logger } from '../src/lib/logger.js';
import { hashPassword } from '../src/lib/password.js';
import { MAX_TERM_WEEK } from '../src/lib/term.js';
const STUDENT_NAMES = [
'王小明',
'李思远',
'张欣怡',
'刘子涵',
'陈嘉豪',
'杨雨欣',
'赵依然',
'黄浩然',
'周思彤',
'吴俊杰',
'徐若曦',
'孙晨曦',
'马文博',
'朱佳琪',
'胡一鸣',
];
// 课程目录:与 shared 的 SUBJECT_CATALOG 保持一致(演示数据覆盖全部常见科目)
const COURSE_NAMES = [...SUBJECT_CATALOG];
/** 每天的上课节次模板(dayOfWeek 1-5) */
const PERIOD_TEMPLATE = [
{ startTime: '08:00', endTime: '08:45' },
{ startTime: '08:55', endTime: '09:40' },
{ startTime: '10:00', endTime: '10:45' },
{ startTime: '10:55', endTime: '11:40' },
{ startTime: '14:00', endTime: '14:45' },
{ startTime: '14:55', endTime: '15:40' },
];
const CLASS_ROOMS = ['教学楼 A301', '教学楼 A302', '实验楼 B205'];
/** 固定种子的伪随机数,保证每次 seed 结果一致 */
function createRandom(seed: number): () => number {
let state = seed;
return () => {
state = (state * 1103515245 + 12345) % 2147483648;
return state / 2147483648;
};
}
async function resetDatabase(): Promise<void> {
// 顺序敏感:先删子表,再删主表
await prisma.grade.deleteMany();
await prisma.homeworkStatus.deleteMany();
await prisma.homework.deleteMany();
await prisma.notificationRead.deleteMany();
await prisma.notification.deleteMany();
await prisma.schedule.deleteMany();
await prisma.course.deleteMany();
await prisma.enrollment.deleteMany();
await prisma.classTeacher.deleteMany();
await prisma.user.updateMany({ data: { classId: null } });
await prisma.user.deleteMany();
await prisma.class.deleteMany();
}
async function main(): Promise<void> {
logger.info('开始写入种子数据...');
if (process.env.NODE_ENV === 'production') {
throw new Error('拒绝在生产环境执行 seed(NODE_ENV=production)');
}
await resetDatabase();
const random = createRandom(20260901);
// 同一角色共用密码,只需各哈希一次
// 学生没有密码:个人学生账号已清理(2026-10-01),学生端统一用班级码 + 班级密码登录
const [adminHash, teacherHash, classPasswordHash] = await Promise.all([
hashPassword('admin123'),
hashPassword('teacher123'),
// 班级账号默认密码(学生端「班级登录」)
hashPassword('123456'),
]);
const admin = await prisma.user.create({
data: { username: 'admin', name: '系统管理员', role: 'ADMIN', passwordHash: adminHash },
});
const teacher1 = await prisma.user.create({
data: { username: 'teacher1', name: '张老师', role: 'TEACHER', passwordHash: teacherHash },
});
const teacher2 = await prisma.user.create({
data: { username: 'teacher2', name: '李老师', role: 'TEACHER', passwordHash: teacherHash },
});
// 班级账号:班级码(学生端「班级登录」的账号)+ 班级密码(默认 123456,可在班级管理里重置)
const classSeeds = [
{ name: '高一(1)班', grade: '高一', teacherId: teacher1.id, code: 'G101' },
{ name: '高一(2)班', grade: '高一', teacherId: teacher2.id, code: 'G102' },
{ name: '高二(3)班', grade: '高二', teacherId: teacher1.id, code: 'G203' },
];
const classes = [];
for (const [index, seed] of classSeeds.entries()) {
const created = await prisma.class.create({
data: {
name: seed.name,
grade: seed.grade,
teacherId: seed.teacherId,
code: seed.code,
passwordHash: classPasswordHash,
},
});
classes.push({ ...created, room: CLASS_ROOMS[index] ?? '教学楼 A101' });
}
// 高二(3)班:李老师作为协作教师
await prisma.classTeacher.create({
data: { classId: classes[2]!.id, teacherId: teacher2.id },
});
// ---------------------------------------------------------------- 学生
const students = [];
for (let index = 0; index < STUDENT_NAMES.length; index += 1) {
const classIndex = Math.floor(index / 5);
const targetClass = classes[classIndex] ?? classes[0]!;
const student = await prisma.user.create({
data: {
username: `student${String(index + 1).padStart(2, '0')}`,
name: STUDENT_NAMES[index] ?? `学生${index + 1}`,
role: 'STUDENT',
classId: targetClass.id,
// 空串占位:User.passwordHash 必填,但学生登录的 403 判定在密码校验之前,永远用不到
passwordHash: '',
},
});
students.push(student);
await prisma.enrollment.create({ data: { userId: student.id, classId: targetClass.id } });
}
// ---------------------------------------------------------------- 课程
const coursesByClass = new Map<string, { id: string; name: string }[]>();
for (const targetClass of classes) {
const created = [];
for (const courseName of COURSE_NAMES) {
const course = await prisma.course.create({
data: { name: courseName, classId: targetClass.id, teacherId: targetClass.teacherId },
});
created.push({ id: course.id, name: course.name });
}
coursesByClass.set(targetClass.id, created);
}
// ---------------------------------------------------------------- 课表
for (const targetClass of classes) {
const classCourses = coursesByClass.get(targetClass.id) ?? [];
for (let dayOfWeek = 1; dayOfWeek <= 5; dayOfWeek += 1) {
for (const [periodIndex, period] of PERIOD_TEMPLATE.entries()) {
const course = classCourses[(dayOfWeek + periodIndex) % classCourses.length];
if (!course) continue;
// 前 2 节限制在第 1 周(演示按周筛选)
const onlyFirstWeek = dayOfWeek === 1 && periodIndex === 0;
// 周二第 3 节演示"单双周":单周上次课表里的科目、双周上一门别的科目
const paritySlot = dayOfWeek === 2 && periodIndex === 2;
await prisma.schedule.create({
data: {
classId: targetClass.id,
courseId: course.id,
dayOfWeek,
startTime: period.startTime,
endTime: period.endTime,
location: targetClass.room,
weekStart: 1,
weekEnd: onlyFirstWeek ? 1 : MAX_TERM_WEEK,
weekParity: paritySlot ? 'ODD' : 'ALL',
},
});
if (paritySlot) {
// 双周同一节换另一门课,用于演示"单双周课表"(第 1 周单周、第 2 周双周)
const evenCourse = classCourses[(dayOfWeek + periodIndex + 2) % classCourses.length];
if (evenCourse) {
await prisma.schedule.create({
data: {
classId: targetClass.id,
courseId: evenCourse.id,
dayOfWeek,
startTime: period.startTime,
endTime: period.endTime,
location: targetClass.room,
weekStart: 1,
weekEnd: MAX_TERM_WEEK,
weekParity: 'EVEN',
},
});
}
}
}
}
}
// ---------------------------------------------------------------- 作业
// 注:作业"截止时间"功能已下线,种子数据不再写入 dueAt(库字段保留以兼容已装库)
const now = Date.now();
const day = 86_400_000;
const homeworkSeeds = [
{
title: '《劝学》全文背诵',
content: '背诵《劝学》并录制音频上传到班级群,注意断句。',
courseIndex: 0,
},
{
title: '数学必修一 3.2 习题',
content: '完成课本 P78 习题 3.2 全部题目,第 12 题选做。',
courseIndex: 1,
},
{
title: '英语周记一篇',
content: '以"My School Life"为题写一篇 150 词左右的英语周记。',
courseIndex: 2,
},
{
title: '物理实验报告',
content: '整理"探究匀变速直线运动"实验数据,撰写实验报告。',
courseIndex: 3,
},
{
title: '化学方程式默写',
content: '默写第一至第三章全部化学方程式,家长签字。',
courseIndex: 4,
},
];
const homeworks = [];
for (const targetClass of classes) {
const classCourses = coursesByClass.get(targetClass.id) ?? [];
for (const seed of homeworkSeeds) {
const course = classCourses[seed.courseIndex % classCourses.length];
const homework = await prisma.homework.create({
data: {
classId: targetClass.id,
courseId: course?.id ?? null,
title: `${seed.title}`,
content: seed.content,
attachmentUrl:
seed.courseIndex % 2 === 0 ? 'https://example.com/classhelper/homework-sample.pdf' : null,
createdBy: targetClass.teacherId,
},
});
homeworks.push(homework);
}
}
// 部分学生已完成部分作业
const studentsByClass = new Map<string, typeof students>();
for (const student of students) {
if (!student.classId) continue;
const bucket = studentsByClass.get(student.classId) ?? [];
bucket.push(student);
studentsByClass.set(student.classId, bucket);
}
for (const homework of homeworks) {
const classmates = studentsByClass.get(homework.classId) ?? [];
for (const [index, student] of classmates.entries()) {
const completed = (index + homework.title.length) % 3 !== 0;
if (!completed && index % 2 === 1) continue;
await prisma.homeworkStatus.create({
data: { homeworkId: homework.id, userId: student.id, completed },
});
}
}
// ---------------------------------------------------------------- 通知
const notificationSeeds = [
{
title: '关于期中考试安排的通知',
content: '期中考试定于下月 8 日至 10 日进行,请同学们提前复习,考试安排详见班级公告栏。',
priority: 'HIGH',
daysAgo: 0.2,
},
{
title: '明天下午临时调课',
content: '因教师外出教研,明天下午第 5、6 节课与周五下午对调,请相互转告。',
priority: 'URGENT',
daysAgo: 0.6,
},
{
title: '校运动会报名开始',
content: '本届校运动会开始报名,每人限报 2 个项目,请到体育委员处登记。',
priority: 'NORMAL',
daysAgo: 2,
},
{
title: '教室卫生值日表更新',
content: '新的值日表已张贴在教室后墙,请各小组按时完成值日。',
priority: 'LOW',
daysAgo: 4,
},
];
for (const targetClass of classes) {
for (const seed of notificationSeeds) {
const notification = await prisma.notification.create({
data: {
classId: targetClass.id,
title: seed.title,
content: seed.content,
priority: seed.priority,
createdBy: targetClass.teacherId,
createdAt: new Date(now - seed.daysAgo * day),
},
});
// 第一条通知:部分学生已读,用于演示未读红点
if (seed.priority === 'LOW') {
const classmates = (studentsByClass.get(targetClass.id) ?? []).slice(0, 3);
for (const student of classmates) {
await prisma.notificationRead.create({
data: { notificationId: notification.id, userId: student.id },
});
}
}
}
}
// ---------------------------------------------------------------- 成绩
const examNames = ['第一次月考', '期中考试'];
for (const targetClass of classes) {
const classCourses = coursesByClass.get(targetClass.id) ?? [];
const classmates = studentsByClass.get(targetClass.id) ?? [];
for (const [examIndex, examName] of examNames.entries()) {
for (const course of classCourses.slice(0, 3)) {
for (const student of classmates) {
const base = 60 + random() * 38;
const score = Math.round(Math.min(100, Math.max(35, base - examIndex * 2)));
await prisma.grade.create({
data: {
classId: targetClass.id,
courseId: course.id,
userId: student.id,
examName,
score,
totalScore: 100,
publishedAt: new Date(now - (examIndex + 1) * 6 * day),
},
});
}
}
}
}
const counts = {
用户: await prisma.user.count(),
班级: await prisma.class.count(),
课程: await prisma.course.count(),
课表: await prisma.schedule.count(),
作业: await prisma.homework.count(),
通知: await prisma.notification.count(),
成绩: await prisma.grade.count(),
};
logger.info('种子数据写入完成', counts);
console.log('\n================ 演示账号 ================');
console.log(`管理员 admin / admin123 (${admin.name})`);
console.log(`教师 teacher1 / teacher123 (${teacher1.name} · 高一(1)班、高二(3)班)`);
console.log(`教师 teacher2 / teacher123 (${teacher2.name} · 高一(2)班、高二(3)班协作)`);
console.log('学生端(班级账号):G101 / G102 / G203,密码 123456');
console.log('(学生只有名单、没有个人账号,成绩/未交/叫人按名单记录)');
console.log('==========================================\n');
}
main()
.catch((error) => {
logger.error('种子数据写入失败', error);
process.exitCode = 1;
})
.finally(async () => {
await disconnectPrisma();
});