From 4dc824c28357f2ee8077a651ff4f3e86c1e30a68 Mon Sep 17 00:00:00 2001 From: LoveChengke Date: Sat, 17 Oct 2026 09:34:23 +0800 Subject: [PATCH] =?UTF-8?q?feat(server):=20=E7=BB=9F=E4=B8=80=E8=AF=BE?= =?UTF-8?q?=E8=A1=A8=E8=A7=A3=E6=9E=90=E5=B9=B6=E5=AE=8C=E5=96=84=E6=9D=83?= =?UTF-8?q?=E9=99=90=E8=8C=83=E5=9B=B4=E4=B8=8E=E5=B9=B4=E5=BA=A6=E5=8D=87?= =?UTF-8?q?=E7=BA=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/server/prisma/seed.ts | 34 +- packages/server/src/index.ts | 2 + packages/server/src/lib/access.ts | 30 +- packages/server/src/lib/class-plan.ts | 131 ++++++ packages/server/src/lib/legacy-class-plans.ts | 85 ++++ packages/server/src/lib/mappers.ts | 6 +- packages/server/src/lib/permission-scope.ts | 32 ++ packages/server/src/lib/permissions.ts | 16 + packages/server/src/lib/schemas.ts | 2 +- packages/server/src/lib/session.ts | 3 +- packages/server/src/lib/settings.ts | 2 + packages/server/src/lib/snapshot.ts | 8 + packages/server/src/lib/students.ts | 7 + packages/server/src/lib/term.ts | 15 +- packages/server/src/modules/ai/ai.service.ts | 3 +- .../src/modules/archives/archives.module.ts | 27 +- .../modules/archives/transferred.service.ts | 49 +++ .../server/src/modules/calls/calls.service.ts | 5 +- .../modules/class-plans/class-plans.module.ts | 241 +++++++++++ .../class-plans/class-plans.schemas.ts | 72 ++++ .../class-plans/class-plans.service.ts | 228 +++++++++++ .../class-plans/profile-import.service.ts | 277 +++++++++++++ .../src/modules/classes/classes.schemas.ts | 8 +- .../src/modules/classes/classes.service.ts | 27 +- .../src/modules/clients/clients.module.ts | 2 +- .../src/modules/clients/clients.service.ts | 26 +- .../src/modules/courses/courses.schemas.ts | 4 + .../src/modules/courses/courses.service.ts | 14 +- .../modules/dashboard/dashboard.service.ts | 10 +- .../src/modules/database/database.service.ts | 52 ++- .../src/modules/grades/grades.module.ts | 17 + .../src/modules/grades/grades.service.ts | 126 +++++- .../modules/homeworks/homeworks.service.ts | 32 +- .../src/modules/imports/class-plan.service.ts | 123 +----- .../src/modules/imports/imports.module.ts | 6 +- .../modules/imports/table-import.service.ts | 9 +- .../modules/imports/time-layout.service.ts | 117 +++--- .../notifications/notifications.service.ts | 3 +- .../permissions/permissions.schemas.ts | 4 + .../permissions/permissions.service.ts | 10 + packages/server/src/modules/registry.ts | 8 + .../src/modules/schedules/schedules.facade.ts | 385 ++++++++++++++++++ .../src/modules/schedules/schedules.module.ts | 76 +++- .../modules/schedules/schedules.service.ts | 208 +--------- .../src/modules/students/students.service.ts | 17 +- .../server/src/modules/system/autostart.ts | 77 ++++ .../src/modules/system/system.service.ts | 84 +--- .../src/modules/term/promotion.service.ts | 184 +++++++++ .../server/src/modules/term/term.module.ts | 24 ++ .../server/src/modules/term/term.schemas.ts | 1 + .../server/src/modules/term/term.service.ts | 15 +- .../modules/update/client-releases.service.ts | 31 +- .../src/modules/update/release-progress.ts | 27 ++ .../src/modules/update/update.module.ts | 24 +- 54 files changed, 2401 insertions(+), 625 deletions(-) create mode 100644 packages/server/src/lib/class-plan.ts create mode 100644 packages/server/src/lib/legacy-class-plans.ts create mode 100644 packages/server/src/lib/permission-scope.ts create mode 100644 packages/server/src/lib/students.ts create mode 100644 packages/server/src/modules/archives/transferred.service.ts create mode 100644 packages/server/src/modules/class-plans/class-plans.module.ts create mode 100644 packages/server/src/modules/class-plans/class-plans.schemas.ts create mode 100644 packages/server/src/modules/class-plans/class-plans.service.ts create mode 100644 packages/server/src/modules/class-plans/profile-import.service.ts create mode 100644 packages/server/src/modules/schedules/schedules.facade.ts create mode 100644 packages/server/src/modules/system/autostart.ts create mode 100644 packages/server/src/modules/term/promotion.service.ts create mode 100644 packages/server/src/modules/update/release-progress.ts diff --git a/packages/server/prisma/seed.ts b/packages/server/prisma/seed.ts index a8931b9..ba6d0d2 100644 --- a/packages/server/prisma/seed.ts +++ b/packages/server/prisma/seed.ts @@ -24,7 +24,10 @@ import { 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'; +import { syncLegacyClassPlans } from '../src/lib/legacy-class-plans.js'; +import { ensureDefaultGroups } from '../src/lib/permissions.js'; +import { getMaxTermWeeks } from '../src/lib/term.js'; +const MAX_TERM_WEEK = await getMaxTermWeeks(); const STUDENT_NAMES = [ '王小明', @@ -213,7 +216,7 @@ async function main(): Promise { data: { name: courseName, classId: targetClass.id, - teacherId: courseName === OTHER_TEACHER_SUBJECT ? otherTeacher : targetClass.teacherId, + teacherId: courseName === OTHER_TEACHER_SUBJECT ? otherTeacher : targetClass.teacherId!, }, }); created.push({ id: course.id, name: course.name }); @@ -316,7 +319,7 @@ async function main(): Promise { assignDate: dayKeyLocal(new Date()), attachmentUrl: seed.courseIndex % 2 === 0 ? 'https://example.com/classhelper/homework-sample.pdf' : null, - createdBy: targetClass.teacherId, + createdBy: targetClass.teacherId!, }, }); homeworks.push(homework); @@ -379,7 +382,7 @@ async function main(): Promise { title: seed.title, content: seed.content, priority: seed.priority, - createdBy: targetClass.teacherId, + createdBy: targetClass.teacherId!, createdAt: new Date(now - seed.daysAgo * day), }, }); @@ -405,11 +408,7 @@ async function main(): Promise { const examNames = ['第一次月考', '期中考试']; const customLevels = ['优', '良', '合格'] as const; const letterLevels = ['A', 'B', 'C', 'D'] as const; - const levelPlan = [ - { type: 'percent' as const }, - { type: 'custom' as const }, - { type: 'letter' as const }, - ]; + const levelPlan = [{ type: 'percent' as const }, { type: 'custom' as const }, { type: 'letter' as const }]; for (const targetClass of classes) { const classCourses = coursesByClass.get(targetClass.id) ?? []; @@ -426,7 +425,8 @@ async function main(): Promise { ? gradeLevel(percent) : plan.type === 'letter' ? (letterLevels[Math.min(letterLevels.length - 1, Math.floor((100 - percent) / 12))] ?? 'D') - : (customLevels[Math.min(customLevels.length - 1, Math.floor((100 - percent) / 15))] ?? '合格'); + : (customLevels[Math.min(customLevels.length - 1, Math.floor((100 - percent) / 15))] ?? + '合格'); await prisma.grade.create({ data: { classId: targetClass.id, @@ -579,12 +579,20 @@ async function main(): Promise { console.log('\n================ 演示账号 ================'); console.log(`管理员 admin / admin123 (${admin.name} · 可执行毕业归档与调班)`); - console.log(`教师 teacher1 / teacher123 (${teacher1.name} · ${classes[0]!.name} 班主任,教数学)`); - console.log(`教师 teacher2 / teacher123 (${teacher2.name} · ${classes[1]!.name} 班主任,教 ${classes[0]!.name} 语文)`); + console.log( + `教师 teacher1 / teacher123 (${teacher1.name} · ${classes[0]!.name} 班主任,教数学)`, + ); + console.log( + `教师 teacher2 / teacher123 (${teacher2.name} · ${classes[1]!.name} 班主任,教 ${classes[0]!.name} 语文)`, + ); + await ensureDefaultGroups(); + for (const cls of classes) await syncLegacyClassPlans(cls.id); console.log('ClassHelper 班级端(班级码):G101 / G102 / G203,密码 123456'); console.log('(学生只有名单、没有账号:成绩/未交/叫人按学号记录)'); console.log(`已在读班级:${classes.map((item) => item.name).join(' / ')}`); - console.log(`已归档届别:${formatYearLabel(2023)}(${graduatedClass.name},${graduateNames.length} 名毕业生)`); + console.log( + `已归档届别:${formatYearLabel(2023)}(${graduatedClass.name},${graduateNames.length} 名毕业生)`, + ); console.log('==========================================\n'); } diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 4f9494c..722113d 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -11,6 +11,7 @@ import { backfillNotificationDates } from './lib/backfill-notification-dates.js' import { refreshDeveloperMode } from './lib/settings.js'; import { resolveWebDistDir } from './lib/web-static.js'; import { initRealtime } from './realtime/socket.js'; +import { startPromotionScheduler } from './modules/term/promotion.service.js'; import { startBackupScheduler } from './modules/database/database.service.js'; /** 数据库自检与可选的首次初始化 */ @@ -95,6 +96,7 @@ httpServer.listen(env.port, env.host, () => { logger.info(`WebSocket:ws://${displayHost}:${env.port}/socket.io`); // 数据库管理模块的定时备份调度(配置与"到点时间"持久化在 data/database-settings.json) startBackupScheduler(); + void startPromotionScheduler().catch((e) => logger.error(`学年升级调度启动失败:${String(e)}`)); }); let shuttingDown = false; diff --git a/packages/server/src/lib/access.ts b/packages/server/src/lib/access.ts index a37e634..40fd118 100644 --- a/packages/server/src/lib/access.ts +++ b/packages/server/src/lib/access.ts @@ -1,3 +1,4 @@ +import type { PermissionKey as ContractPermissionKey } from '@classhelper/shared'; import { canAssignTeachers, canManageClasses, @@ -9,6 +10,7 @@ import { resolveClassRole, type ClassRole, } from '@classhelper/shared'; +import { teacherScopeWhere } from './permission-scope.js'; import { prisma } from './db.js'; import { ApiError } from './http.js'; import type { TokenPayload } from './jwt.js'; @@ -64,14 +66,11 @@ export function classDeviceClassId(user: TokenPayload): string | null { * 这里就是「科任老师」的判定源 —— 以前查的是没有科目的 `ClassTeacher` 协作表, * 现在查 `Course`,于是"能不能进这个班"与"能不能改这一科的作业成绩"用的是同一份事实。 */ -function classMembershipWhere(teacherId: string) { - return { - OR: [{ teacherId }, { courses: { some: { teacherId } } }], - }; -} - /** 当前用户可访问的全部班级 id */ -export async function getAccessibleClassIds(user: TokenPayload): Promise { +export async function getAccessibleClassIds( + user: TokenPayload, + permission?: ContractPermissionKey, +): Promise { if (isAdmin(user)) { const classes = await prisma.class.findMany({ select: { id: true } }); return classes.map((item) => item.id); @@ -79,7 +78,7 @@ export async function getAccessibleClassIds(user: TokenPayload): Promise item.id); @@ -92,7 +91,9 @@ export async function getAccessibleClassIds(user: TokenPayload): Promise { if (isAdmin(user)) return true; if (isTeacher(user)) { - const count = await prisma.class.count({ where: { id: classId, ...classMembershipWhere(user.sub) } }); + const count = await prisma.class.count({ + where: { AND: [{ id: classId }, await teacherScopeWhere(user)] }, + }); return count > 0; } return classDeviceClassId(user) === classId; @@ -157,6 +158,7 @@ export async function assertCanManageRoster(user: TokenPayload, classId?: string if (!isAdmin(user)) throw ApiError.forbidden('该操作需要指定班级,只有管理员可以全局执行'); return; } + await assertClassAccess(user, classId); const classRole = await resolveUserClassRole(user, classId); if (!canManageRoster(classRole)) { throw ApiError.forbidden( @@ -169,6 +171,7 @@ export async function assertCanManageRoster(user: TokenPayload, classId?: string /** 课表管理(含时间配置导入):管理员或本班班主任 */ export async function assertCanManageSchedule(user: TokenPayload, classId: string): Promise { + await assertClassAccess(user, classId); const classRole = await resolveUserClassRole(user, classId); if (!canManageSchedule(classRole)) { throw ApiError.forbidden( @@ -201,6 +204,7 @@ export async function assertCanManageGrades(user: TokenPayload, classId?: string return; } + await assertClassAccess(user, classId); const classRole = await resolveUserClassRole(user, classId); if (canManageGrades(classRole)) return; @@ -288,14 +292,18 @@ export function requireClassDeviceClassId(user: TokenPayload): string { */ export type ClassScope = { mode: 'all' } | { mode: 'in'; classIds: string[] }; -export async function resolveClassScope(user: TokenPayload, requestedClassId?: string): Promise { +export async function resolveClassScope( + user: TokenPayload, + requestedClassId?: string, + permission?: ContractPermissionKey, +): Promise { if (requestedClassId) { await assertClassAccess(user, requestedClassId); return { mode: 'in', classIds: [requestedClassId] }; } if (isAdmin(user)) return { mode: 'all' }; - const classIds = await getAccessibleClassIds(user); + const classIds = await getAccessibleClassIds(user, permission); // 没有任何班级时不抛错,返回空集合让列表接口给出空数组,避免新教师首次登录报错 return { mode: 'in', classIds }; } diff --git a/packages/server/src/lib/class-plan.ts b/packages/server/src/lib/class-plan.ts new file mode 100644 index 0000000..3e7b3b8 --- /dev/null +++ b/packages/server/src/lib/class-plan.ts @@ -0,0 +1,131 @@ +import { + dayKeyLocal, + resolveClassStatus, + type ScheduleDto, + type ActiveClassPlanDto, +} from '@classhelper/shared'; +import { prisma } from './db.js'; +import { loadTermContext, resolveCurrentWeek } from './term.js'; + +export const planInclude = { + entries: { include: { course: { select: { id: true, name: true } }, timePoint: true } }, + layout: { include: { points: { orderBy: { position: 'asc' as const } } } }, + group: true, +} as const; +export type PlanRow = NonNullable>>[number]; +export async function loadPlans(classId: string) { + return prisma.classPlan.findMany({ + where: { classId }, + include: planInclude, + orderBy: { updatedAt: 'desc' }, + }); +} +export function projectPlan(plan: PlanRow): ScheduleDto[] { + return plan.entries + .filter((e) => e.isEnabled && e.course && e.timePoint.timeType === 'LESSON') + .map((e) => ({ + id: e.legacyScheduleId || e.id, + classId: plan.classId, + courseId: e.courseId!, + course: e.course!, + dayOfWeek: plan.weekday || 1, + startTime: e.timePoint.startTime, + endTime: e.timePoint.endTime, + location: e.location, + weekStart: plan.weekStart, + weekEnd: plan.weekEnd, + weekParity: + plan.weekCountDivTotal === 2 && plan.weekCountDiv + ? plan.weekCountDiv === 1 + ? 'ODD' + : 'EVEN' + : 'ALL', + source: 'manual', + createdAt: plan.createdAt.toISOString(), + updatedAt: plan.updatedAt.toISOString(), + })); +} + +/** + * 唯一日课表解析器。按现场上游 LessonsService.GetClassPlanByDate: + * 预定(含预定临时层)→临时课表→有效临时群/选定群的 TimeRule→全局群→无群默认。 + * 上游现在把临时层也作为预定课表;本地 overlayDate 保留同日临时层兼容。 + * IsActivated 是运行态,不落库;周次只用 loadTermContext/resolveCurrentWeek。 + */ +export async function resolveActiveClassPlan( + classId: string, + date: Date = new Date(), +): Promise { + const day = dayKeyLocal(date); + const weekday = date.getDay() || 7; + const week = resolveCurrentWeek(date, await loadTermContext(classId)); + const [plans, scheduled, setting] = await Promise.all([ + loadPlans(classId), + prisma.scheduledClassPlan.findUnique({ where: { classId_date: { classId, date: day } } }), + prisma.appSetting.findUnique({ where: { key: `schedule.group.${classId}` } }), + ]); + let groupId: string | null = null; + let temporaryPlanId: string | null = null; + if (setting) { + try { + const value = JSON.parse(setting.value) as { + selected?: string; + temporary?: string; + expireTime?: string; + temporaryPlan?: string; + temporaryPlanDate?: string; + }; + temporaryPlanId = value.temporaryPlanDate === day ? value.temporaryPlan || null : null; + groupId = + value.temporary && value.expireTime && new Date(value.expireTime) >= date + ? value.temporary + : value.selected || null; + } catch { + /* 非法/旧设置回到默认群 */ + } + } + const matches = (p: PlanRow) => + p.isEnabled && + !p.isOverlay && + (p.weekday === 0 || p.weekday === weekday) && + week >= p.weekStart && + week <= p.weekEnd && + (p.weekCountDiv === 0 || ((week - 1) % p.weekCountDivTotal) + 1 === p.weekCountDiv); + const candidates = plans.filter(matches); + const plan = + plans.find((p) => p.id === scheduled?.classPlanId) ?? + plans.find((p) => p.id === temporaryPlanId) ?? + plans.find((p) => p.isOverlay && p.overlayDate === day && p.isEnabled) ?? + candidates.find( + (p) => p.associatedGroup && (groupId ? p.associatedGroup === groupId : p.group?.isDefault), + ) ?? + candidates.find((p) => p.group?.isGlobal) ?? + candidates.find((p) => !p.associatedGroup) ?? + null; + const schedules = plan + ? projectPlan(plan).map((s) => ({ + ...s, + dayOfWeek: weekday, + weekStart: week, + weekEnd: week, + })) + : []; + const status = resolveClassStatus(schedules, date, week); + return { + classPlan: plan, + timeLayout: plan + ? { + ...plan.layout, + points: plan.layout.points.map((p) => ({ + ...p, + timeType: p.timeType as 'LESSON' | 'BREAK' | 'DIVIDER' | 'ACTION', + })), + } + : null, + schedules, + week, + current: status.current, + next: status.next, + inClass: status.inClass, + }; +} diff --git a/packages/server/src/lib/legacy-class-plans.ts b/packages/server/src/lib/legacy-class-plans.ts new file mode 100644 index 0000000..b09b9fb --- /dev/null +++ b/packages/server/src/lib/legacy-class-plans.ts @@ -0,0 +1,85 @@ +import { prisma } from './db.js'; + +/** 种子初始化时将旧行物化到按周展开的完整日课表,保留周次范围,最多 60 周,绝不改原表作迁移来源。 */ +export async function syncLegacyClassPlans(classId: string) { + await prisma.$transaction( + async (tx) => { + const rows = await tx.schedule.findMany({ where: { classId }, orderBy: { createdAt: 'asc' } }); + await tx.classPlan.deleteMany({ where: { classId, id: { startsWith: `legacy-plan-${classId}-` } } }); + for (const row of rows) { + const key = 'schedule.compat.' + row.id, + value = JSON.stringify({ + weekStart: row.weekStart, + weekEnd: row.weekEnd, + weekParity: row.weekParity, + }); + await tx.appSetting.upsert({ where: { key }, create: { key, value }, update: { value } }); + } + const days = [...new Set(rows.map((r) => r.dayOfWeek))]; + for (const day of days) { + const layoutId = `legacy-layout-${classId}-${day}`; + await tx.timeLayout.upsert({ + where: { id: layoutId }, + create: { id: layoutId, classId, name: `兼容作息 ${day}`, source: 'manual' }, + update: {}, + }); + const points = new Map(); + for (const row of rows.filter((r) => r.dayOfWeek === day)) { + const key = `${row.startTime}-${row.endTime}`; + if (points.has(key)) continue; + const id = `${layoutId}-${key}`; + points.set(key, id); + await tx.timeLayoutItem.upsert({ + where: { id }, + create: { + id, + timeLayoutId: layoutId, + position: points.size, + startTime: row.startTime, + endTime: row.endTime, + }, + update: {}, + }); + } + for (let week = 1; week <= 60; week++) { + const active = rows.filter( + (r) => + r.dayOfWeek === day && + r.weekStart <= week && + r.weekEnd >= week && + (r.weekParity === 'ALL' || r.weekParity === (week % 2 ? 'ODD' : 'EVEN')), + ); + if (!active.length) continue; + const occupied = new Set(); + await tx.classPlan.create({ + data: { + id: `legacy-plan-${classId}-${day}-${week}`, + classId, + name: `兼容第 ${week} 周星期 ${day}`, + timeLayoutId: layoutId, + weekday: day, + weekStart: week, + weekEnd: week, + entries: { + create: active + .filter((r) => { + const point = points.get(`${r.startTime}-${r.endTime}`)!; + if (occupied.has(point)) return false; + occupied.add(point); + return true; + }) + .map((r) => ({ + courseId: r.courseId, + timeLayoutItemId: points.get(`${r.startTime}-${r.endTime}`)!, + legacyScheduleId: r.id, + location: r.location, + })), + }, + }, + }); + } + } + }, + { timeout: 30000 }, + ); +} diff --git a/packages/server/src/lib/mappers.ts b/packages/server/src/lib/mappers.ts index c2a6a2e..9b0d52f 100644 --- a/packages/server/src/lib/mappers.ts +++ b/packages/server/src/lib/mappers.ts @@ -187,7 +187,7 @@ export interface ClassLike { enrollmentYear?: number | null; /** 班号;老数据可能为空 */ classIndex?: number | null; - teacherId: string; + teacherId: string | null; /** 本学期教学周数(班主任可调,默认 20) */ termWeeks?: number; /** 班级端按学号查成绩的开关 */ @@ -213,7 +213,7 @@ export function toClassDto(item: ClassLike): ClassDto { grade: item.grade, enrollmentYear: item.enrollmentYear ?? null, classIndex: item.classIndex ?? null, - teacherId: item.teacherId, + teacherId: item.teacherId ?? '', termWeeks: item.termWeeks ?? 20, // 默认开启:老库补列时的默认值也是 true,两边保持一致 studentGradeQueryEnabled: item.studentGradeQueryEnabled ?? true, @@ -241,6 +241,8 @@ export interface CourseLike { export function toCourseDto(item: CourseLike): CourseDto { return { + initial: 'initial' in item && item.initial ? String(item.initial) : Array.from(item.name)[0] || '', + isOutDoor: 'isOutDoor' in item ? item.isOutDoor === true : false, id: item.id, name: item.name, teacherId: item.teacherId, diff --git a/packages/server/src/lib/permission-scope.ts b/packages/server/src/lib/permission-scope.ts new file mode 100644 index 0000000..e75a68e --- /dev/null +++ b/packages/server/src/lib/permission-scope.ts @@ -0,0 +1,32 @@ +import { prisma } from './db.js'; +import type { TokenPayload } from './jwt.js'; +import type { Prisma } from '../generated/prisma/client.js'; +import type { PermissionKey } from '@classhelper/shared'; + +/** 范围与功能键正交;显式分组覆盖角色默认,不在范围内一律不放行。 */ +export async function teacherScopeWhere( + user: TokenPayload, + permission?: PermissionKey, +): Promise { + const assignments = await prisma.userPermissionGroup.findMany({ + where: { userId: user.sub }, + include: { group: { include: { items: true } } }, + }); + const own = { OR: [{ teacherId: user.sub }, { courses: { some: { teacherId: user.sub } } }] }; + if (!assignments.length) return own; + const alternatives: Prisma.ClassWhereInput[] = []; + for (const { group } of assignments) { + if (permission && !group.items.some((item) => item.key === permission)) continue; + if (group.scope === 'ALL') return {}; + if (group.scope === 'SELF_CLASS') + alternatives.push(group.scopeValue === 'HEAD' ? { teacherId: user.sub } : own); + if (group.scope === 'GRADE' || group.scope === 'DIVISION') { + const grades = group.scopeValue + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + if (grades.length) alternatives.push({ grade: { in: grades } }); + } + } + return { OR: alternatives }; +} diff --git a/packages/server/src/lib/permissions.ts b/packages/server/src/lib/permissions.ts index 4853a53..f0d8c1f 100644 --- a/packages/server/src/lib/permissions.ts +++ b/packages/server/src/lib/permissions.ts @@ -1,4 +1,5 @@ import { + BUILTIN_TEACHER_GROUPS, PERMISSION_KEYS, PERMISSION_LABELS, ROLE_DEFAULT_PERMISSIONS, @@ -68,6 +69,20 @@ export function ensureDefaultGroups(): Promise { return defaultGroupsReady; } async function createDefaultGroups(): Promise { + for (const group of BUILTIN_TEACHER_GROUPS) { + await prisma.permissionGroup.upsert({ + where: { id: group.id }, + update: {}, + create: { + id: group.id, + name: group.name, + builtin: true, + scope: group.scope, + scopeValue: group.scopeValue, + items: { create: group.keys.map((key) => ({ key })) }, + }, + }); + } for (const role of ['ADMIN', 'TEACHER'] as const) { if (await prisma.permissionGroup.findUnique({ where: { defaultRole: role }, select: { id: true } })) continue; @@ -86,6 +101,7 @@ async function createDefaultGroups(): Promise { defaultRole: role, name, builtin: true, + scope: role === 'ADMIN' ? 'ALL' : 'SELF_CLASS', description: '未分配其他权限组时自动生效;可调整权限。管理员始终保留权限与系统管理入口。', items: { create: ROLE_DEFAULT_PERMISSIONS[role].map((key) => ({ key })) }, }, diff --git a/packages/server/src/lib/schemas.ts b/packages/server/src/lib/schemas.ts index 8c5a290..c606693 100644 --- a/packages/server/src/lib/schemas.ts +++ b/packages/server/src/lib/schemas.ts @@ -52,7 +52,7 @@ export const attachmentUrlSchema = z .nullish(); /** 周次:1..30 */ -export const weekNumberSchema = z.coerce.number().int().min(1).max(30); +export const weekNumberSchema = z.coerce.number().int().min(1).max(60); /** * 学号:学生的唯一标识与查询键。 diff --git a/packages/server/src/lib/session.ts b/packages/server/src/lib/session.ts index abeb9a6..1a7ff17 100644 --- a/packages/server/src/lib/session.ts +++ b/packages/server/src/lib/session.ts @@ -1,4 +1,5 @@ import { classDeviceClassId, isClassDevice } from './access.js'; +import { activeStudentWhere } from './students.js'; import { prisma } from './db.js'; import type { TokenPayload } from './jwt.js'; @@ -26,7 +27,7 @@ export function classSessionClassId(user: TokenPayload): string | null { /** 该班级的全部学生 id(班级端的"全班"范围) */ export async function listClassStudentIds(classId: string): Promise { const students = await prisma.student.findMany({ - where: { classId, status: { not: 'inactive' } }, + where: { classId, ...activeStudentWhere() }, select: { id: true }, }); return students.map((item) => item.id); diff --git a/packages/server/src/lib/settings.ts b/packages/server/src/lib/settings.ts index 2ec6f2d..e80299e 100644 --- a/packages/server/src/lib/settings.ts +++ b/packages/server/src/lib/settings.ts @@ -28,6 +28,8 @@ export const SETTING_KEYS = { * 班级自己的 `Class.termStartDate` 仍然优先(见 `lib/term.ts` 的三级回落)。 */ termStartDate: 'term.startDate', + termMaxWeeks: 'term.maxWeeks', + promotionAt: 'term.promotionAt', } as const; export type SettingKey = (typeof SETTING_KEYS)[keyof typeof SETTING_KEYS]; diff --git a/packages/server/src/lib/snapshot.ts b/packages/server/src/lib/snapshot.ts index 84bef34..1e761c3 100644 --- a/packages/server/src/lib/snapshot.ts +++ b/packages/server/src/lib/snapshot.ts @@ -45,6 +45,14 @@ export const SNAPSHOT_TABLES = [ 'StudentClassTransfer', 'Schedule', 'TimeLayout', + 'TimeLayoutItem', + 'ClassPlanGroup', + 'ClassPlan', + 'ClassPlanEntry', + 'ScheduledClassPlan', + 'PromotionRun', + 'TermWeek', + 'Exam', 'Homework', 'HomeworkStatus', 'Notification', diff --git a/packages/server/src/lib/students.ts b/packages/server/src/lib/students.ts new file mode 100644 index 0000000..f1a6fea --- /dev/null +++ b/packages/server/src/lib/students.ts @@ -0,0 +1,7 @@ +/** 所有在读入口共用;归档/转出专页显式读取历史状态。 */ +export function activeStudentWhere() { + return { status: { notIn: ['transferred', 'inactive'] } }; +} +export function isActiveStudent(student: { status: string }) { + return !activeStudentWhere().status.notIn.includes(student.status); +} diff --git a/packages/server/src/lib/term.ts b/packages/server/src/lib/term.ts index e7fc361..aea39a4 100644 --- a/packages/server/src/lib/term.ts +++ b/packages/server/src/lib/term.ts @@ -1,5 +1,5 @@ import { - MAX_TERM_WEEK, + getMaxTermWeeks as normalizeMaxWeeks, resolveCurrentWeek as resolveWeek, resolveWeekFromRanges, } from '@classhelper/shared'; @@ -7,7 +7,9 @@ import { env } from '../config/env.js'; import { prisma } from './db.js'; import { SETTING_KEYS, getSetting } from './settings.js'; -export { MAX_TERM_WEEK }; +export async function getMaxTermWeeks(): Promise { + return normalizeMaxWeeks(await getSetting(SETTING_KEYS.termMaxWeeks)); +} /** 某个班的周次口径(管理员可以为每个班单独配开学日期与逐周区间) */ export interface TermWeekContext { @@ -27,7 +29,7 @@ export interface TermWeekContext { */ export function resolveCurrentWeek(now: Date = new Date(), context?: TermWeekContext | null): number { const termStartDate = context?.termStartDate || env.termStartDate; - const maxWeek = context?.maxWeek ?? MAX_TERM_WEEK; + const maxWeek = context?.maxWeek ?? normalizeMaxWeeks(); // 配了逐周区间就按区间判定 —— 调休/补课让某一周变长变短时,线性推算会整体错位 if (context && context.weeks.length > 0) { @@ -48,13 +50,14 @@ export function resolveCurrentWeek(now: Date = new Date(), context?: TermWeekCon * @param classId 不传/空串表示**只看全校默认**(管理员在「学期周次」页编辑默认口径时就是这种调用) */ export async function loadTermContext(classId?: string | null): Promise { - const [globalWeeks, settingStart] = await Promise.all([ + const [globalWeeks, settingStart, maxTermWeeks] = await Promise.all([ prisma.termWeek.findMany({ where: { classId: '' }, orderBy: { weekNumber: 'asc' }, select: { weekNumber: true, startDate: true, endDate: true }, }), getSetting(SETTING_KEYS.termStartDate), + getMaxTermWeeks(), ]); if (!classId) { // 全校默认没有"班级记录"可以存开学日期,因此按「显式设置 → 第 1 周的起始日 → .env」回落 @@ -63,7 +66,7 @@ export async function loadTermContext(classId?: string | null): Promise 0 ? cls.termWeeks : MAX_TERM_WEEK, + maxWeek: Math.min(cls?.termWeeks || maxTermWeeks, maxTermWeeks), }; } diff --git a/packages/server/src/modules/ai/ai.service.ts b/packages/server/src/modules/ai/ai.service.ts index b984c28..018602e 100644 --- a/packages/server/src/modules/ai/ai.service.ts +++ b/packages/server/src/modules/ai/ai.service.ts @@ -1,3 +1,4 @@ +import { activeStudentWhere } from '../../lib/students.js'; import { dayKeyLocal, type AiActionKind, @@ -267,7 +268,7 @@ async function buildPreview( const student = studentName && classId ? await prisma.student.findFirst({ - where: { classId, name: studentName }, + where: { classId, name: studentName, ...activeStudentWhere() }, select: { id: true, name: true }, }) : null; diff --git a/packages/server/src/modules/archives/archives.module.ts b/packages/server/src/modules/archives/archives.module.ts index 1d9b2b4..409ccaa 100644 --- a/packages/server/src/modules/archives/archives.module.ts +++ b/packages/server/src/modules/archives/archives.module.ts @@ -1,13 +1,13 @@ +import { listTransferred, restoreTransferred } from './transferred.service.js'; import { Router } from 'express'; -import { sendCreated, sendOk } from '../../lib/http.js'; +import { sendOk } from '../../lib/http.js'; import { idParamSchema } from '../../lib/schemas.js'; import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js'; -import { validate, validatedBody, validatedParams, validatedQuery } from '../../middleware/validate.js'; +import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js'; import { defineModule } from '../module.types.js'; import { archiveClassParamSchema, archivedContentQuerySchema, - createArchiveSchema, listArchivesQuerySchema, suggestedEnrollmentYears, } from './archives.schemas.js'; @@ -15,6 +15,15 @@ import * as archiveService from './archives.service.js'; const router = Router(); router.use(authenticate()); +router.get('/transferred', requirePermission('archive:manage'), async (req, res) => + sendOk(res, await listTransferred(getAuthUser(req))), +); +router.post( + '/transferred/:id/restore', + requirePermission('archive:manage'), + validate({ params: idParamSchema }), + async (req, res) => sendOk(res, await restoreTransferred(getAuthUser(req), String(req.params.id))), +); /** * 毕业归档(**全部接口仅管理员**)。 @@ -76,18 +85,6 @@ router.get( }, ); -/** POST /api/archives - 执行毕业归档(按届别整届归档) */ -router.post( - '/', - requirePermission('archive:manage'), - validate({ body: createArchiveSchema }), - async (req, res) => { - const user = getAuthUser(req); - const result = await archiveService.createArchive(user, validatedBody(req)); - sendCreated(res, result, `${result.name} 已归档,共 ${result.classCount} 个班级`); - }, -); - /** DELETE /api/archives/:id - 撤销归档(管理员误操作的退路,不删任何数据) */ router.delete( '/:id', diff --git a/packages/server/src/modules/archives/transferred.service.ts b/packages/server/src/modules/archives/transferred.service.ts new file mode 100644 index 0000000..c22571e --- /dev/null +++ b/packages/server/src/modules/archives/transferred.service.ts @@ -0,0 +1,49 @@ +import { prisma } from '../../lib/db.js'; +import { assertCanManageClasses } from '../../lib/access.js'; +import type { TokenPayload } from '../../lib/jwt.js'; +import { ApiError } from '../../lib/http.js'; +import { notifyScheduleChanged } from '../class-plans/class-plans.service.js'; +export async function listTransferred(user: TokenPayload) { + assertCanManageClasses(user); + return prisma.student.findMany({ + where: { status: 'transferred' }, + select: { + id: true, + name: true, + studentNo: true, + classId: true, + transferNote: true, + transferredAt: true, + class: { select: { name: true } }, + }, + orderBy: { transferredAt: 'desc' }, + }); +} +export async function restoreTransferred(user: TokenPayload, id: string) { + assertCanManageClasses(user); + const student = await prisma.student.findUnique({ where: { id }, include: { class: true } }); + if (student?.status !== 'transferred' || !student.classId || student.class?.archivedYearId) + throw ApiError.badRequest('只能撤回原班仍在读的转出记录'); + await prisma.$transaction([ + prisma.student.update({ + where: { id }, + data: { status: 'active', transferredAt: null, archivedAt: null, archivedYearId: null }, + }), + prisma.studentClassTransfer.create({ + data: { + studentId: id, + studentNo: student.studentNo, + studentName: student.name, + fromClassId: null, + toClassId: student.classId, + toClassName: student.class?.name ?? '', + operatorId: user.sub, + operatorName: user.name, + mode: 'restore-transfer', + note: '撤回转出,保留原转出记录', + }, + }), + ]); + await notifyScheduleChanged(student.classId); + return { id, restored: true }; +} diff --git a/packages/server/src/modules/calls/calls.service.ts b/packages/server/src/modules/calls/calls.service.ts index 3faeeb2..03838c3 100644 --- a/packages/server/src/modules/calls/calls.service.ts +++ b/packages/server/src/modules/calls/calls.service.ts @@ -1,5 +1,6 @@ import { SOCKET_EVENTS, buildCallTitle, dayKeyLocal, type NotificationDto } from '@classhelper/shared'; import { assertCanPublishNotification } from '../../lib/access.js'; +import { isActiveStudent } from '../../lib/students.js'; import { prisma } from '../../lib/db.js'; import { ApiError } from '../../lib/http.js'; import type { TokenPayload } from '../../lib/jwt.js'; @@ -30,9 +31,9 @@ export async function createCall(user: TokenPayload, input: CreateCallInput): Pr const student = await prisma.student.findUnique({ where: { id: input.studentId }, - select: { id: true, name: true, studentNo: true, classId: true }, + select: { id: true, name: true, studentNo: true, classId: true, status: true }, }); - if (!student) throw ApiError.notFound('学生不存在'); + if (!student || !isActiveStudent(student)) throw ApiError.notFound('学生不存在'); if (student.classId !== input.classId) { throw new ApiError(400, 'BAD_REQUEST', '该学生不在所选班级'); } diff --git a/packages/server/src/modules/class-plans/class-plans.module.ts b/packages/server/src/modules/class-plans/class-plans.module.ts new file mode 100644 index 0000000..6cc31d5 --- /dev/null +++ b/packages/server/src/modules/class-plans/class-plans.module.ts @@ -0,0 +1,241 @@ +import { importProfile } from './profile-import.service.js'; +import { Router } from 'express'; +import { z } from 'zod'; +import { prisma } from '../../lib/db.js'; +import { assertCanManageSchedule, assertClassAccess } from '../../lib/access.js'; +import { sendOk, sendCreated, ApiError } from '../../lib/http.js'; +import { idParamSchema } from '../../lib/schemas.js'; +import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js'; +import { validate, validatedQuery } from '../../middleware/validate.js'; +import { defineModule } from '../module.types.js'; +import * as schemas from './class-plans.schemas.js'; +import * as service from './class-plans.service.js'; + +const layouts = Router(); +layouts.use(authenticate()); +layouts.get('/', validate({ query: schemas.scopeQuery }), async (req, res) => + sendOk(res, await service.listLayouts(getAuthUser(req), validatedQuery<{ classId?: string }>(req).classId)), +); +layouts.post( + '/', + requirePermission('timetable:manage'), + validate({ body: schemas.layoutSchema }), + async (req, res) => sendCreated(res, await service.saveLayout(req.body)), +); +layouts.put( + '/:id', + requirePermission('timetable:manage'), + validate({ params: idParamSchema, body: schemas.layoutSchema }), + async (req, res) => sendOk(res, await service.saveLayout(req.body, String(req.params.id))), +); +layouts.post( + '/:id/clone', + requirePermission('timetable:manage'), + validate({ params: idParamSchema, body: schemas.cloneSchema }), + async (req, res) => { + const current = (await service.listLayouts(getAuthUser(req))).find((p) => p.id === req.params.id); + if (!current) throw ApiError.notFound('时间表不存在'); + sendCreated( + res, + await service.saveLayout({ + ...current, + name: req.body.name || `${current.name} 副本`, + points: current.points.map((p) => ({ ...p, id: undefined, timeType: p.timeType as 'LESSON' })), + }), + ); + }, +); +layouts.post( + '/:id/import', + requirePermission('timetable:manage'), + validate({ params: idParamSchema, body: schemas.importLayoutSchema }), + async (req, res) => { + const all = await service.listLayouts(getAuthUser(req)); + const target = all.find((p) => p.id === req.params.id), + source = all.find((p) => p.id === req.body.sourceId); + if (!target || !source) throw ApiError.notFound('时间表不存在'); + sendOk( + res, + await service.saveLayout( + { + ...target, + points: source.points.map((p) => ({ ...p, id: undefined, timeType: p.timeType as 'LESSON' })), + }, + target.id, + ), + ); + }, +); +layouts.delete( + '/:id', + requirePermission('timetable:manage'), + validate({ params: idParamSchema }), + async (req, res) => { + const id = String(req.params.id); + if (await prisma.classPlan.count({ where: { timeLayoutId: id } })) + throw ApiError.conflict('仍有课表使用该时间表'); + await prisma.timeLayout.delete({ where: { id } }); + await service.notifyScheduleChanged(); + sendOk(res, { id }); + }, +); + +const plans = Router(); +plans.use(authenticate()); +plans.post( + '/import/profile', + requirePermission('schedule:write'), + validate({ + body: z.object({ + classId: z.string().min(1), + payload: z.unknown(), + mode: z.enum(['merge', 'replace']).default('merge'), + }), + }), + async (req, res) => + sendCreated( + res, + await importProfile(getAuthUser(req), req.body.classId, req.body.payload, req.body.mode), + ), +); +plans.get('/', validate({ query: schemas.scopeQuery }), async (req, res) => { + const q = validatedQuery<{ classId?: string; week?: number }>(req); + sendOk(res, await service.listPlans(getAuthUser(req), q.classId, q.week)); +}); +plans.post( + '/', + requirePermission('schedule:write'), + validate({ body: schemas.planSchema }), + async (req, res) => sendCreated(res, await service.savePlan(getAuthUser(req), req.body)), +); +plans.put( + '/:id', + requirePermission('schedule:write'), + validate({ params: idParamSchema, body: schemas.planSchema }), + async (req, res) => sendOk(res, await service.savePlan(getAuthUser(req), req.body, String(req.params.id))), +); +plans.patch( + '/:id/enabled', + requirePermission('schedule:write'), + validate({ params: idParamSchema, body: z.object({ enabled: z.boolean() }) }), + async (req, res) => { + const p = await service.requirePlan(getAuthUser(req), String(req.params.id)); + sendOk( + res, + await prisma.classPlan.update({ where: { id: p.id }, data: { isEnabled: req.body.enabled } }), + ); + await service.notifyScheduleChanged(p.classId); + }, +); +plans.put( + '/:id/entries', + requirePermission('schedule:write'), + validate({ params: idParamSchema, body: schemas.entriesSchema }), + async (req, res) => + sendOk(res, await service.saveEntries(getAuthUser(req), String(req.params.id), req.body)), +); +plans.post( + '/:id/clone', + requirePermission('schedule:write'), + validate({ params: idParamSchema, body: schemas.cloneSchema }), + async (req, res) => + sendCreated(res, await service.clonePlan(getAuthUser(req), String(req.params.id), req.body.name)), +); +plans.get('/:id/export', validate({ params: idParamSchema }), async (req, res) => { + const p = await service.requirePlan(getAuthUser(req), String(req.params.id), false); + sendOk(res, { format: 'classhelper-class-plan', plan: p, timeLayout: p.layout }); +}); +plans.post( + '/import', + requirePermission('schedule:write'), + validate({ body: z.object({ plan: schemas.planSchema, entries: schemas.entriesSchema.shape.entries }) }), + async (req, res) => { + const p = await service.savePlan(getAuthUser(req), req.body.plan); + sendCreated(res, await service.saveEntries(getAuthUser(req), p.id, { entries: req.body.entries })); + }, +); +plans.delete( + '/:id', + requirePermission('schedule:write'), + validate({ params: idParamSchema }), + async (req, res) => { + const p = await service.requirePlan(getAuthUser(req), String(req.params.id)); + await prisma.classPlan.delete({ where: { id: p.id } }); + await service.notifyScheduleChanged(p.classId); + sendOk(res, { id: p.id }); + }, +); + +const groups = Router(); +groups.use(authenticate()); +groups.get('/selection', validate({ query: z.object({ classId: z.string().min(1) }) }), async (req, res) => { + const { classId } = validatedQuery<{ classId: string }>(req); + await assertClassAccess(getAuthUser(req), classId); + const value = await prisma.appSetting.findUnique({ where: { key: `schedule.group.${classId}` } }); + sendOk(res, value ? JSON.parse(value.value) : {}); +}); +groups.get('/', validate({ query: z.object({ classId: z.string().min(1) }) }), async (req, res) => { + const { classId } = validatedQuery<{ classId: string }>(req); + await assertClassAccess(getAuthUser(req), classId); + sendOk(res, await prisma.classPlanGroup.findMany({ where: { classId } })); +}); +groups.post( + '/', + requirePermission('schedule:write'), + validate({ body: schemas.groupSchema }), + async (req, res) => sendCreated(res, await service.saveGroup(getAuthUser(req), req.body)), +); +groups.put( + '/:id', + requirePermission('schedule:write'), + validate({ params: idParamSchema, body: schemas.groupSchema }), + async (req, res) => sendOk(res, await service.saveGroup(getAuthUser(req), req.body, String(req.params.id))), +); +groups.delete( + '/:id', + requirePermission('schedule:write'), + validate({ params: idParamSchema }), + async (req, res) => { + const p = await prisma.classPlanGroup.findUnique({ where: { id: String(req.params.id) } }); + if (!p) throw ApiError.notFound('群组不存在'); + await assertCanManageSchedule(getAuthUser(req), p.classId); + await prisma.classPlanGroup.delete({ where: { id: p.id } }); + await service.notifyScheduleChanged(p.classId); + sendOk(res, { id: p.id }); + }, +); +groups.post( + '/select', + requirePermission('schedule:write'), + validate({ body: schemas.groupSelectionSchema }), + async (req, res) => { + const { classId, ...value } = schemas.groupSelectionSchema.parse(req.body); + await assertCanManageSchedule(getAuthUser(req), classId); + for (const id of [value.selected, value.temporary].filter(Boolean)) + if (!(await prisma.classPlanGroup.findFirst({ where: { id, classId } }))) + throw ApiError.badRequest('群组不属于本班'); + if (value.temporary && !value.expireTime) throw ApiError.badRequest('临时群组必须指定过期时间'); + await prisma.appSetting.upsert({ + where: { key: `schedule.group.${classId}` }, + create: { key: `schedule.group.${classId}`, value: JSON.stringify(value) }, + update: { value: JSON.stringify(value) }, + }); + await service.notifyScheduleChanged(classId); + sendOk(res, value); + }, +); +export const timeLayoutsModule = defineModule({ + name: 'time-layouts', + basePath: '/time-layouts', + router: layouts, +}); +export const classPlansModule = defineModule({ + name: 'class-plans', + basePath: '/class-plans', + router: plans, +}); +export const classPlanGroupsModule = defineModule({ + name: 'class-plan-groups', + basePath: '/class-plan-groups', + router: groups, +}); diff --git a/packages/server/src/modules/class-plans/class-plans.schemas.ts b/packages/server/src/modules/class-plans/class-plans.schemas.ts new file mode 100644 index 0000000..d97f159 --- /dev/null +++ b/packages/server/src/modules/class-plans/class-plans.schemas.ts @@ -0,0 +1,72 @@ +import { z } from 'zod'; +import { isDayKey, TIME_POINT_TYPES } from '@classhelper/shared'; +import { timeSchema } from '../../lib/schemas.js'; +export const scopeQuery = z.object({ + classId: z.string().min(1).optional(), + week: z.coerce.number().int().min(1).max(60).optional(), +}); +export const pointSchema = z + .object({ + id: z.string().min(1).optional(), + name: z.string().max(64).default(''), + startTime: timeSchema, + endTime: timeSchema, + timeType: z.enum(TIME_POINT_TYPES).default('LESSON'), + isHideDefault: z.boolean().default(false), + defaultClassId: z.string().nullish(), + breakName: z.string().max(64).default(''), + actionSet: z.string().max(10000).default('{}'), + }) + .refine((p) => p.timeType === 'DIVIDER' || p.startTime < p.endTime, '结束时间必须晚于开始时间'); +export const layoutSchema = z.object({ + name: z.string().trim().min(1).max(64), + classId: z.string().nullish(), + points: z.array(pointSchema).max(100), +}); +export const planSchema = z + .object({ + classId: z.string().min(1), + name: z.string().trim().min(1).max(64), + timeLayoutId: z.string().min(1), + associatedGroup: z.string().nullish(), + weekday: z.number().int().min(0).max(7).default(0), + weekCountDiv: z.number().int().min(0).max(9).default(0), + weekCountDivTotal: z.number().int().min(1).max(9).default(2), + weekStart: z.number().int().min(1).max(60).default(1), + weekEnd: z.number().int().min(1).max(60).default(60), + isEnabled: z.boolean().default(true), + }) + .refine((p) => p.weekCountDiv <= p.weekCountDivTotal && p.weekStart <= p.weekEnd, '启用周次规则不合法'); +export const entriesSchema = z.object({ + entries: z + .array( + z.object({ + timeLayoutItemId: z.string().min(1), + courseId: z.string().nullish(), + isEnabled: z.boolean().default(true), + isChangedClass: z.boolean().default(false), + location: z.string().max(100).nullish(), + }), + ) + .max(100), +}); +export const groupSchema = z.object({ + classId: z.string().min(1), + name: z.string().trim().min(1).max(64), + isGlobal: z.boolean().default(false), + isDefault: z.boolean().default(false), +}); +export const datedPlanSchema = z.object({ + classId: z.string().min(1), + classPlanId: z.string().min(1), + date: z.string().refine(isDayKey, '日期无效'), +}); +export const swapSchema = datedPlanSchema.extend({ first: z.string().min(1), second: z.string().min(1) }); +export const cloneSchema = z.object({ name: z.string().trim().min(1).max(64).optional() }); +export const importLayoutSchema = z.object({ sourceId: z.string().min(1) }); +export const groupSelectionSchema = z.object({ + classId: z.string().min(1), + selected: z.string().min(1).optional(), + temporary: z.string().min(1).optional(), + expireTime: z.iso.datetime({ offset: true }).optional(), +}); diff --git a/packages/server/src/modules/class-plans/class-plans.service.ts b/packages/server/src/modules/class-plans/class-plans.service.ts new file mode 100644 index 0000000..10ff40d --- /dev/null +++ b/packages/server/src/modules/class-plans/class-plans.service.ts @@ -0,0 +1,228 @@ +import { SOCKET_EVENTS } from '@classhelper/shared'; +import { prisma } from '../../lib/db.js'; +import { assertCanManageSchedule, assertClassAccess, getAccessibleClassIds } from '../../lib/access.js'; +import { getMaxTermWeeks } from '../../lib/term.js'; +import { ApiError } from '../../lib/http.js'; +import type { TokenPayload } from '../../lib/jwt.js'; +import { emitToClass } from '../../realtime/bus.js'; +import { planInclude } from '../../lib/class-plan.js'; +import type { z } from 'zod'; +import type { + planSchema, + layoutSchema, + entriesSchema, + groupSchema, + datedPlanSchema, +} from './class-plans.schemas.js'; + +export async function notifyScheduleChanged(classId?: string | null) { + const ids = classId + ? [classId] + : (await prisma.class.findMany({ where: { archivedYearId: null }, select: { id: true } })).map( + (c) => c.id, + ); + for (const id of ids) emitToClass(id, SOCKET_EVENTS.scheduleUpdated, { classId: id, action: 'updated' }); +} +export async function listLayouts(user: TokenPayload, classId?: string) { + if (classId) await assertClassAccess(user, classId); + const ids = classId ? [classId] : await getAccessibleClassIds(user); + return prisma.timeLayout.findMany({ + where: { OR: [{ classId: null }, { classId: { in: ids } }] }, + include: { points: { orderBy: { position: 'asc' } } }, + }); +} +export async function saveLayout(input: z.infer, id?: string) { + if (id && !(await prisma.timeLayout.findUnique({ where: { id } }))) throw ApiError.notFound('时间表不存在'); + const pointIds = input.points.flatMap((p) => (p.id ? [p.id] : [])); + if (new Set(pointIds).size !== pointIds.length) throw ApiError.badRequest('时间点不能重复'); + const result = await prisma.$transaction(async (tx) => { + const layout = id + ? await tx.timeLayout.update({ where: { id }, data: { name: input.name } }) + : await tx.timeLayout.create({ + data: { name: input.name, classId: input.classId ?? null, source: 'manual' }, + }); + const owned = await tx.timeLayoutItem.findMany({ + where: { timeLayoutId: layout.id }, + select: { id: true }, + }); + if (pointIds.some((p) => !owned.some((o) => o.id === p))) + throw ApiError.badRequest('时间点不属于这份时间表'); + await tx.timeLayoutItem.deleteMany({ where: { timeLayoutId: layout.id, id: { notIn: pointIds } } }); + for (const [position, point] of input.points.entries()) { + const { id: pointId, ...data } = point; + if (pointId) await tx.timeLayoutItem.update({ where: { id: pointId }, data: { ...data, position } }); + else await tx.timeLayoutItem.create({ data: { ...data, position, timeLayoutId: layout.id } }); + } + return tx.timeLayout.findUniqueOrThrow({ + where: { id: layout.id }, + include: { points: { orderBy: { position: 'asc' } } }, + }); + }); + await notifyScheduleChanged(); + return result; +} +export async function listPlans(user: TokenPayload, classId?: string, week?: number) { + if (classId) await assertClassAccess(user, classId); + return prisma.classPlan.findMany({ + where: { + classId: { in: classId ? [classId] : await getAccessibleClassIds(user) }, + ...(week ? { weekStart: { lte: week }, weekEnd: { gte: week } } : {}), + }, + include: planInclude, + orderBy: [{ weekday: 'asc' }, { name: 'asc' }], + }); +} +export async function requirePlan(user: TokenPayload, id: string, write = true) { + const p = await prisma.classPlan.findUnique({ where: { id }, include: planInclude }); + if (!p) throw ApiError.notFound('课表不存在'); + await (write ? assertCanManageSchedule : assertClassAccess)(user, p.classId); + return p; +} +export async function savePlan(user: TokenPayload, input: z.infer, id?: string) { + await assertCanManageSchedule(user, input.classId); + const maxWeek = await getMaxTermWeeks(); + if (input.weekStart > maxWeek) throw ApiError.badRequest('起始周超过全校上限'); + input = { ...input, weekEnd: Math.min(input.weekEnd, maxWeek) }; + if (id && (await requirePlan(user, id)).classId !== input.classId) + throw ApiError.badRequest('不能移动课表到另一班'); + const layout = await prisma.timeLayout.findUnique({ where: { id: input.timeLayoutId } }); + if (!layout || (layout.classId && layout.classId !== input.classId)) + throw ApiError.badRequest('时间表不属于本班或全校'); + if (input.associatedGroup) { + const group = await prisma.classPlanGroup.findUnique({ where: { id: input.associatedGroup } }); + if (group?.classId !== input.classId) throw ApiError.badRequest('课表群组不属于本班'); + } + const result = id + ? await prisma.classPlan.update({ where: { id }, data: input }) + : await prisma.classPlan.create({ data: input }); + await notifyScheduleChanged(input.classId); + return result; +} +export async function saveEntries(user: TokenPayload, id: string, input: z.infer) { + const plan = await requirePlan(user, id); + const ids = input.entries.map((e) => e.timeLayoutItemId); + if ( + new Set(ids).size !== ids.length || + ids.some((id) => !plan.layout.points.some((p) => p.id === id && p.timeType === 'LESSON')) + ) + throw ApiError.badRequest('时间点必须唯一且为本时间表的上课时间点'); + const courseIds = [...new Set(input.entries.flatMap((e) => (e.courseId ? [e.courseId] : [])))]; + const count = await prisma.course.count({ where: { id: { in: courseIds }, classId: plan.classId } }); + if (count !== courseIds.length) throw ApiError.badRequest('不能使用其他班级的课程'); + await prisma.$transaction(async (tx) => { + await tx.classPlanEntry.deleteMany({ where: { classPlanId: id } }); + for (const e of input.entries) await tx.classPlanEntry.create({ data: { ...e, classPlanId: id } }); + }); + await notifyScheduleChanged(plan.classId); + return requirePlan(user, id); +} +export async function clonePlan(user: TokenPayload, id: string, name?: string, overlayDate?: string) { + const p = await requirePlan(user, id); + const { + id: _id, + entries, + layout: _layout, + group: _group, + createdAt: _created, + updatedAt: _updated, + scheduled: _scheduled, + ...data + } = { ...p, scheduled: undefined }; + const cloned = await prisma.classPlan.create({ + data: { + ...data, + name: name || `${p.name} 副本`, + isEnabled: Boolean(overlayDate), + isOverlay: Boolean(overlayDate), + overlaySourceId: overlayDate ? id : null, + overlayDate: overlayDate ?? null, + entries: { + create: entries.map((e) => ({ + timeLayoutItemId: e.timeLayoutItemId, + courseId: e.courseId, + location: e.location, + isEnabled: e.isEnabled, + isChangedClass: e.isChangedClass, + })), + }, + }, + }); + await notifyScheduleChanged(p.classId); + return cloned; +} +export async function saveGroup(user: TokenPayload, input: z.infer, id?: string) { + await assertCanManageSchedule(user, input.classId); + if (id) { + const current = await prisma.classPlanGroup.findUnique({ where: { id } }); + if (current?.classId !== input.classId) throw ApiError.forbidden('无权修改该群组'); + } + const result = await prisma.$transaction(async (tx) => { + if (input.isDefault) + await tx.classPlanGroup.updateMany({ where: { classId: input.classId }, data: { isDefault: false } }); + return id + ? tx.classPlanGroup.update({ where: { id }, data: input }) + : tx.classPlanGroup.create({ data: input }); + }); + await notifyScheduleChanged(input.classId); + return result; +} +export async function schedulePlan(user: TokenPayload, input: z.infer) { + const p = await requirePlan(user, input.classPlanId); + if (p.classId !== input.classId) throw ApiError.badRequest('课表不属于本班'); + const result = await prisma.scheduledClassPlan.upsert({ + where: { classId_date: { classId: input.classId, date: input.date } }, + create: { classId: input.classId, classPlanId: input.classPlanId, date: input.date }, + update: { classPlanId: input.classPlanId }, + }); + await notifyScheduleChanged(input.classId); + return result; +} + +/** 临时层、换课及预定在同一事务发布,客户端不会先收到尚未换完的中间课表。 */ +export async function createOverlay( + user: TokenPayload, + id: string, + date: string, + swap?: { first: string; second: string }, +) { + const source = await requirePlan(user, id); + const a = source.entries.find((e) => e.timeLayoutItemId === swap?.first), + b = source.entries.find((e) => e.timeLayoutItemId === swap?.second); + if (swap && (!a || !b || a.id === b.id)) throw ApiError.badRequest('请选择两节不同的课'); + const result = await prisma.$transaction(async (tx) => { + const p = await tx.classPlan.create({ + data: { + classId: source.classId, + name: `${source.name} 临时层`, + timeLayoutId: source.timeLayoutId, + weekday: source.weekday, + weekCountDiv: source.weekCountDiv, + weekCountDivTotal: source.weekCountDivTotal, + weekStart: source.weekStart, + weekEnd: source.weekEnd, + associatedGroup: source.associatedGroup, + isOverlay: true, + isEnabled: true, + overlaySourceId: source.id, + overlayDate: date, + entries: { + create: source.entries.map((e) => ({ + timeLayoutItemId: e.timeLayoutItemId, + courseId: + swap && e.id === a?.id ? b!.courseId : swap && e.id === b?.id ? a!.courseId : e.courseId, + isEnabled: e.isEnabled, + isChangedClass: Boolean(swap && (e.id === a?.id || e.id === b?.id)), + location: e.location, + })), + }, + }, + }); + return tx.scheduledClassPlan.upsert({ + where: { classId_date: { classId: source.classId, date } }, + create: { classId: source.classId, date, classPlanId: p.id }, + update: { classPlanId: p.id }, + }); + }); + await notifyScheduleChanged(source.classId); + return result; +} diff --git a/packages/server/src/modules/class-plans/profile-import.service.ts b/packages/server/src/modules/class-plans/profile-import.service.ts new file mode 100644 index 0000000..881a0db --- /dev/null +++ b/packages/server/src/modules/class-plans/profile-import.service.ts @@ -0,0 +1,277 @@ +import { createHash } from 'node:crypto'; +import { z } from 'zod'; +import { isDayKey, dayKeyLocal } from '@classhelper/shared'; +import { prisma } from '../../lib/db.js'; +import { assertCanManageSchedule } from '../../lib/access.js'; +import { ApiError } from '../../lib/http.js'; +import type { TokenPayload } from '../../lib/jwt.js'; +import { notifyScheduleChanged } from './class-plans.service.js'; +const record = z.record(z.string(), z.unknown()); +function object(value: unknown): Record { + const parsed = record.safeParse(value); + return parsed.success ? parsed.data : {}; +} +function time(value: unknown) { + const match = String(value ?? '').match(/(?:^|T)(\d{1,2}):(\d{2})/); + if (!match) throw ApiError.badRequest('时间点缺少合法起止时间'); + const result = `${match[1]!.padStart(2, '0')}:${match[2]}`; + if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(result)) throw ApiError.badRequest('时间不合法'); + return result; +} +/** 直接保留上游三元组/四种时间点/显式课程位置,绝不经过单双周的有损中间格式。 */ +export async function importProfile(user: TokenPayload, classId: string, payload: unknown, mode = 'merge') { + await assertCanManageSchedule(user, classId); + let parsed = payload; + if (typeof payload === 'string') { + try { + parsed = JSON.parse(payload); + } catch { + throw ApiError.badRequest('JSON 格式不正确'); + } + } + const root = object(parsed), + layouts = object(root.TimeLayouts), + plans = object(root.ClassPlans), + subjects = object(root.Subjects), + groups = object(root.ClassPlanGroups); + if (!Object.keys(layouts).length || !Object.keys(plans).length) + throw ApiError.badRequest('需要包含 TimeLayouts 与 ClassPlans 的 ClassIsland JSON'); + if (Object.keys(plans).length > 500 || Object.keys(layouts).length > 100) + throw ApiError.badRequest('导入课表过多'); + const cls = await prisma.class.findUniqueOrThrow({ where: { id: classId } }); + const warnings: string[] = []; + const stable = (kind: string, id: string) => + `profile-${kind}-${createHash('sha256') + .update(classId + '|' + id) + .digest('hex') + .slice(0, 32)}`; + const result = await prisma.$transaction( + async (tx) => { + const courses = new Map(); + const createdCourses: string[] = []; + for (const [id, value] of Object.entries(subjects)) { + const subject = object(value), + name = String(subject.Name || '') + .trim() + .slice(0, 64); + if (!name) continue; + let course = await tx.course.findUnique({ where: { classId_name: { classId, name } } }); + if (!course) { + if (!cls.teacherId) throw ApiError.badRequest('请先为新班指定班主任或建立科目任课关系'); + course = await tx.course.create({ + data: { + classId, + name, + teacherId: cls.teacherId, + initial: String(subject.Initial || Array.from(name)[0] || '').slice(0, 8), + isOutDoor: subject.IsOutDoor === true, + }, + }); + createdCourses.push(name); + } else if (subject.Initial !== undefined || subject.IsOutDoor !== undefined) + course = await tx.course.update({ + where: { id: course.id }, + data: { + ...(subject.Initial !== undefined + ? { initial: String(subject.Initial || Array.from(name)[0] || '').slice(0, 8) } + : {}), + ...(subject.IsOutDoor !== undefined ? { isOutDoor: subject.IsOutDoor === true } : {}), + }, + }); + courses.set(id, course.id); + } + const groupIds = new Map(); + for (const [id, value] of Object.entries(groups)) { + const g = object(value); + const data = { + classId, + name: String(g.Name || '导入群组').slice(0, 64), + isGlobal: g.IsGlobal === true, + isDefault: id === root.SelectedClassPlanGroupId, + }; + const created = await tx.classPlanGroup.upsert({ + where: { id: stable('group', id) }, + create: { id: stable('group', id), ...data }, + update: data, + }); + groupIds.set(id, created.id); + } + if (mode === 'replace') { + await tx.scheduledClassPlan.deleteMany({ where: { classId } }); + await tx.classPlan.deleteMany({ where: { classId } }); + } + const layoutIds = new Map(); + for (const [id, value] of Object.entries(layouts)) { + const l = object(value), + points = Array.isArray(l.Layouts) ? l.Layouts : []; + if (points.length > 100) throw ApiError.badRequest('每份时间表最多100个时间点'); + const data = { + classId, + name: String(l.Name || '导入时间表').slice(0, 64), + source: 'classisland', + isOverlay: l.IsOverlay === true, + }; + const created = await tx.timeLayout.upsert({ + where: { id: stable('layout', id) }, + create: { id: stable('layout', id), ...data }, + update: data, + }); + const lessons: string[] = [], + pointIds: string[] = []; + for (const [position, value] of points.entries()) { + const p = object(value), + timeType = (['LESSON', 'BREAK', 'DIVIDER', 'ACTION'] as const)[Number(p.TimeType || 0)]; + if (!timeType) throw ApiError.badRequest('未知时间点类型'); + const startTime = time(p.StartTime), + endTime = time(p.EndTime); + if (timeType !== 'DIVIDER' && startTime >= endTime) + throw ApiError.badRequest('时间点结束必须晚于开始'); + const pointData = { + timeLayoutId: created.id, + position, + name: String(p.Name || '').slice(0, 64), + startTime, + endTime, + timeType, + isHideDefault: p.IsHideDefault === true, + defaultClassId: courses.get(String(p.DefaultClassId)) || null, + breakName: String(p.BreakNameText || p.BreakName || '').slice(0, 64), + actionSet: JSON.stringify(p.ActionSet || {}), + }; + const point = await tx.timeLayoutItem.upsert({ + where: { id: stable('point', id + '|' + position) }, + create: { id: stable('point', id + '|' + position), ...pointData }, + update: pointData, + }); + pointIds.push(point.id); + if (timeType === 'LESSON') lessons.push(point.id); + } + await tx.timeLayoutItem.deleteMany({ where: { timeLayoutId: created.id, id: { notIn: pointIds } } }); + layoutIds.set(id, { id: created.id, lessons }); + } + for (const [id, value] of Object.entries(layouts)) { + const source = layoutIds.get(String(object(value).OverlaySourceId)), + target = layoutIds.get(id); + if (source && target) + await tx.timeLayout.update({ where: { id: target.id }, data: { overlaySourceId: source.id } }); + } + const planIds = new Map(); + let created = 0, + updated = 0; + for (const [id, value] of Object.entries(plans)) { + const p = object(value), + rule = object(p.TimeRule), + layout = layoutIds.get(String(p.TimeLayoutId)); + if (!layout) { + warnings.push(`课表 ${p.Name} 的时间表不存在,已跳过`); + continue; + } + const weekday = Number(rule.WeekDay || 0), + weekCountDiv = Number(rule.WeekCountDiv || 0), + weekCountDivTotal = Number(rule.WeekCountDivTotal || 2); + if ( + !Number.isInteger(weekday) || + weekday < 0 || + weekday > 7 || + !Number.isInteger(weekCountDiv) || + weekCountDiv < 0 || + weekCountDiv > 9 || + !Number.isInteger(weekCountDivTotal) || + weekCountDivTotal < 1 || + weekCountDivTotal > 9 || + weekCountDiv > weekCountDivTotal + ) + throw ApiError.badRequest('导入启用规则不合法(支持1–9周轮换)'); + const previous = await tx.classPlan.findUnique({ where: { id: stable('plan', id) } }); + const data = { + classId, + name: String(p.Name || '导入课表').slice(0, 64), + timeLayoutId: layout.id, + associatedGroup: groupIds.get(String(p.AssociatedGroup)) || null, + weekday, + weekCountDiv, + weekCountDivTotal, + isEnabled: p.IsEnabled !== false, + isOverlay: p.IsOverlay === true, + }; + const plan = await tx.classPlan.upsert({ + where: { id: stable('plan', id) }, + create: { id: stable('plan', id), ...data }, + update: data, + }); + planIds.set(id, plan.id); + if (previous) updated += layout.lessons.length; + else created += layout.lessons.length; + await tx.classPlanEntry.deleteMany({ where: { classPlanId: plan.id } }); + const entries = Array.isArray(p.Classes) ? p.Classes : []; + for (const [index, pointId] of layout.lessons.entries()) { + const entry = object(entries[index]); + await tx.classPlanEntry.create({ + data: { + classPlanId: plan.id, + timeLayoutItemId: pointId, + courseId: entry.IsEmpty === true ? null : courses.get(String(entry.SubjectId)) || null, + isEnabled: entry.IsEnabled !== false, + isChangedClass: entry.IsChangedClass === true, + }, + }); + } + } + for (const [id, value] of Object.entries(plans)) { + const p = object(value), + planId = planIds.get(id), + sourceId = planIds.get(String(p.OverlaySourceId)); + if (planId && sourceId) + await tx.classPlan.update({ where: { id: planId }, data: { overlaySourceId: sourceId } }); + } + for (const [dateKey, value] of Object.entries(object(root.OrderedSchedules))) { + const date = isDayKey(dateKey) ? dateKey : dayKeyLocal(new Date(dateKey)), + planId = planIds.get(String(object(value).ClassPlanId)); + if (date && isDayKey(date) && planId) + await tx.scheduledClassPlan.upsert({ + where: { classId_date: { classId, date } }, + create: { classId, date, classPlanId: planId }, + update: { classPlanId: planId }, + }); + } + const dateOf = (value: unknown) => { + const parsed = new Date(String(value || '')); + return Number.isNaN(parsed.getTime()) ? dayKeyLocal(new Date()) : dayKeyLocal(parsed); + }; + const overlayId = planIds.get(String(root.OverlayClassPlanId)); + if (root.IsOverlayClassPlanEnabled === true && overlayId) + await tx.classPlan.update({ + where: { id: overlayId }, + data: { isOverlay: true, isEnabled: true, overlayDate: dateOf(root.TempClassPlanSetupTime) }, + }); + const temporaryPlan = planIds.get(String(root.TempClassPlanId)), + selected = groupIds.get(String(root.SelectedClassPlanGroupId)), + temporary = + root.IsTempClassPlanGroupEnabled === true + ? groupIds.get(String(root.TempClassPlanGroupId)) + : undefined; + if (temporaryPlan || selected || temporary) { + const key = `schedule.group.${classId}`, + value = JSON.stringify({ + selected, + temporary, + expireTime: root.TempClassPlanGroupExpireTime, + temporaryPlan, + temporaryPlanDate: temporaryPlan ? dateOf(root.TempClassPlanSetupTime) : undefined, + }); + await tx.appSetting.upsert({ where: { key }, create: { key, value }, update: { value } }); + } + return { + imported: created + updated, + created, + updated, + createdCourses, + weekStart: 1, + weekEnd: cls.termWeeks, + }; + }, + { timeout: 30000 }, + ); + await notifyScheduleChanged(classId); + return { ...result, warnings }; +} diff --git a/packages/server/src/modules/classes/classes.schemas.ts b/packages/server/src/modules/classes/classes.schemas.ts index e461ca9..7c18be0 100644 --- a/packages/server/src/modules/classes/classes.schemas.ts +++ b/packages/server/src/modules/classes/classes.schemas.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import { MAX_TERM_WEEK, SUBJECT_CATALOG, isDayKey } from '@classhelper/shared'; +import { SUBJECT_CATALOG, isDayKey } from '@classhelper/shared'; import { classIndexSchema, enrollmentYearSchema, @@ -27,11 +27,11 @@ export const createClassSchema = z.object({ .regex(/^[A-Za-z0-9]{4,16}$/, '班级码需为 4~16 位字母或数字') .optional(), /** - * 本学期教学周数。上限与课表周次口径统一取 MAX_TERM_WEEK(30): + * 本学期教学周数。动态上限由服务层统一取 getMaxTermWeeks()(默认30、最多60): * 周次选择器与 weekNumberSchema 都只到 30,允许更大的值只会让用户设出一个 * 系统兑现不了的学期长度(第 31 周之后永远为空)。 */ - termWeeks: z.coerce.number().int().min(1).max(MAX_TERM_WEEK).optional(), + termWeeks: z.coerce.number().int().min(1).max(60).optional(), }); /** @@ -48,7 +48,7 @@ export const updateClassSchema = z.object({ .nullish(), enrollmentYear: enrollmentYearSchema.optional(), classIndex: classIndexSchema.optional(), - termWeeks: z.coerce.number().int().min(1).max(MAX_TERM_WEEK).optional(), + termWeeks: z.coerce.number().int().min(1).max(60).optional(), }); /** 学年升级(批量改年级,不归档) */ diff --git a/packages/server/src/modules/classes/classes.service.ts b/packages/server/src/modules/classes/classes.service.ts index f7525ba..d9781de 100644 --- a/packages/server/src/modules/classes/classes.service.ts +++ b/packages/server/src/modules/classes/classes.service.ts @@ -26,10 +26,12 @@ import type { UpdateClassInput, UpdateStudentGradeQueryInput, } from './classes.schemas.js'; +import { activeStudentWhere, isActiveStudent } from '../../lib/students.js'; +import { getMaxTermWeeks } from '../../lib/term.js'; import { logger } from '../../lib/logger.js'; const countSelect = { - students: true, + students: { where: activeStudentWhere() }, courses: true, homeworks: true, notifications: true, @@ -48,7 +50,7 @@ const teacherBriefSelect = { select: { id: true, name: true, username: true } } * 管理员(管理全部班级)或该班级的班主任(要发给学生用,只读展示)。 * 科任老师与 ClassHelper 班级端都看不到别人班的班级码。 */ -function canSeeClassAccount(user: TokenPayload, item: { teacherId: string }): boolean { +function canSeeClassAccount(user: TokenPayload, item: { teacherId: string | null }): boolean { if (user.role === 'ADMIN') return true; return user.role === 'TEACHER' && item.teacherId === user.sub; } @@ -103,7 +105,7 @@ export async function getClassDetail(user: TokenPayload, classId: string): Promi include: { teacher: teacherBriefSelect, _count: { select: countSelect }, - students: { orderBy: { studentNo: 'asc' } }, + students: { where: activeStudentWhere(), orderBy: { studentNo: 'asc' } }, courses: { orderBy: { name: 'asc' }, include: { teacher: teacherBriefSelect }, @@ -158,6 +160,13 @@ export async function listSubjectTeachers( /** 创建班级 */ export async function createClass(user: TokenPayload, input: CreateClassInput): Promise { assertCanManageClasses(user); + if ( + 'termWeeks' in input && + typeof input.termWeeks === 'number' && + input.termWeeks > (await getMaxTermWeeks()) + ) + throw ApiError.badRequest('班级周数不能超过全校上限'); + let teacherId = user.sub; if (input.teacherId && user.role === 'ADMIN') { teacherId = await requireTeacher(input.teacherId, '指定的班主任不存在或角色不是教师'); @@ -244,6 +253,7 @@ export async function assignSubjectTeachers( (await prisma.class.findUniqueOrThrow({ where: { id: classId }, select: { teacherId: true } })) .teacherId; + if (!teacherId) throw ApiError.badRequest('请先指定班主任或为该科指定任课老师'); const existing = assignment.courseId ? await prisma.course.findUnique({ where: { id: assignment.courseId }, @@ -285,6 +295,13 @@ export async function updateClassAccount( input: { code?: string; password?: string }, ): Promise { assertCanManageClasses(user); + if ( + 'termWeeks' in input && + typeof input.termWeeks === 'number' && + input.termWeeks > (await getMaxTermWeeks()) + ) + throw ApiError.badRequest('班级周数不能超过全校上限'); + await updateClassAccountRecord(classId, input); const updated = await prisma.class.findUnique({ @@ -371,7 +388,7 @@ export async function deleteClass(user: TokenPayload, classId: string): Promise< export async function listClassStudents(user: TokenPayload, classId: string): Promise { await assertClassAccess(user, classId); const students = await prisma.student.findMany({ - where: { classId, archivedYearId: null }, + where: { classId, archivedYearId: null, ...activeStudentWhere() }, include: { class: { select: { name: true, grade: true } } }, orderBy: { studentNo: 'asc' }, }); @@ -395,6 +412,8 @@ export async function addStudent( const existing = await prisma.student.findUnique({ where: { studentNo: input.studentNo } }); let studentId: string; + if (existing && !isActiveStudent(existing)) + throw ApiError.conflict('学号已被历史学生占用;请使用新学号或在归档页撤回转出'); if (existing) { studentId = existing.id; if (existing.classId !== classId) { diff --git a/packages/server/src/modules/clients/clients.module.ts b/packages/server/src/modules/clients/clients.module.ts index dc14a60..9b69b13 100644 --- a/packages/server/src/modules/clients/clients.module.ts +++ b/packages/server/src/modules/clients/clients.module.ts @@ -84,7 +84,7 @@ router.get( /** POST /api/clients/updates - 统一下发客户端更新 */ router.post( '/updates', - requirePermission('client:update-push'), + requirePermission('client:update'), validate({ body: pushClientUpdateSchema }), async (req, res) => { const user = getAuthUser(req); diff --git a/packages/server/src/modules/clients/clients.service.ts b/packages/server/src/modules/clients/clients.service.ts index 155f909..88ed25f 100644 --- a/packages/server/src/modules/clients/clients.service.ts +++ b/packages/server/src/modules/clients/clients.service.ts @@ -2,7 +2,6 @@ import { CLIENT_COMMAND_EXPIRE_MS, CLIENT_COMMAND_LABELS, CLIENT_ONLINE_WINDOW_MS, - weekParityOf, type ClientUpdateMode, type ClientCommandAckRequest, type ClientCommandDto, @@ -12,7 +11,7 @@ import { type ClientPushResult, type ClientTargetResult, } from '@classhelper/shared'; -import { loadTermContext, resolveCurrentWeek } from '../../lib/term.js'; +import { resolveActiveClassPlan } from '../../lib/class-plan.js'; import { resolveClassScope, classScopeWhere } from '../../lib/access.js'; import { prisma } from '../../lib/db.js'; import { ApiError } from '../../lib/http.js'; @@ -276,11 +275,12 @@ export async function listDevices( /** 命令去重后的目标设备(保持请求顺序,便于结果一一对应) */ async function findTargetDevices(user: TokenPayload, deviceIds: string[]): Promise> { const wanted = [...new Set(deviceIds)]; - const scope = classScopeWhere(await resolveClassScope(user)); + const scope = classScopeWhere(await resolveClassScope(user, undefined, 'remote:control')); const rows = (await prisma.clientDevice.findMany({ where: { AND: [scope, { id: { in: wanted } }] }, select: { id: true, name: true, deviceKey: true }, })) as unknown as DeviceRow[]; + if (rows.length !== wanted.length) throw ApiError.forbidden('部分设备超出管理范围'); return new Map(rows.map((row) => [row.id, row])); } @@ -288,7 +288,7 @@ async function findTargetDevices(user: TokenPayload, deviceIds: string[]): Promi * POST /api/clients/commands - 下发远控命令(关机/重启/注销/锁屏) * * 同一台机器上还没执行的旧命令会被**自动撤销**:否则"先关机、后重启"会排队执行, - * 机器刚关就被下一条叫起来,看起来像失控。客户端侧还有一次弹窗二次确认。 + * 机器刚关就被下一条叫起来,看起来像失控。客户端收到即执行,并以设备标识回执真实结果。 */ export async function createCommands( user: TokenPayload, @@ -331,7 +331,7 @@ export async function createCommands( /** 更新推送的目标集合:优先设备,其次班级,最后"全部" */ async function resolveUpdateTargets(user: TokenPayload, input: PushClientUpdateInput): Promise { - const scope = classScopeWhere(await resolveClassScope(user)); + const scope = classScopeWhere(await resolveClassScope(user, undefined, 'client:update')); const deviceIds = [...new Set(input.deviceIds ?? [])]; if (deviceIds.length > 0) { const rows = (await prisma.clientDevice.findMany({ @@ -414,18 +414,8 @@ async function afterSchoolAt(classId: string | null, fallback = '17:00'): Promis const now = new Date(); let end = fallback; if (classId) { - const week = resolveCurrentWeek(now, await loadTermContext(classId)); - const last = await prisma.schedule.findFirst({ - where: { - classId, - dayOfWeek: now.getDay() || 7, - weekStart: { lte: week }, - weekEnd: { gte: week }, - weekParity: { in: ['ALL', weekParityOf(week)] }, - }, - orderBy: { endTime: 'desc' }, - select: { endTime: true }, - }); + const active = await resolveActiveClassPlan(classId, now); + const last = [...active.schedules].sort((a, b) => b.endTime.localeCompare(a.endTime))[0]; if (last && last.endTime > end) end = last.endTime; } const [hours, minutes] = end.split(':').map(Number); @@ -436,7 +426,7 @@ async function afterSchoolAt(classId: string | null, fallback = '17:00'): Promis /** DELETE /api/clients/devices/:id - 移除一台设备记录(换机 / 旧机器清理) */ export async function deleteDevice(user: TokenPayload, deviceId: string): Promise<{ id: string }> { - const scope = classScopeWhere(await resolveClassScope(user)); + const scope = classScopeWhere(await resolveClassScope(user, undefined, 'client:manage')); const device = await prisma.clientDevice.findFirst({ where: { AND: [scope, { id: deviceId }] }, select: { id: true, name: true, deviceKey: true }, diff --git a/packages/server/src/modules/courses/courses.schemas.ts b/packages/server/src/modules/courses/courses.schemas.ts index 47edac0..d469b6c 100644 --- a/packages/server/src/modules/courses/courses.schemas.ts +++ b/packages/server/src/modules/courses/courses.schemas.ts @@ -1,11 +1,15 @@ import { z } from 'zod'; export const createCourseSchema = z.object({ + initial: z.string().trim().max(8).optional(), + isOutDoor: z.boolean().optional(), name: z.string().trim().min(1, '请输入课程名称').max(64), classId: z.string().min(1, '请选择班级'), }); export const updateCourseSchema = z.object({ + initial: z.string().trim().max(8).optional(), + isOutDoor: z.boolean().optional(), name: z.string().trim().min(1).max(64).optional(), }); diff --git a/packages/server/src/modules/courses/courses.service.ts b/packages/server/src/modules/courses/courses.service.ts index 8b0f7da..390ef89 100644 --- a/packages/server/src/modules/courses/courses.service.ts +++ b/packages/server/src/modules/courses/courses.service.ts @@ -44,7 +44,13 @@ export async function createCourse(user: TokenPayload, input: CreateCourseInput) } const created = await prisma.course.create({ - data: { name: input.name, classId: input.classId, teacherId: user.sub }, + data: { + name: input.name, + initial: input.initial || Array.from(input.name)[0] || '', + isOutDoor: input.isOutDoor ?? false, + classId: input.classId, + teacherId: user.sub, + }, include: { teacher: teacherSelect }, }); return toCourseDto(created); @@ -61,7 +67,11 @@ export async function updateCourse( const updated = await prisma.course.update({ where: { id: courseId }, - data: { ...(input.name ? { name: input.name } : {}) }, + data: { + ...(input.name ? { name: input.name } : {}), + ...(input.initial !== undefined ? { initial: input.initial } : {}), + ...(input.isOutDoor !== undefined ? { isOutDoor: input.isOutDoor } : {}), + }, include: { teacher: teacherSelect }, }); return toCourseDto(updated); diff --git a/packages/server/src/modules/dashboard/dashboard.service.ts b/packages/server/src/modules/dashboard/dashboard.service.ts index 58d116e..428835c 100644 --- a/packages/server/src/modules/dashboard/dashboard.service.ts +++ b/packages/server/src/modules/dashboard/dashboard.service.ts @@ -8,7 +8,7 @@ import { requireClassDeviceClassId, } from '../../lib/access.js'; import { prisma } from '../../lib/db.js'; -import { MAX_TERM_WEEK } from '../../lib/term.js'; +import { getMaxTermWeeks } from '../../lib/term.js'; import type { TokenPayload } from '../../lib/jwt.js'; import { toHomeworkDto, toNotificationDto } from '../../lib/mappers.js'; import { personalIdWhere, resolvePersonalIds } from '../../lib/session.js'; @@ -21,17 +21,17 @@ const creatorSelect = { select: { id: true, name: true, username: true } } as co * - ClassHelper 班级端:固定为自己绑定的班级; * - 教师 / 管理员:优先用显式 classId,**并且必须通过班级访问校验**(与其他接口同口径, * 否则任意登录用户都能拿 classId 探测别班的教学周数); - * - 都不传:全局默认(MAX_TERM_WEEK 兜底)。 + * - 都不传:全局默认(getMaxTermWeeks 兜底)。 */ export async function resolveTermWeeks(user: TokenPayload, classId?: string): Promise { if (classId) await assertClassAccess(user, classId); const targetClassId = classId ?? (isClassDevice(user) ? requireClassDeviceClassId(user) : undefined); - if (!targetClassId) return MAX_TERM_WEEK; + if (!targetClassId) return getMaxTermWeeks(); const record = await prisma.class.findUnique({ where: { id: targetClassId }, select: { termWeeks: true }, }); - return record?.termWeeks && record.termWeeks > 0 ? record.termWeeks : MAX_TERM_WEEK; + return record?.termWeeks && record.termWeeks > 0 ? record.termWeeks : await getMaxTermWeeks(); } /** @@ -66,7 +66,7 @@ export async function getDashboardSummary(user: TokenPayload): Promise= 1 ? Math.floor(raw.intervalHours) : DEFAULT_SCHEDULE.intervalHours, - keepCount: typeof raw.keepCount === 'number' && raw.keepCount >= 1 ? Math.floor(raw.keepCount) : DEFAULT_SCHEDULE.keepCount, + typeof raw.intervalHours === 'number' && raw.intervalHours >= 1 + ? Math.floor(raw.intervalHours) + : DEFAULT_SCHEDULE.intervalHours, + keepCount: + typeof raw.keepCount === 'number' && raw.keepCount >= 1 + ? Math.floor(raw.keepCount) + : DEFAULT_SCHEDULE.keepCount, lastAutoBackupAt: typeof raw.lastAutoBackupAt === 'string' ? raw.lastAutoBackupAt : null, }; } catch { @@ -379,12 +381,28 @@ export async function testConnection(target: TargetConnectionInput): Promise> | null = null; try { client = await createPrismaClient(target.provider, url); @@ -483,12 +501,12 @@ async function assertTargetEmpty(target: TargetConnectionInput): Promise try { const rows = target.provider === 'sqlite' - ? (await client.$queryRawUnsafe( + ? ((await client.$queryRawUnsafe( "SELECT COUNT(*) AS count FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'", - )) as { count?: bigint | number }[] - : (await client.$queryRawUnsafe( + )) as { count?: bigint | number }[]) + : ((await client.$queryRawUnsafe( 'SELECT COUNT(*) AS count FROM information_schema.tables WHERE table_schema = DATABASE()', - )) as { count?: bigint | number }[]; + )) as { count?: bigint | number }[]); const tableCount = Number(rows[0]?.count ?? 0); if (tableCount > 0) { throw ApiError.badRequest( @@ -552,7 +570,11 @@ async function compileGeneratedClient(job: SwitchJob): Promise { } /** 跨库迁移的数据写入走独立子进程:运行中的服务进程缓存的是旧方言的 Prisma Client */ -function runApplySnapshotTool(job: SwitchJob, target: TargetConnectionInput, snapshotFile: string): Promise> { +function runApplySnapshotTool( + job: SwitchJob, + target: TargetConnectionInput, + snapshotFile: string, +): Promise> { const distTool = path.join(env.serverRoot, 'dist', 'tools', 'apply-snapshot.js'); const srcTool = path.join(env.serverRoot, 'src', 'tools', 'apply-snapshot.ts'); const tsxCli = path.join(env.serverRoot, 'node_modules', 'tsx', 'dist', 'cli.mjs'); @@ -698,7 +720,11 @@ async function runSwitch(job: SwitchJob, target: TargetConnectionInput): Promise if (!test.ok) { throw new Error(test.error ?? '目标库连接失败'); } - finishStep(job, '测试目标库连接', test.note ?? `连接正常(${test.latencyMs ?? '-'}ms,${test.version ?? '-'})`); + finishStep( + job, + '测试目标库连接', + test.note ?? `连接正常(${test.latencyMs ?? '-'}ms,${test.version ?? '-'})`, + ); // 4. 目标库必须为空 stepOf(job, '检查目标库为空'); diff --git a/packages/server/src/modules/grades/grades.module.ts b/packages/server/src/modules/grades/grades.module.ts index 8350604..d1e01d9 100644 --- a/packages/server/src/modules/grades/grades.module.ts +++ b/packages/server/src/modules/grades/grades.module.ts @@ -31,6 +31,23 @@ import * as gradeService from './grades.service.js'; const router = Router(); router.use(authenticate()); +router.get('/overview', async (req, res) => { + const user = getAuthUser(req); + const classId = user.classId ?? user.sub; + const cls = await (await import('../../lib/db.js')).prisma.class.findUnique({ where: { id: classId } }); + sendOk( + res, + cls?.studentGradeQueryEnabled + ? { mode: 'personal', items: await gradeService.listMyGrades(user) } + : await gradeService.collectiveAnalysis(user, classId), + ); +}); +router.get('/collective', validate({ query: examListQuerySchema }), async (req, res) => { + const user = getAuthUser(req); + const { classId } = validatedQuery<{ classId: string }>(req); + sendOk(res, await gradeService.collectiveAnalysis(user, classId)); +}); + /** GET /api/grades/my - ClassHelper 班级端查看本班成绩(教师/管理员返回空数组) */ router.get('/my', async (req, res) => { const user = getAuthUser(req); diff --git a/packages/server/src/modules/grades/grades.service.ts b/packages/server/src/modules/grades/grades.service.ts index 23bfcaa..e4f26f1 100644 --- a/packages/server/src/modules/grades/grades.service.ts +++ b/packages/server/src/modules/grades/grades.service.ts @@ -1,3 +1,4 @@ +import type { CollectiveGradeAnalysisDto as ContractCollectiveGradeAnalysisDto } from '@classhelper/shared'; import { SOCKET_EVENTS, canQueryStudentGrades, @@ -21,6 +22,7 @@ import { resolveClassScope, resolveUserClassRole, } from '../../lib/access.js'; +import { activeStudentWhere, isActiveStudent } from '../../lib/students.js'; import { prisma } from '../../lib/db.js'; import { ApiError } from '../../lib/http.js'; import type { TokenPayload } from '../../lib/jwt.js'; @@ -41,6 +43,74 @@ const studentSelect = { select: { id: true, studentNo: true, name: true } } as c const classSelect = { select: { name: true } } as const; const include = { course: courseSelect, student: studentSelect, class: classSelect } as const; +async function assertDetailQuery(user: TokenPayload, classId: string): Promise { + await assertClassAccess(user, classId); + const cls = await prisma.class.findUniqueOrThrow({ where: { id: classId } }); + if ( + !canQueryStudentGrades({ + classRole: await resolveUserClassRole(user, classId), + gradeQueryEnabled: cls.studentGradeQueryEnabled, + }) + ) { + throw ApiError.forbidden('本班只开放集体分析,未开放个人成绩'); + } +} + +/** 服务端聚合;不返回极值、个人标识或可由补集反推的稀疏分段。 */ +export async function collectiveAnalysis( + user: TokenPayload, + classId: string, +): Promise { + await assertClassAccess(user, classId); + const rows = await prisma.grade.findMany({ + where: { classId, student: activeStudentWhere() }, + select: { + studentId: true, + course: { select: { name: true } }, + examName: true, + score: true, + totalScore: true, + }, + }); + const enough = (items: typeof rows) => new Set(items.map((r) => r.studentId)).size >= 5; + const mean = (items: typeof rows) => + enough(items) + ? Math.round(items.reduce((s, r) => s + gradePercent(r.score, r.totalScore), 0) / items.length) + : null; + const suppressed = !enough(rows); + const subjects = [...new Set(rows.map((r) => r.course?.name || '综合'))].map((name) => ({ + name, + averagePercent: mean(rows.filter((r) => (r.course?.name || '综合') === name)), + })); + const trend = [...new Set(rows.map((r) => r.examName))].map((examName) => ({ + examName, + averagePercent: mean(rows.filter((r) => r.examName === examName)), + })); + const bins = [0, 60, 80, 90].map((min, index, all) => ({ + label: `${min}–${all[index + 1] ?? 100}`, + count: rows.filter((r) => { + const p = gradePercent(r.score, r.totalScore); + return p >= min && (index === all.length - 1 || p < all[index + 1]!); + }).length, + })); + const sparse = suppressed || bins.some((b) => b.count > 0 && b.count < 3); + const rate = (min: number) => { + const n = rows.filter((r) => gradePercent(r.score, r.totalScore) >= min).length; + return suppressed || n < 3 || rows.length - n < 3 ? null : Math.round((n / rows.length) * 100); + }; + return { + mode: 'collective', + classId, + suppressed, + averagePercent: mean(rows), + passRate: rate(60), + excellentRate: rate(90), + subjects: suppressed ? [] : subjects, + trend: suppressed ? [] : trend, + distribution: bins.map((b) => ({ ...b, count: sparse ? null : b.count })), + }; +} + export interface ListGradeOptions { classId?: string; courseId?: string; @@ -56,11 +126,12 @@ export interface ListGradeOptions { * 教师/管理员调用时返回空数组(他们没有"自己的成绩"),请用 `listGrades`。 */ export async function listMyGrades(user: TokenPayload): Promise { + if (isClassDevice(user)) await assertDetailQuery(user, user.classId ?? user.sub); const studentIds = await resolvePersonalIds(user); if (studentIds.length === 0) return []; const grades = await prisma.grade.findMany({ - where: { studentId: { in: studentIds } }, + where: { studentId: { in: studentIds }, student: activeStudentWhere() }, include, orderBy: [{ publishedAt: 'desc' }, { examName: 'asc' }], take: 1000, @@ -71,9 +142,11 @@ export async function listMyGrades(user: TokenPayload): Promise { /** 教师 / 管理员查看成绩(按可访问班级过滤) */ export async function listGrades(user: TokenPayload, options: ListGradeOptions): Promise { const scope = await resolveClassScope(user, options.classId); + if (isClassDevice(user)) await assertDetailQuery(user, user.classId ?? user.sub); const grades = await prisma.grade.findMany({ where: { ...classScopeWhere(scope), + student: activeStudentWhere(), ...(options.courseId ? { courseId: options.courseId } : {}), ...(options.studentId ? { studentId: options.studentId } : {}), ...(options.examName ? { examName: options.examName } : {}), @@ -202,7 +275,7 @@ export async function updateGrade( gradeId: string, input: UpdateGradeInput, ): Promise { - const current = await prisma.grade.findUnique({ where: { id: gradeId } }); + const current = await prisma.grade.findFirst({ where: { id: gradeId, student: activeStudentWhere() } }); if (!current) throw ApiError.notFound('成绩记录不存在'); // 改的是哪条成绩,就按那条成绩所属的班级 + 科目判权限 @@ -243,7 +316,7 @@ export async function updateGrade( } export async function deleteGrade(user: TokenPayload, gradeId: string): Promise { - const current = await prisma.grade.findUnique({ where: { id: gradeId } }); + const current = await prisma.grade.findFirst({ where: { id: gradeId, student: activeStudentWhere() } }); if (!current) throw ApiError.notFound('成绩记录不存在'); await assertCanManageSubjectContent(user, current.classId, current.courseId, '删除成绩'); @@ -272,7 +345,7 @@ export async function updateGradeLevels( ): Promise<{ updated: number }> { if (input.items && input.items.length > 0) { const records = await prisma.grade.findMany({ - where: { id: { in: input.items.map((item) => item.id) } }, + where: { id: { in: input.items.map((item) => item.id) }, student: activeStudentWhere() }, select: { id: true, classId: true, courseId: true }, }); if (records.length !== input.items.length) { @@ -309,6 +382,7 @@ export async function updateGradeLevels( where: { classId, examName: input.examName!, + student: activeStudentWhere(), ...(input.courseId !== undefined ? { courseId: input.courseId ?? null } : {}), }, select: { id: true, score: true, totalScore: true, level: true }, @@ -346,21 +420,24 @@ export async function getStudentGradeDetail( studentNo: string, requestedClassId?: string, ): Promise { + if (isClassDevice(user)) await assertDetailQuery(user, user.classId ?? user.sub); const scope = await resolveClassScope(user, requestedClassId); const student = await prisma.student.findFirst({ - where: { studentNo: normalizeStudentNo(studentNo), ...classScopeWhere(scope) }, + where: { ...activeStudentWhere(), studentNo: normalizeStudentNo(studentNo), ...classScopeWhere(scope) }, include: { class: { select: { id: true, name: true, studentGradeQueryEnabled: true } } }, }); if (!student) throw ApiError.notFound('没找到这个学号的学生,请核对学号是否输入正确'); if (student.class) { const classRole = await resolveUserClassRole(user, student.class.id); - const allowed = canQueryStudentGrades({ - classRole, - // 老库补列时的默认值是 true(开箱可用),所以 null/undefined 都按开启处理 - gradeQueryEnabled: student.class.studentGradeQueryEnabled ?? true, - }); + const allowed = + isStaff(user) || + canQueryStudentGrades({ + classRole, + // 老库补列时的默认值是 true(开箱可用),所以 null/undefined 都按开启处理 + gradeQueryEnabled: student.class.studentGradeQueryEnabled ?? true, + }); if (!allowed) { throw ApiError.forbidden('本班未开放学生自助查询成绩,请联系老师开启'); } @@ -395,9 +472,10 @@ export async function getStudentGradeDetailById( ): Promise { const student = await prisma.student.findUnique({ where: { id: studentId }, - select: { studentNo: true, classId: true }, + select: { studentNo: true, classId: true, status: true }, }); - if (!student) throw ApiError.notFound('学生不存在'); + if (!student || ('status' in student && !isActiveStudent(student as { status: string }))) + throw ApiError.notFound('学生不存在'); await assertClassAccess(user, student.classId ?? ''); return getStudentGradeDetail(user, student.studentNo); } @@ -468,9 +546,9 @@ async function upsertGrade(data: { async function assertStudentInClass(studentId: string, classId: string): Promise { const student = await prisma.student.findUnique({ where: { id: studentId }, - select: { classId: true }, + select: { classId: true, status: true }, }); - if (!student) throw ApiError.badRequest('学生不存在'); + if (!student || !isActiveStudent(student)) throw ApiError.badRequest('学生不存在'); if (student.classId !== classId) throw ApiError.badRequest('该学生不属于所选班级'); } @@ -491,7 +569,11 @@ async function assertCourseInClass(courseId: string, classId: string): Promise { await assertClassAccess(user, classId); const [groups, exams] = await Promise.all([ - prisma.grade.groupBy({ by: ['examName'], where: { classId }, _count: { _all: true } }), + prisma.grade.groupBy({ + by: ['examName'], + where: { classId, student: activeStudentWhere() }, + _count: { _all: true }, + }), prisma.exam.findMany({ where: { classId }, select: { id: true, name: true, countInTotal: true }, @@ -580,16 +662,18 @@ export async function getExamRanking( if (!classRecord) throw ApiError.notFound('班级不存在'); const classRole = await resolveUserClassRole(user, classId); - const allowed = canQueryStudentGrades({ - classRole, - // 老库补列时的默认值是 true(开箱可用),所以 null/undefined 都按开启处理 - gradeQueryEnabled: classRecord.studentGradeQueryEnabled ?? true, - }); + const allowed = + isStaff(user) || + canQueryStudentGrades({ + classRole, + // 老库补列时的默认值是 true(开箱可用),所以 null/undefined 都按开启处理 + gradeQueryEnabled: classRecord.studentGradeQueryEnabled ?? true, + }); if (!allowed) throw ApiError.forbidden('本班未开放成绩查询,请联系老师开启'); const [grades, exam] = await Promise.all([ prisma.grade.findMany({ - where: { classId, examName }, + where: { classId, examName, student: activeStudentWhere() }, include, orderBy: [{ publishedAt: 'desc' }], }), diff --git a/packages/server/src/modules/homeworks/homeworks.service.ts b/packages/server/src/modules/homeworks/homeworks.service.ts index 1463d49..9318849 100644 --- a/packages/server/src/modules/homeworks/homeworks.service.ts +++ b/packages/server/src/modules/homeworks/homeworks.service.ts @@ -19,6 +19,7 @@ import { resolveClassScope, } from '../../lib/access.js'; import { normalizeBulkClassIds, summarizeBulk, toBulkFailure } from '../../lib/bulk.js'; +import { activeStudentWhere, isActiveStudent } from '../../lib/students.js'; import { prisma } from '../../lib/db.js'; import { ApiError } from '../../lib/http.js'; import type { TokenPayload } from '../../lib/jwt.js'; @@ -67,16 +68,14 @@ export async function listHomeworks( ...(options.keyword ? { OR: [{ title: { contains: options.keyword } }, { content: { contains: options.keyword } }] } : {}), - ...(device && options.pendingOnly - ? { statuses: { none: { ...personalWhere, completed: true } } } - : {}), + ...(device && options.pendingOnly ? { statuses: { none: { ...personalWhere, completed: true } } } : {}), // 按天查看:作业「属于哪一天」看 assignDate(本地日期),不是 createdAt ...(options.date ? { assignDate: options.date } : {}), }, include: { course: courseSelect, creator: creatorSelect, - statuses: device ? { where: personalWhere } : true, + statuses: device ? { where: personalWhere } : { where: { student: activeStudentWhere() } }, }, orderBy: [{ assignDate: 'desc' }, { createdAt: 'desc' }], take: 200, @@ -127,7 +126,9 @@ export async function getHomework(user: TokenPayload, homeworkId: string): Promi include: { course: courseSelect, creator: creatorSelect, - statuses: device ? { where: personalIdWhere(personalIds) } : true, + statuses: device + ? { where: personalIdWhere(personalIds) } + : { where: { student: activeStudentWhere() } }, }, }); if (!homework) throw ApiError.notFound('作业不存在'); @@ -165,6 +166,7 @@ export async function createHomework(user: TokenPayload, input: CreateHomeworkIn select: { teacherId: true }, }); if (!owner) throw ApiError.notFound('班级不存在'); + if (!owner.teacherId) throw ApiError.badRequest('本班尚未指定班主任,暂不能由班级端录入作业'); createdBy = owner.teacherId; } @@ -179,7 +181,11 @@ export async function createHomework(user: TokenPayload, input: CreateHomeworkIn assignDate: input.assignDate ?? dayKeyLocal(new Date()), createdBy, }, - include: { course: courseSelect, creator: creatorSelect, statuses: true }, + include: { + course: courseSelect, + creator: creatorSelect, + statuses: { where: { student: activeStudentWhere() } }, + }, }); const dto = toHomeworkDto(created, { withStatus: true }); @@ -268,7 +274,11 @@ export async function updateHomework( ...(input.attachmentUrl !== undefined ? { attachmentUrl: input.attachmentUrl ?? null } : {}), ...(input.assignDate !== undefined ? { assignDate: input.assignDate } : {}), }, - include: { course: courseSelect, creator: creatorSelect, statuses: true }, + include: { + course: courseSelect, + creator: creatorSelect, + statuses: { where: { student: activeStudentWhere() } }, + }, }); const dto = toHomeworkDto(updated, { withStatus: true }); @@ -340,9 +350,9 @@ export async function updateHomeworkStatus( } const target = await prisma.student.findUnique({ where: { id: input.studentId }, - select: { classId: true }, + select: { classId: true, status: true }, }); - if (!target || target.classId !== homework.classId) { + if (!target || !isActiveStudent(target) || target.classId !== homework.classId) { throw ApiError.badRequest('请指定该作业所在班级的学生(studentId)'); } @@ -363,7 +373,7 @@ export async function updateHomeworkStatus( async function buildSubmissions(classId: string, homeworkId: string): Promise { const [students, statuses] = await Promise.all([ prisma.student.findMany({ - where: { classId, archivedYearId: null }, + where: { classId, archivedYearId: null, ...activeStudentWhere() }, select: { id: true, name: true, studentNo: true }, orderBy: { studentNo: 'asc' }, }), @@ -426,7 +436,7 @@ export async function updateHomeworkSubmissions( assertCanManageSubmissions(user); const students = await prisma.student.findMany({ - where: { classId: homework.classId, archivedYearId: null }, + where: { classId: homework.classId, archivedYearId: null, ...activeStudentWhere() }, select: { id: true }, }); if (students.length === 0) throw ApiError.badRequest('当前班级还没有学生,请先在名单里添加学生'); diff --git a/packages/server/src/modules/imports/class-plan.service.ts b/packages/server/src/modules/imports/class-plan.service.ts index da2823c..532a70c 100644 --- a/packages/server/src/modules/imports/class-plan.service.ts +++ b/packages/server/src/modules/imports/class-plan.service.ts @@ -1,7 +1,6 @@ +import { importProfile } from '../class-plans/profile-import.service.js'; import { prisma } from '../../lib/db.js'; -import { ApiError } from '../../lib/http.js'; import type { TokenPayload } from '../../lib/jwt.js'; -import { logger } from '../../lib/logger.js'; import { assertCanManageSchedule } from '../../lib/access.js'; import { weekParityFromDiv } from '@classhelper/shared'; import type { ClassPlanImportInput } from './imports.schemas.js'; @@ -20,7 +19,7 @@ import type { ClassPlanImportInput } from './imports.schemas.js'; * ``` * * 关键语义(与 ClassIsland 源码一致): - * - `TimeRule.WeekDay`:0=周日、1=周一 … 6=周六; + * - `TimeRule.WeekDay`:0=不按天、1=周一 … 7=周日; * - `TimeRule.WeekCountDiv`:0=每周;1=单周;2=双周(`WeekCountDivTotal=2` 时); * 一般规则:`WeekCountDivTotal = n` 时,第 `WeekCountDiv` 周启用(1-based); * - `Classes[i]` 对应 `Layouts` 里**第 i 个 `TimeType === 0`(上课)的点**,课间/分割线不占位; @@ -184,18 +183,9 @@ export function parseClassIslandClassPlan(input: unknown): ClassPlanParseResult const rule = asRecord(plan.TimeRule ?? plan.timeRule) ?? {}; const rawWeekDay = Number(rule.WeekDay ?? rule.weekDay ?? 0); // ClassIsland:0=周日 … 6=周六 → 我们:1=周一 … 7=周日 - const dayOfWeek = Number.isFinite(rawWeekDay) - ? rawWeekDay === 0 - ? 7 - : Math.min(7, Math.max(1, rawWeekDay)) - : 1; + const dayOfWeek = Number.isFinite(rawWeekDay) ? Math.min(7, Math.max(0, rawWeekDay)) : 1; const weekCountDiv = Number(rule.WeekCountDiv ?? rule.weekCountDiv ?? 0) || 0; const weekCountDivTotal = Number(rule.WeekCountDivTotal ?? rule.weekCountDivTotal ?? 2) || 2; - if (weekCountDivTotal > 2 && weekCountDiv > 0) { - result.warnings.push( - `${planName}:${weekCountDivTotal} 周轮换无法用"单双周"表达,已按每周处理(第 ${weekCountDiv} 周)`, - ); - } const weekParity = resolveParity(weekCountDiv, weekCountDivTotal); const layoutId = String(plan.TimeLayoutId ?? plan.timeLayoutId ?? ''); @@ -275,109 +265,6 @@ export async function previewClassPlan( * - replace:先清空该班课表再写入(失败回滚:整个过程在一个事务里); * - merge:按 `星期 + 开始时间 + 单双周` 去重,存在则更新时间/课程,否则新增。 */ -export async function importClassPlan( - user: TokenPayload, - input: ClassPlanImportInput, -): Promise<{ - imported: number; - created: number; - updated: number; - createdCourses: string[]; - weekStart: number; - weekEnd: number; -}> { - await assertCanManageSchedule(user, input.classId); - const parsed = parseClassIslandClassPlan(input.payload); - if (parsed.entries.length === 0) { - throw new ApiError(400, 'IMPORT_INVALID', parsed.errors[0] ?? '没有可导入的课程'); - } - - const classRecord = await prisma.class.findUnique({ - where: { id: input.classId }, - select: { id: true, teacherId: true, termWeeks: true }, - }); - if (!classRecord) throw ApiError.notFound('班级不存在'); - - // 导入范围与本班配置的学期长度一致。 - const weekStart = input.termStartWeek ?? 1; - const weekEnd = classRecord.termWeeks; - if (weekStart > weekEnd) throw ApiError.badRequest('导入起始周不能超出本班学期周数'); - - const result = await prisma.$transaction(async (tx) => { - // 1) 补建缺失课程(用班主任作为课程任课老师) - const existingCourses = await tx.course.findMany({ - where: { classId: input.classId }, - select: { id: true, name: true }, - }); - const courseIdByName = new Map(existingCourses.map((item) => [item.name, item.id])); - const createdCourses: string[] = []; - for (const name of parsed.subjects) { - if (courseIdByName.has(name)) continue; - const created = await tx.course.create({ - data: { classId: input.classId, name, teacherId: classRecord.teacherId }, - select: { id: true, name: true }, - }); - courseIdByName.set(created.name, created.id); - createdCourses.push(created.name); - } - - // 2) replace 模式:先清空该班课表 - if (input.mode === 'replace') { - await tx.schedule.deleteMany({ where: { classId: input.classId } }); - } - - // 3) 写入课程(merge 时按 星期+开始时间+单双周 去重) - let created = 0; - let updated = 0; - for (const entry of parsed.entries) { - const courseId = courseIdByName.get(entry.subject); - if (!courseId) continue; - const existing = await tx.schedule.findFirst({ - where: { - classId: input.classId, - dayOfWeek: entry.dayOfWeek, - startTime: entry.startTime, - weekParity: entry.weekParity, - }, - select: { id: true }, - }); - if (existing) { - await tx.schedule.update({ - where: { id: existing.id }, - data: { courseId, endTime: entry.endTime, weekStart, weekEnd }, - }); - updated += 1; - } else { - await tx.schedule.create({ - data: { - classId: input.classId, - courseId, - dayOfWeek: entry.dayOfWeek, - startTime: entry.startTime, - endTime: entry.endTime, - location: null, - weekStart, - weekEnd, - weekParity: entry.weekParity, - }, - }); - created += 1; - } - } - return { created, updated, createdCourses }; - }); - - const total = result.created + result.updated; - logger.info( - `ClassIsland 课程表导入:班级=${input.classId} 模式=${input.mode} 新增=${result.created} 更新=${result.updated} ` + - `补建科目=[${result.createdCourses.join(',')}]`, - ); - return { - imported: total, - created: result.created, - updated: result.updated, - createdCourses: result.createdCourses, - weekStart, - weekEnd, - }; +export async function importClassPlan(user: TokenPayload, input: ClassPlanImportInput) { + return importProfile(user, input.classId, input.payload, input.mode); } diff --git a/packages/server/src/modules/imports/imports.module.ts b/packages/server/src/modules/imports/imports.module.ts index f740d63..a64ea18 100644 --- a/packages/server/src/modules/imports/imports.module.ts +++ b/packages/server/src/modules/imports/imports.module.ts @@ -1,7 +1,7 @@ import { Router } from 'express'; import { ApiError, sendOk } from '../../lib/http.js'; import { idParamSchema } from '../../lib/schemas.js'; -import { authenticate, getAuthUser, requireAnyPermission } from '../../middleware/auth.js'; +import { authenticate, getAuthUser, requirePermission, requireAnyPermission } from '../../middleware/auth.js'; import { validate, validatedBody, validatedParams, validatedQuery } from '../../middleware/validate.js'; import { defineModule } from '../module.types.js'; import { @@ -151,7 +151,7 @@ router.get('/time-layout', validate({ query: timeLayoutQuerySchema }), async (re * POST /api/imports/time-layout - 导入 ClassIsland 时间配置 * mode=replace 覆盖 / merge 按开始时间合并;解析失败返回 400 且不修改原配置。 */ -router.post('/time-layout', validate({ body: timeLayoutImportSchema }), async (req, res) => { +router.post('/time-layout', requirePermission('timetable:manage'), validate({ body: timeLayoutImportSchema }), async (req, res) => { const result = await importTimeLayout(getAuthUser(req), validatedBody(req)); sendOk( res, @@ -163,7 +163,7 @@ router.post('/time-layout', validate({ body: timeLayoutImportSchema }), async (r }); /** DELETE /api/imports/time-layout/:id - 删除某份时间配置 */ -router.delete('/time-layout/:id', validate({ params: idParamSchema }), async (req, res) => { +router.delete('/time-layout/:id', requirePermission('timetable:manage'), validate({ params: idParamSchema }), async (req, res) => { const { id } = validatedParams<{ id: string }>(req); await deleteTimeLayout(getAuthUser(req), id); sendOk(res, { id }, '时间配置已删除'); diff --git a/packages/server/src/modules/imports/table-import.service.ts b/packages/server/src/modules/imports/table-import.service.ts index 1a87dee..e7759b9 100644 --- a/packages/server/src/modules/imports/table-import.service.ts +++ b/packages/server/src/modules/imports/table-import.service.ts @@ -1,5 +1,6 @@ import * as XLSX from 'xlsx'; import { formatClassName, normalizeStudentNo, type TableImportKind } from '@classhelper/shared'; +import { activeStudentWhere, isActiveStudent } from '../../lib/students.js'; import { prisma } from '../../lib/db.js'; import { env } from '../../config/env.js'; import { ApiError } from '../../lib/http.js'; @@ -329,7 +330,7 @@ export async function commitGrades(user: TokenPayload, input: TableCommitInput): const parsed = parseTable(buffer, input.fileName); const students = await prisma.student.findMany({ - where: { classId }, + where: { classId, ...activeStudentWhere() }, select: { id: true, studentNo: true, name: true }, }); // 学号在库里已归一化为大写,比对前统一一次 @@ -565,9 +566,11 @@ export async function commitStudents(user: TokenPayload, input: TableCommitInput const existing = await prisma.student.findUnique({ where: { studentNo }, - select: { id: true, classId: true }, + select: { id: true, classId: true, status: true }, }); + if (existing && !isActiveStudent(existing)) + throw ApiError.conflict('该学号已被历史学生占用,不能通过导入复用'); if (existing) { if (input.mode === 'append') { skipped += 1; @@ -737,7 +740,7 @@ export async function commitClassTeachers( where: { archivedYearId: null }, select: { id: true, name: true, teacherId: true }, }); - const classByName = new Map(); + const classByName = new Map(); for (const item of classes) { const key = item.name.toLowerCase(); if (classByName.has(key)) classByName.delete(key); diff --git a/packages/server/src/modules/imports/time-layout.service.ts b/packages/server/src/modules/imports/time-layout.service.ts index 4f3838c..6dcf619 100644 --- a/packages/server/src/modules/imports/time-layout.service.ts +++ b/packages/server/src/modules/imports/time-layout.service.ts @@ -1,3 +1,4 @@ +import { saveLayout } from '../class-plans/class-plans.service.js'; import { prisma } from '../../lib/db.js'; import { ApiError } from '../../lib/http.js'; import type { TokenPayload } from '../../lib/jwt.js'; @@ -232,7 +233,7 @@ export function parseClassIslandTimeLayout(input: unknown): TimeLayoutParseResul export interface TimeLayoutDto { id: string; - classId: string; + classId: string | null; name: string; source: string; items: ParsedTimeLayoutItem[]; @@ -242,10 +243,18 @@ export interface TimeLayoutDto { function toDto(record: { id: string; - classId: string; + classId: string | null; name: string; source: string; items: string; + points?: Array<{ + position: number; + name: string; + startTime: string; + endTime: string; + timeType: string; + isHideDefault: boolean; + }>; createdAt: Date; updatedAt: Date; }): TimeLayoutDto { @@ -255,6 +264,21 @@ function toDto(record: { } catch { items = []; } + if (record.points?.length) + items = record.points.map((p) => ({ + index: p.position, + name: p.name, + startTime: p.startTime, + endTime: p.endTime, + type: (p.timeType === 'LESSON' + ? 'class' + : p.timeType === 'BREAK' + ? 'break' + : p.timeType === 'DIVIDER' + ? 'divider' + : 'action') as ParsedTimeLayoutItem['type'], + skipped: p.isHideDefault, + })); return { id: record.id, classId: record.classId, @@ -284,13 +308,17 @@ function safeJson(text: string): unknown { export async function listTimeLayouts(user: TokenPayload, classId: string): Promise { await assertCanManageSchedule(user, classId); - const records = await prisma.timeLayout.findMany({ where: { classId }, orderBy: { updatedAt: 'desc' } }); + const records = await prisma.timeLayout.findMany({ + where: { classId }, + include: { points: { orderBy: { position: 'asc' } } }, + orderBy: { updatedAt: 'desc' }, + }); return records.map(toDto); } /** * 导入(写入):解析 → 有错误直接 400(不写库)→ 按 replace/merge 落库。 - * 权限与"课表管理"一致:管理员或本班班主任。 + * 写入路由要求 timetable:manage,班级范围仍由本服务校验。 */ export async function importTimeLayout( user: TokenPayload, @@ -312,76 +340,29 @@ export async function importTimeLayout( } const name = input.name?.trim() || '默认时间表'; - const existing = await prisma.timeLayout.findMany({ - where: { classId: input.classId }, - orderBy: { updatedAt: 'desc' }, + const current = await prisma.timeLayout.findFirst({where:{classId:input.classId,name},include:{points:{orderBy:{position:'asc'}}}}); + const previous = current ? toDto(current).items : []; + const starts=new Set(parsed.items.map(p=>p.startTime)); + const merged=input.mode==='merge'?parsed.items.filter(p=>previous.some(old=>old.startTime===p.startTime)).length:0; + const combined=input.mode==='merge'?[...previous.filter(p=>!starts.has(p.startTime)),...parsed.items].sort((a,b)=>a.startTime.localeCompare(b.startTime)):parsed.items; + const claimed=new Set(); + const points=combined.map((p,position)=>{ + const timeType=({class:'LESSON',break:'BREAK',divider:'DIVIDER',action:'ACTION'} as const)[p.type]; + const candidates=current?.points.filter(old=>!claimed.has(old.id)&&old.startTime===p.startTime&&old.timeType===timeType)||[]; + const previousPoint=candidates.find(old=>old.endTime===p.endTime)||candidates[0]; + if(previousPoint)claimed.add(previousPoint.id); + return {id:previousPoint?.id,position,name:p.name,startTime:p.startTime,endTime:p.endTime,timeType,isHideDefault:p.skipped,defaultClassId:null,breakName:p.type==='break'?p.name:'',actionSet:'{}'}; }); - const current = existing.find((item) => item.name === name) ?? existing[0] ?? null; - - if (input.mode === 'merge' && current) { - let previousItems: ParsedTimeLayoutItem[] = []; - try { - previousItems = JSON.parse(current.items) as ParsedTimeLayoutItem[]; - } catch { - previousItems = []; - } - - // 以"开始时间"为槽位标识: - // - 导入项与旧配置的开始时间相同 → 视为同一节次被覆盖(计入 merged) - // - 开始时间不同 → 视为新增(计入 replaced),旧节次全部保留 - // 注意:旧配置里可能存在开始时间相同的多条(例如分割线/跳过的节次), - // 只有被此次导入显式覆盖的那些才移除,避免合并时静默丢数据。 - const incomingStarts = new Set(parsed.items.map((item) => item.startTime)); - const previousStarts = new Set(previousItems.map((item) => item.startTime)); - const merged = parsed.items.filter((item) => previousStarts.has(item.startTime)).length; - const replaced = parsed.items.length - merged; - const preserved = previousItems.filter((item) => !incomingStarts.has(item.startTime)); - const combined = [...preserved, ...parsed.items].sort((a, b) => a.startTime.localeCompare(b.startTime)); - - const updated = await prisma.timeLayout.update({ - where: { id: current.id }, - data: { - items: JSON.stringify(combined.map((item, index) => ({ ...item, index: index + 1 }))), - source: 'classisland', - }, - }); - logger.info( - `课表时间配置合并导入:班级=${input.classId} 覆盖=${merged} 新增=${replaced} 共 ${combined.length} 节`, - ); - return { layout: toDto(updated), mode: 'merge', warnings: parsed.warnings, replaced, merged }; - } - - // replace:同一班级同名配置整体替换;没有则新建 - if (current) { - const updated = await prisma.timeLayout.update({ - where: { id: current.id }, - data: { items: JSON.stringify(parsed.items), name, source: 'classisland' }, - }); - logger.info(`课表时间配置覆盖导入:班级=${input.classId} 共 ${parsed.items.length} 节`); - return { - layout: toDto(updated), - mode: 'replace', - warnings: parsed.warnings, - replaced: parsed.items.length, - merged: 0, - }; - } - const created = await prisma.timeLayout.create({ - data: { classId: input.classId, name, source: 'classisland', items: JSON.stringify(parsed.items) }, - }); - logger.info(`课表时间配置新建:班级=${input.classId} 共 ${parsed.items.length} 节`); - return { - layout: toDto(created), - mode: 'replace', - warnings: parsed.warnings, - replaced: parsed.items.length, - merged: 0, - }; + const layout=await saveLayout({classId:input.classId,name,points},current?.id); + logger.info(`课表时间配置导入:班级=${input.classId} 模式=${input.mode} 共 ${combined.length} 个时间点`); + return {layout:toDto(layout),mode:input.mode,warnings:parsed.warnings,replaced:parsed.items.length-merged,merged}; } export async function deleteTimeLayout(user: TokenPayload, layoutId: string): Promise { const record = await prisma.timeLayout.findUnique({ where: { id: layoutId } }); if (!record) throw ApiError.notFound('时间配置不存在'); + if (!record.classId) throw ApiError.forbidden('全校时间表请在时间表编辑器管理'); await assertCanManageSchedule(user, record.classId); + if(await prisma.classPlan.count({where:{timeLayoutId:layoutId}}))throw ApiError.conflict('仍有课表使用该时间表'); await prisma.timeLayout.delete({ where: { id: layoutId } }); } diff --git a/packages/server/src/modules/notifications/notifications.service.ts b/packages/server/src/modules/notifications/notifications.service.ts index 1925658..139e129 100644 --- a/packages/server/src/modules/notifications/notifications.service.ts +++ b/packages/server/src/modules/notifications/notifications.service.ts @@ -16,6 +16,7 @@ import { classScopeWhere, resolveClassScope, } from '../../lib/access.js'; +import { activeStudentWhere } from '../../lib/students.js'; import { prisma } from '../../lib/db.js'; import { ApiError } from '../../lib/http.js'; import type { TokenPayload } from '../../lib/jwt.js'; @@ -87,7 +88,7 @@ export async function listNotifications( }, include: { creator: creatorSelect, - reads: isStaff ? true : { where: personalWhere }, + reads: isStaff ? { where: { student: activeStudentWhere() } } : { where: personalWhere }, }, orderBy: [{ createdAt: 'desc' }], take: 200, diff --git a/packages/server/src/modules/permissions/permissions.schemas.ts b/packages/server/src/modules/permissions/permissions.schemas.ts index b9785c4..72d0cc6 100644 --- a/packages/server/src/modules/permissions/permissions.schemas.ts +++ b/packages/server/src/modules/permissions/permissions.schemas.ts @@ -22,6 +22,8 @@ const keysSchema = z.array(permissionKeySchema).max(200, '一次最多提交 200 export const createPermissionGroupSchema = z.object({ name: z.string().trim().min(1, '请输入权限组名称').max(40, '权限组名称最多 40 个字'), description: z.string().trim().max(200, '描述最多 200 个字').optional(), + scope: z.enum(['SELF_CLASS', 'GRADE', 'DIVISION', 'ALL']).optional(), + scopeValue: z.string().trim().max(200).optional(), keys: keysSchema.optional(), }); @@ -34,6 +36,8 @@ export const createPermissionGroupSchema = z.object({ export const updatePermissionGroupSchema = z.object({ name: z.string().trim().min(1, '请输入权限组名称').max(40, '权限组名称最多 40 个字').optional(), description: z.string().trim().max(200, '描述最多 200 个字').optional(), + scope: z.enum(['SELF_CLASS', 'GRADE', 'DIVISION', 'ALL']).optional(), + scopeValue: z.string().trim().max(200).optional(), keys: keysSchema.optional(), }); diff --git a/packages/server/src/modules/permissions/permissions.service.ts b/packages/server/src/modules/permissions/permissions.service.ts index 682ad23..e370300 100644 --- a/packages/server/src/modules/permissions/permissions.service.ts +++ b/packages/server/src/modules/permissions/permissions.service.ts @@ -36,6 +36,8 @@ const GROUP_SELECT = { description: true, builtin: true, defaultRole: true, + scope: true, + scopeValue: true, createdAt: true, updatedAt: true, items: { select: { key: true } }, @@ -48,6 +50,8 @@ type GroupRow = { description: string; builtin: boolean; defaultRole: string | null; + scope: string; + scopeValue: string; createdAt: Date; updatedAt: Date; items: { key: string }[]; @@ -91,6 +95,8 @@ function normalizeKeys(keys: readonly string[] | undefined): PermissionKey[] { function toGroupDto(row: GroupRow): PermissionGroupDto { return { id: row.id, + scope: row.scope as PermissionGroupDto['scope'], + scopeValue: row.scopeValue, name: row.name, description: row.description, builtin: row.builtin, @@ -156,6 +162,8 @@ export async function createGroup(input: CreatePermissionGroupRequest): Promise< const created = await prisma.permissionGroup.create({ data: { name, + scope: input.scope ?? 'SELF_CLASS', + scopeValue: input.scopeValue ?? '', description: input.description?.trim() ?? '', items: { create: keys.map((key) => ({ key })) }, }, @@ -188,6 +196,8 @@ export async function updateGroup( where: { id: groupId }, data: { ...(name ? { name } : {}), + ...(input.scope ? { scope: input.scope } : {}), + ...(input.scopeValue !== undefined ? { scopeValue: input.scopeValue } : {}), ...(input.description !== undefined ? { description: input.description.trim() } : {}), }, }); diff --git a/packages/server/src/modules/registry.ts b/packages/server/src/modules/registry.ts index 5f6edc5..352f0cb 100644 --- a/packages/server/src/modules/registry.ts +++ b/packages/server/src/modules/registry.ts @@ -1,3 +1,8 @@ +import { + classPlansModule, + timeLayoutsModule, + classPlanGroupsModule, +} from './class-plans/class-plans.module.js'; import { aiModule } from './ai/ai.module.js'; import { archivesModule } from './archives/archives.module.js'; import { authModule } from './auth/auth.module.js'; @@ -34,6 +39,9 @@ export const apiModules: ApiModule[] = [ clientsModule, coursesModule, schedulesModule, + classPlansModule, + timeLayoutsModule, + classPlanGroupsModule, homeworksModule, notificationsModule, permissionsModule, diff --git a/packages/server/src/modules/schedules/schedules.facade.ts b/packages/server/src/modules/schedules/schedules.facade.ts new file mode 100644 index 0000000..10b7a23 --- /dev/null +++ b/packages/server/src/modules/schedules/schedules.facade.ts @@ -0,0 +1,385 @@ +import { getMaxTermWeeks as normalizeMaxWeeks } from '@classhelper/shared'; +import { randomUUID } from 'node:crypto'; +import { + buildScheduleWeekView, + dayKeyLocal, + shiftDayKey, + type ScheduleDto, + type ClassStatusDto, + type ScheduleWeekView, +} from '@classhelper/shared'; +import { prisma } from '../../lib/db.js'; +import { + assertClassAccess, + assertCanManageSchedule, + getAccessibleClassIds, + isClassDevice, + requireClassDeviceClassId, +} from '../../lib/access.js'; +import { + loadPlans, + planInclude, + projectPlan, + resolveActiveClassPlan, + type PlanRow, +} from '../../lib/class-plan.js'; +import { loadTermContext, resolveCurrentWeek, getMaxTermWeeks } from '../../lib/term.js'; +import { ApiError } from '../../lib/http.js'; +import type { TokenPayload } from '../../lib/jwt.js'; +import type { Prisma } from '../../generated/prisma/client.js'; +import type { CreateScheduleInput, UpdateScheduleInput } from './schedules.schemas.js'; +import { notifyScheduleChanged } from '../class-plans/class-plans.service.js'; +export interface ListScheduleOptions { + classId?: string; + week?: number; + dayOfWeek?: number; +} +/** 兼容门面只投影/写入新模型;旧 Schedule 保留作迁移审计来源,避免两份业务真值。 */ +export async function listSchedules( + user: TokenPayload, + options: ListScheduleOptions, +): Promise { + if (options.classId) await assertClassAccess(user, options.classId); + const classIds = options.classId ? [options.classId] : await getAccessibleClassIds(user); + const result: ScheduleDto[] = []; + for (const classId of classIds) { + if (options.week) { + if (options.week > (await loadTermContext(classId)).maxWeek) continue; + const dates = await datesForWeek(classId, options.week); + for (const date of dates) { + const weekday = date.getDay() || 7; + if (options.dayOfWeek && options.dayOfWeek !== weekday) continue; + result.push(...(await resolveActiveClassPlan(classId, date)).schedules); + } + } else { + for (const plan of await loadPlans(classId)) + if (!plan.isOverlay && (!options.dayOfWeek || plan.weekday === options.dayOfWeek)) + result.push(...projectPlan(plan)); + } + } + const byId = new Map(); + for (const row of result) { + const previous = byId.get(row.id); + if (previous) { + previous.weekStart = Math.min(previous.weekStart, row.weekStart); + previous.weekEnd = Math.max(previous.weekEnd, row.weekEnd); + } else byId.set(row.id, { ...row }); + } + const metadata = await prisma.appSetting.findMany({ + where: { key: { in: [...byId.keys()].map((id) => 'schedule.compat.' + id) } }, + }); + for (const row of metadata) { + try { + const item = byId.get(row.key.replace('schedule.compat.', '')); + if (item) Object.assign(item, JSON.parse(row.value)); + } catch { + /* 损坏的兼容元数据不影响新模型 */ + } + } + return [...byId.values()].sort( + (a, b) => a.dayOfWeek - b.dayOfWeek || a.startTime.localeCompare(b.startTime), + ); +} +async function datesForWeek(classId: string, week: number) { + const context = await loadTermContext(classId); + const first = + context.weeks.find((w) => w.weekNumber === week)?.startDate || + shiftDayKey(context.termStartDate, (week - 1) * 7); + const date = new Date(`${first}T12:00:00`); + const monday = shiftDayKey(dayKeyLocal(date), -((date.getDay() || 7) - 1)); + return Array.from({ length: 7 }, (_, i) => new Date(`${shiftDayKey(monday, i)}T12:00:00`)); +} +export async function getActiveScheduleWeek( + user: TokenPayload, + options: { classId?: string; week?: number }, +) { + const classId = isClassDevice(user) ? requireClassDeviceClassId(user) : options.classId; + if (!classId) throw ApiError.badRequest('请指定班级'); + await assertClassAccess(user, classId); + const context = await loadTermContext(classId), + week = options.week ?? resolveCurrentWeek(new Date(), context); + if (week > context.maxWeek) throw ApiError.badRequest('周次超过本班教学周数'); + const days = []; + for (const date of await datesForWeek(classId, week)) + days.push({ + weekday: date.getDay() || 7, + date: dayKeyLocal(date), + ...(await resolveActiveClassPlan(classId, date)), + }); + return { classId, week, days }; +} +export async function getScheduleGrid( + user: TokenPayload, + options: { classId?: string; week?: number }, +): Promise { + const week = options.week ?? resolveCurrentWeek(new Date(), await loadTermContext(options.classId)); + return buildScheduleWeekView(await listSchedules(user, { ...options, week }), week); +} +export async function computeClassStatus(classId: string, at = new Date()): Promise { + return { ...(await resolveActiveClassPlan(classId, at)), classId, serverTime: at.toISOString() }; +} +export async function getClassStatus(user: TokenPayload, options: { classId?: string; at?: Date } = {}) { + const classId = isClassDevice(user) ? requireClassDeviceClassId(user) : options.classId; + if (!classId) throw ApiError.badRequest('请指定班级'); + await assertClassAccess(user, classId); + return computeClassStatus(classId, options.at); +} + +type Tx = Prisma.TransactionClient; +async function privateLayout(tx: Tx, plan: PlanRow) { + const layout = await tx.timeLayout.create({ + data: { classId: plan.classId, name: `${plan.name} 作息`, source: 'manual' }, + }); + const mapping = new Map(); + for (const p of plan.layout.points) { + const { id: _id, timeLayoutId: _layout, ...data } = p; + const point = await tx.timeLayoutItem.create({ data: { ...data, timeLayoutId: layout.id } }); + mapping.set(p.id, point.id); + } + return { layout, mapping }; +} +function matches(p: PlanRow, day: number, week: number) { + return ( + p.isEnabled && + !p.isOverlay && + (p.weekday === day || p.weekday === 0) && + week >= p.weekStart && + week <= p.weekEnd && + (p.weekCountDiv === 0 || ((week - 1) % p.weekCountDivTotal) + 1 === p.weekCountDiv) + ); +} +async function assertCourse(courseId: string, classId: string) { + if (!(await prisma.course.findFirst({ where: { id: courseId, classId } }))) + throw ApiError.badRequest('课程不属于本班'); +} +export async function createSchedule( + user: TokenPayload, + input: CreateScheduleInput, + transaction?: Tx, + identity?: string, +): Promise { + if (!transaction) { + await assertCanManageSchedule(user, input.classId); + await assertCourse(input.courseId, input.classId); + } + const cls = await (transaction ?? prisma).class.findUniqueOrThrow({ where: { id: input.classId } }), + max = transaction + ? normalizeMaxWeeks( + (await transaction.appSetting.findUnique({ where: { key: 'term.maxWeeks' } }))?.value, + ) + : await getMaxTermWeeks(); + const start = input.weekStart ?? 1, + end = input.weekEnd ?? Math.min(cls.termWeeks, max); + if (start > end || end > max) throw ApiError.badRequest('课表周次范围超过全校上限'); + const legacyId = identity || randomUUID(), + plans = transaction + ? await transaction.classPlan.findMany({ + where: { classId: input.classId }, + include: planInclude, + orderBy: { updatedAt: 'desc' }, + }) + : await loadPlans(input.classId); + let result: ScheduleDto | undefined; + const write = async (tx: Tx) => { + const key = 'schedule.compat.' + legacyId, + value = JSON.stringify({ weekStart: start, weekEnd: end, weekParity: input.weekParity ?? 'ALL' }); + await tx.appSetting.upsert({ where: { key }, create: { key, value }, update: { value } }); + for (let week = start; week <= end; week++) { + if (input.weekParity && input.weekParity !== 'ALL' && input.weekParity !== (week % 2 ? 'ODD' : 'EVEN')) + continue; + const source = + plans.find((p) => matches(p, input.dayOfWeek, week) && p.group?.isDefault) ?? + plans.find((p) => matches(p, input.dayOfWeek, week) && !p.associatedGroup) ?? + plans.find((p) => matches(p, input.dayOfWeek, week) && p.group?.isGlobal); + const privateData = source + ? await privateLayout(tx, source) + : { + layout: await tx.timeLayout.create({ + data: { + classId: input.classId, + name: `第${week}周星期${input.dayOfWeek}作息`, + source: 'manual', + }, + }), + mapping: new Map(), + }; + const target = await tx.classPlan.create({ + data: { + classId: input.classId, + name: `第${week}周星期${input.dayOfWeek}`, + timeLayoutId: privateData.layout.id, + associatedGroup: source?.associatedGroup, + weekday: input.dayOfWeek, + weekStart: week, + weekEnd: week, + }, + }); + if (source) + for (const e of source.entries) + await tx.classPlanEntry.create({ + data: { + classPlanId: target.id, + timeLayoutItemId: privateData.mapping.get(e.timeLayoutItemId)!, + courseId: e.courseId, + isEnabled: e.isEnabled, + isChangedClass: e.isChangedClass, + location: e.location, + legacyScheduleId: e.legacyScheduleId, + }, + }); + let point = await tx.timeLayoutItem.findFirst({ + where: { + timeLayoutId: privateData.layout.id, + startTime: input.startTime, + endTime: input.endTime, + timeType: 'LESSON', + }, + }); + if (!point) + point = await tx.timeLayoutItem.create({ + data: { + timeLayoutId: privateData.layout.id, + position: source?.layout.points.length || 0, + startTime: input.startTime, + endTime: input.endTime, + }, + }); + await tx.classPlanEntry.upsert({ + where: { classPlanId_timeLayoutItemId: { classPlanId: target.id, timeLayoutItemId: point.id } }, + create: { + classPlanId: target.id, + timeLayoutItemId: point.id, + courseId: input.courseId, + location: input.location, + legacyScheduleId: legacyId, + }, + update: { courseId: input.courseId, location: input.location, legacyScheduleId: legacyId }, + }); + const full = await tx.classPlan.findUniqueOrThrow({ where: { id: target.id }, include: planInclude }); + result = projectPlan(full).find((e) => e.id === legacyId); + } + }; + if (transaction) await write(transaction); + else await prisma.$transaction(write, { timeout: 30000 }); + if (!result) throw ApiError.badRequest('所选范围没有符合单双周的教学周'); + if (!transaction) await notifyScheduleChanged(input.classId); + return { ...result, weekStart: start, weekEnd: end, weekParity: input.weekParity ?? 'ALL' }; +} +export async function updateSchedule( + user: TokenPayload, + id: string, + input: UpdateScheduleInput, +): Promise { + const entries = await prisma.classPlanEntry.findMany({ + where: { OR: [{ id }, { legacyScheduleId: id }] }, + include: { plan: { include: planInclude } }, + }); + if (!entries.length) throw ApiError.notFound('课表记录不存在'); + const stored = await prisma.appSetting.findUnique({ where: { key: 'schedule.compat.' + id } }); + const rule: Partial<{ weekStart: number; weekEnd: number; weekParity: 'ALL' | 'ODD' | 'EVEN' }> = stored + ? JSON.parse(stored.value) + : {}; + const classId = entries[0]!.plan.classId; + await assertCanManageSchedule(user, classId); + if (input.courseId) await assertCourse(input.courseId, classId); + if ( + input.dayOfWeek !== undefined || + input.weekStart !== undefined || + input.weekEnd !== undefined || + input.weekParity !== undefined + ) { + const first = entries[0]!, + point = first.plan.layout.points.find((p) => p.id === first.timeLayoutItemId)!; + if (!first.courseId && !input.courseId) throw ApiError.badRequest('请选择科目'); + const weeks = entries.map((e) => e.plan.weekStart).sort((a, b) => a - b); + const parity = + weeks.length > 1 && weeks.every((w, i) => i === 0 || w - weeks[i - 1] === 2) + ? weeks[0]! % 2 + ? 'ODD' + : 'EVEN' + : 'ALL'; + const value: CreateScheduleInput = { + classId, + courseId: input.courseId || first.courseId!, + dayOfWeek: input.dayOfWeek ?? first.plan.weekday, + startTime: input.startTime ?? point.startTime, + endTime: input.endTime ?? point.endTime, + weekStart: input.weekStart ?? rule.weekStart ?? Math.min(...weeks), + weekEnd: input.weekEnd ?? rule.weekEnd ?? Math.max(...entries.map((e) => e.plan.weekEnd)), + weekParity: input.weekParity ?? rule.weekParity ?? parity, + location: input.location === undefined ? first.location : input.location, + }; + const result = await prisma.$transaction( + async (tx) => { + await tx.classPlanEntry.deleteMany({ where: { id: { in: entries.map((e) => e.id) } } }); + return createSchedule(user, value, tx, id); + }, + { timeout: 30000 }, + ); + await notifyScheduleChanged(classId); + return result; + } + const max = await getMaxTermWeeks(); + if ((input.weekEnd ?? 1) > max) throw ApiError.badRequest('周次超过全校上限'); + let result: ScheduleDto | undefined; + await prisma.$transaction( + async (tx) => { + for (const row of entries) { + const oldPoint = row.plan.layout.points.find((p) => p.id === row.timeLayoutItemId)!; + const startTime = input.startTime ?? oldPoint.startTime, + endTime = input.endTime ?? oldPoint.endTime; + if (startTime >= endTime) throw ApiError.badRequest('结束时间必须晚于开始时间'); + const copy = await privateLayout(tx, row.plan); + for (const e of row.plan.entries) + await tx.classPlanEntry.update({ + where: { id: e.id }, + data: { timeLayoutItemId: copy.mapping.get(e.timeLayoutItemId)! }, + }); + await tx.classPlan.update({ + where: { id: row.plan.id }, + data: { + timeLayoutId: copy.layout.id, + ...(input.dayOfWeek ? { weekday: input.dayOfWeek } : {}), + ...(input.weekStart ? { weekStart: input.weekStart } : {}), + ...(input.weekEnd ? { weekEnd: input.weekEnd } : {}), + ...(input.weekParity + ? { + weekCountDiv: input.weekParity === 'ALL' ? 0 : input.weekParity === 'ODD' ? 1 : 2, + weekCountDivTotal: 2, + } + : {}), + }, + }); + await tx.timeLayoutItem.update({ + where: { id: copy.mapping.get(row.timeLayoutItemId)! }, + data: { startTime, endTime }, + }); + await tx.classPlanEntry.update({ + where: { id: row.id }, + data: { + ...(input.courseId ? { courseId: input.courseId } : {}), + ...(input.location !== undefined ? { location: input.location } : {}), + }, + }); + result = projectPlan( + await tx.classPlan.findUniqueOrThrow({ where: { id: row.plan.id }, include: planInclude }), + ).find((e) => e.id === id); + } + }, + { timeout: 30000 }, + ); + await notifyScheduleChanged(classId); + if (!result) throw ApiError.notFound('课表记录不存在'); + return { ...result, ...rule }; +} +export async function deleteSchedule(user: TokenPayload, id: string) { + const entries = await prisma.classPlanEntry.findMany({ + where: { OR: [{ id }, { legacyScheduleId: id }] }, + select: { id: true, plan: { select: { classId: true } } }, + }); + if (!entries.length) throw ApiError.notFound('课表记录不存在'); + for (const classId of new Set(entries.map((e) => e.plan.classId))) + await assertCanManageSchedule(user, classId); + await prisma.classPlanEntry.deleteMany({ where: { id: { in: entries.map((e) => e.id) } } }); + for (const classId of new Set(entries.map((e) => e.plan.classId))) await notifyScheduleChanged(classId); +} diff --git a/packages/server/src/modules/schedules/schedules.module.ts b/packages/server/src/modules/schedules/schedules.module.ts index f3fe082..7de893e 100644 --- a/packages/server/src/modules/schedules/schedules.module.ts +++ b/packages/server/src/modules/schedules/schedules.module.ts @@ -1,4 +1,10 @@ -import { Router } from 'express'; +import { z } from 'zod'; +import { assertClassAccess, assertCanManageSchedule } from '../../lib/access.js'; +import * as plans from '../class-plans/class-plans.service.js'; +import { datedPlanSchema, swapSchema } from '../class-plans/class-plans.schemas.js'; +import { prisma } from '../../lib/db.js'; +import { ApiError } from '../../lib/http.js'; +import { Router } from 'express'; import { sendCreated, sendOk } from '../../lib/http.js'; import { idParamSchema } from '../../lib/schemas.js'; import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js'; @@ -17,6 +23,74 @@ import * as scheduleService from './schedules.service.js'; const router = Router(); router.use(authenticate()); +router.get('/active-week', validate({ query: gridQuerySchema }), async (req, res) => + sendOk(res, await scheduleService.getActiveScheduleWeek(getAuthUser(req), validatedQuery(req))), +); +router.get('/scheduled', validate({ query: z.object({ classId: z.string().min(1) }) }), async (req, res) => { + const { classId } = validatedQuery<{ classId: string }>(req); + await assertClassAccess(getAuthUser(req), classId); + sendOk( + res, + await prisma.scheduledClassPlan.findMany({ + where: { classId }, + include: { plan: { select: { name: true, isOverlay: true } } }, + orderBy: { date: 'asc' }, + }), + ); +}); +router.delete( + '/scheduled/:id', + requirePermission('schedule:write'), + validate({ params: idParamSchema }), + async (req, res) => { + const row = await prisma.scheduledClassPlan.findUnique({ + where: { id: String(req.params.id) }, + include: { plan: true }, + }); + if (!row) throw ApiError.notFound('预定不存在'); + await assertCanManageSchedule(getAuthUser(req), row.classId); + await prisma.$transaction(async (tx) => { + await tx.scheduledClassPlan.delete({ where: { id: row.id } }); + if (row.plan.isOverlay) + await tx.classPlan.update({ where: { id: row.classPlanId }, data: { isEnabled: false } }); + }); + await plans.notifyScheduleChanged(row.classId); + sendOk(res, { id: row.id }); + }, +); + +router.post( + '/scheduled', + requirePermission('schedule:write'), + validate({ body: datedPlanSchema }), + async (req, res) => sendCreated(res, await plans.schedulePlan(getAuthUser(req), req.body)), +); +router.post( + '/overlay', + requirePermission('schedule:write'), + validate({ body: datedPlanSchema }), + async (req, res) => { + const source = await plans.requirePlan(getAuthUser(req), req.body.classPlanId); + if (source.classId !== req.body.classId) throw ApiError.badRequest('课表不属于本班'); + sendCreated(res, await plans.createOverlay(getAuthUser(req), source.id, req.body.date)); + }, +); +router.post( + '/swap', + requirePermission('schedule:write'), + validate({ body: swapSchema }), + async (req, res) => { + const source = await plans.requirePlan(getAuthUser(req), req.body.classPlanId); + if (source.classId !== req.body.classId) throw ApiError.badRequest('课表不属于本班'); + sendCreated( + res, + await plans.createOverlay(getAuthUser(req), source.id, req.body.date, { + first: req.body.first, + second: req.body.second, + }), + ); + }, +); /** GET /api/schedules?classId=&week=&dayOfWeek= - 课表列表 */ router.get('/', validate({ query: listSchedulesQuerySchema }), async (req, res) => { diff --git a/packages/server/src/modules/schedules/schedules.service.ts b/packages/server/src/modules/schedules/schedules.service.ts index 0391ed0..e9eac0b 100644 --- a/packages/server/src/modules/schedules/schedules.service.ts +++ b/packages/server/src/modules/schedules/schedules.service.ts @@ -1,207 +1 @@ -import { - SOCKET_EVENTS, - buildScheduleWeekView, - resolveClassStatus, - weekParityOf, - type ClassStatusDto, - type ScheduleDto, - type ScheduleWeekView, -} from '@classhelper/shared'; -import { - assertClassAccess, - assertCanManageSchedule, - classScopeWhere, - isClassDevice, - requireClassDeviceClassId, - resolveClassScope, -} from '../../lib/access.js'; -import { prisma } from '../../lib/db.js'; -// 周次口径按**班级**取(管理员可以为每个班单独配开学日期与逐周区间) -import { loadTermContext, resolveCurrentWeek } from '../../lib/term.js'; -import { ApiError } from '../../lib/http.js'; -import type { TokenPayload } from '../../lib/jwt.js'; -import { toScheduleDto } from '../../lib/mappers.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 { - 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 } } : {}), - // 单双周:ALL 永远命中;ODD / EVEN 只在对应周次命中(第 1 周为单周) - ...(week && week > 0 ? { weekParity: { in: ['ALL', weekParityOf(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 { - // 当前周次按**本班**的口径算(管理员可以为每个班单独配开学日期与逐周区间) - const week = options.week ?? resolveCurrentWeek(new Date(), await loadTermContext(options.classId)); - const items = await listSchedules(user, { classId: options.classId, week }); - return buildScheduleWeekView(items, week); -} - -/** - * 计算指定班级在某时刻的上课状态(不做权限校验,供服务端内部复用)。 - * @param at 判定时刻,默认当前时间(接口层可传入用于诊断/测试) - */ -export async function computeClassStatus(classId: string, at?: Date): Promise { - const now = at ?? new Date(); - const week = resolveCurrentWeek(now, await loadTermContext(classId)); - const schedules = await prisma.schedule.findMany({ - where: { classId }, - include: { course: courseSelect }, - orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }], - }); - const status = resolveClassStatus(schedules.map(toScheduleDto), now, week); - - return { - classId, - inClass: status.inClass, - current: status.current, - next: status.next, - serverTime: now.toISOString(), - week, - }; -} - -/** - * 班级上课状态(带权限校验)。 - * - 学生:固定为自己所在班级 - * - 教师/管理员:需要显式 classId(前端发布通知时使用) - */ -export async function getClassStatus( - user: TokenPayload, - options: { classId?: string; at?: Date } = {}, -): Promise { - const classId = isClassDevice(user) ? requireClassDeviceClassId(user) : options.classId; - if (!classId) throw ApiError.badRequest('请指定班级(classId)'); - await assertClassAccess(user, classId); - return computeClassStatus(classId, options.at); -} - -export async function createSchedule(user: TokenPayload, input: CreateScheduleInput): Promise { - // 教学周数由班级设置决定(班主任可调,默认 20 周) - const classRecord = await prisma.class.findUnique({ - where: { id: input.classId }, - select: { termWeeks: true }, - }); - const termWeeks = classRecord?.termWeeks ?? 20; - await assertCanManageSchedule(user, input.classId); - await assertCourseInClass(input.courseId, input.classId); - - const weekStart = input.weekStart ?? 1; - const weekEnd = input.weekEnd ?? termWeeks; - if (weekStart > weekEnd) throw ApiError.badRequest('起始周次不能大于结束周次'); - - 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, - weekEnd, - // 单双周:默认 ALL(每周),与旧数据行为一致 - weekParity: input.weekParity ?? 'ALL', - }, - 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 { - const current = await prisma.schedule.findUnique({ where: { id: scheduleId } }); - if (!current) throw ApiError.notFound('课表记录不存在'); - await assertCanManageSchedule(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, - ...(input.weekParity !== undefined ? { weekParity: input.weekParity } : {}), - }, - 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 { - const current = await prisma.schedule.findUnique({ where: { id: scheduleId } }); - if (!current) throw ApiError.notFound('课表记录不存在'); - await assertCanManageSchedule(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 { - const course = await prisma.course.findUnique({ where: { id: courseId }, select: { classId: true } }); - if (!course) throw ApiError.badRequest('课程不存在'); - if (course.classId !== classId) throw ApiError.badRequest('该课程不属于所选班级'); -} +export * from './schedules.facade.js'; diff --git a/packages/server/src/modules/students/students.service.ts b/packages/server/src/modules/students/students.service.ts index cd4867f..29be19e 100644 --- a/packages/server/src/modules/students/students.service.ts +++ b/packages/server/src/modules/students/students.service.ts @@ -7,6 +7,7 @@ import { isAdmin, resolveClassScope, } from '../../lib/access.js'; +import { activeStudentWhere, isActiveStudent } from '../../lib/students.js'; import { prisma } from '../../lib/db.js'; import { ApiError } from '../../lib/http.js'; import type { TokenPayload } from '../../lib/jwt.js'; @@ -40,7 +41,8 @@ export async function listStudents(user: TokenPayload, options: ListStudentsQuer const students = await prisma.student.findMany({ where: { ...classScopeWhere(scope), - ...(options.status ? { status: options.status } : {}), + ...activeStudentWhere(), + ...(options.status ? { AND: [{ status: options.status }, activeStudentWhere()] } : {}), // 默认不显示已归档(毕业 / 转出)的学生:他们属于归档页,不该混在在读名单里 ...(options.includeArchived ? {} : { archivedYearId: null }), ...(options.keyword @@ -58,7 +60,11 @@ export async function listStudents(user: TokenPayload, options: ListStudentsQuer /** 按学号取学生(学生没账号,因此没有"按 id 取自己"的入口) */ export async function findStudentByNo(studentNo: string, classId?: string): Promise { const record = await prisma.student.findFirst({ - where: { studentNo: normalizeStudentNo(studentNo), ...(classId ? { classId } : {}) }, + where: { + ...activeStudentWhere(), + studentNo: normalizeStudentNo(studentNo), + ...(classId ? { classId } : {}), + }, include: withClass, }); return record ? toStudentDto(record) : null; @@ -98,7 +104,7 @@ export async function updateStudent( input: UpdateStudentInput, ): Promise { const student = await prisma.student.findUnique({ where: { id: studentId } }); - if (!student) throw ApiError.notFound('学生不存在'); + if (!student || !isActiveStudent(student)) throw ApiError.notFound('学生不存在'); await assertCanManageRoster(user, student.classId ?? undefined); if (input.studentNo && input.studentNo !== student.studentNo) { @@ -155,7 +161,9 @@ export async function transferStudents( ): Promise<{ moved: number; transfers: StudentTransferDto[] }> { if (!isAdmin(user)) throw ApiError.forbidden('只有管理员可以调班'); - const students = await prisma.student.findMany({ where: { id: { in: input.studentIds } } }); + const students = await prisma.student.findMany({ + where: { id: { in: input.studentIds }, ...activeStudentWhere() }, + }); if (students.length !== input.studentIds.length) { throw ApiError.badRequest('部分学生不存在,请刷新后重试'); } @@ -264,6 +272,7 @@ export async function listTransfers( } const where = { + student: activeStudentWhere(), ...(options.studentId ? { studentId: options.studentId } : {}), ...(options.classId ? { OR: [{ fromClassId: options.classId }, { toClassId: options.classId }] } : {}), }; diff --git a/packages/server/src/modules/system/autostart.ts b/packages/server/src/modules/system/autostart.ts new file mode 100644 index 0000000..bfcf1fd --- /dev/null +++ b/packages/server/src/modules/system/autostart.ts @@ -0,0 +1,77 @@ +import path from 'node:path'; +import type { AutostartStatusDto } from '@classhelper/shared'; +import { ApiError } from '../../lib/http.js'; +export interface AutostartHost { + platform: string; + root: string; + exists: (file: string) => boolean; + run: (file: string, args: string[]) => { ok: boolean; output: string }; +} +/** 可注入的操作系统适配器;验证只替换 run,不写真实注册表或调用 systemctl。 */ +export function createAutostartController(host: AutostartHost) { + const key = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run', + name = 'ClassHelperServer'; + const cmd = path.join(host.root, 'start.cmd'); + const unsupported = (detail: string): AutostartStatusDto => ({ + supported: false, + enabled: false, + mechanism: 'unsupported', + detail, + }); + function read(): AutostartStatusDto { + if (host.platform === 'win32') { + if (!host.exists(cmd)) + return unsupported('未检测到服务端安装目录的 start.cmd;开发环境不提供开机自启动切换'); + const install = host.run('reg.exe', ['query', 'HKCU\\Software\\ClassHelperServer', '/v', 'InstallDir']); + const installedRoot = install.output.match(/REG_SZ\s+(.+)$/m)?.[1]?.trim(); + if ( + !install.ok || + !installedRoot || + path.resolve(installedRoot).toLowerCase() !== path.resolve(host.root).toLowerCase() + ) + return unsupported('当前为开发或免安装目录;请使用服务端安装程序安装后设置开机自启动'); + const result = host.run('reg.exe', ['query', key, '/v', name]); + const value = result.output.match(/REG_SZ\s+(.+)$/m)?.[1]?.trim(); + return { + supported: true, + enabled: result.ok && value === `"${cmd}"`, + mechanism: 'run-key', + detail: `登录 Windows 后运行 ${cmd}(HKCU Run / ${name})`, + }; + } + if (host.platform === 'linux') { + if (!host.exists('/run/systemd/system') || !host.exists('/etc/systemd/system/classhelper.service')) + return unsupported( + '未检测到正在运行的 systemd 与 classhelper 服务单元;容器或免安装环境不提供开机自启动切换', + ); + const result = host.run('systemctl', ['is-enabled', 'classhelper']); + return { + supported: true, + enabled: result.ok && /^enabled(?:-runtime)?\s*$/.test(result.output), + mechanism: 'systemd', + detail: 'systemctl enable/disable classhelper', + }; + } + return unsupported(`当前平台 ${host.platform} 不支持切换开机自启动`); + } + function write(enabled: boolean): AutostartStatusDto { + const status = read(); + if (!status.supported) throw ApiError.badRequest(status.detail); + if (status.enabled === enabled) return status; + const result = + status.mechanism === 'run-key' + ? host.run( + 'reg.exe', + enabled + ? ['add', key, '/v', name, '/t', 'REG_SZ', '/d', `"${cmd}"`, '/f'] + : ['delete', key, '/v', name, '/f'], + ) + : host.run('systemctl', [enabled ? 'enable' : 'disable', 'classhelper']); + if (!result.ok) throw ApiError.badRequest(`无法修改开机自启动:${result.output}`); + const after = read(); + if (after.enabled !== enabled) + throw ApiError.badRequest('系统未保存所需的开机自启动状态,请检查账户权限与安装目录'); + return after; + } + return { read, write }; +} diff --git a/packages/server/src/modules/system/system.service.ts b/packages/server/src/modules/system/system.service.ts index 8688330..5b6db69 100644 --- a/packages/server/src/modules/system/system.service.ts +++ b/packages/server/src/modules/system/system.service.ts @@ -1,3 +1,4 @@ +import { createAutostartController } from './autostart.js'; import { spawnSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; @@ -9,7 +10,6 @@ import { } from '@classhelper/shared'; import { env } from '../../config/env.js'; import { prisma } from '../../lib/db.js'; -import { ApiError } from '../../lib/http.js'; import type { TokenPayload } from '../../lib/jwt.js'; import { logger } from '../../lib/logger.js'; import { SETTING_KEYS, getBooleanSetting, setBooleanSetting } from '../../lib/settings.js'; @@ -25,21 +25,12 @@ import type { UpdateAutostartInput, UpdateSystemSettingsInput } from './system.s * 1. 只在"装好的形态"里放行(开发机、免安装包、容器里返回 `supported:false`); * 2. 失败原因是运维性质的(权限不足、单元文件不存在),一律带原文回给管理员,不吞成 500。 */ -const RUN_KEY_PATH = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run'; -const RUN_KEY_NAME = 'ClassHelperServer'; -const SYSTEMD_UNIT = 'classhelper'; - function runCommand(file: string, args: string[]): { ok: boolean; output: string } { const result = spawnSync(file, args, { encoding: 'utf8', windowsHide: true }); const output = `${result.stdout ?? ''}${result.stderr ?? ''}`.trim(); return { ok: result.status === 0, output: output || (result.error ? result.error.message : '') }; } -/** 安装器写在安装目录里的启动脚本:它存在才算"装好的形态" */ -function startCmdPath(): string { - return path.join(path.dirname(process.execPath), 'start.cmd'); -} - export async function getSystemSettings(): Promise { return { developerMode: await getBooleanSetting(SETTING_KEYS.developerMode, false) }; } @@ -55,74 +46,17 @@ export async function updateSystemSettings( return getSystemSettings(); } +const autostart = createAutostartController({ + platform: process.platform, + root: path.resolve(env.serverRoot, '..'), + exists: fs.existsSync, + run: runCommand, +}); export async function getAutostartStatus(): Promise { - if (process.platform === 'win32') { - const cmd = startCmdPath(); - if (!fs.existsSync(cmd)) { - return { - supported: false, - enabled: false, - mechanism: 'unsupported', - detail: '未检测到安装形态(缺少 start.cmd):开发环境或免安装目录不提供开机自启切换', - }; - } - const query = runCommand('reg.exe', ['query', RUN_KEY_PATH, '/v', RUN_KEY_NAME]); - return { - supported: true, - enabled: query.ok, - mechanism: 'run-key', - detail: `注册表 HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run\\${RUN_KEY_NAME} → "${cmd}"`, - }; - } - - if (process.platform === 'linux') { - const unit = `/etc/systemd/system/${SYSTEMD_UNIT}.service`; - if (!fs.existsSync(unit)) { - return { - supported: false, - enabled: false, - mechanism: 'unsupported', - detail: `未找到 ${unit}(不是 systemd 安装形态)`, - }; - } - const query = runCommand('systemctl', ['is-enabled', SYSTEMD_UNIT]); - return { - supported: true, - enabled: query.ok, - mechanism: 'systemd', - detail: `systemctl is-enabled ${SYSTEMD_UNIT}`, - }; - } - - return { - supported: false, - enabled: false, - mechanism: 'unsupported', - detail: `当前平台(${process.platform})不支持切换开机自启`, - }; + return autostart.read(); } - export async function updateAutostart(input: UpdateAutostartInput): Promise { - const current = await getAutostartStatus(); - if (!current.supported) throw ApiError.badRequest(current.detail); - - if (current.mechanism === 'run-key') { - const cmd = startCmdPath(); - const result = input.enabled - ? runCommand('reg.exe', ['add', RUN_KEY_PATH, '/v', RUN_KEY_NAME, '/t', 'REG_SZ', '/d', `"${cmd}"`, '/f']) - : runCommand('reg.exe', ['delete', RUN_KEY_PATH, '/v', RUN_KEY_NAME, '/f']); - if (!result.ok) throw ApiError.internal(`写入开机自启项失败:${result.output}`); - } else { - const result = runCommand('systemctl', [input.enabled ? 'enable' : 'disable', SYSTEMD_UNIT]); - if (!result.ok) { - throw ApiError.badRequest( - `systemctl ${input.enabled ? 'enable' : 'disable'} ${SYSTEMD_UNIT} 失败:${result.output}(服务是否以特权用户运行?)`, - ); - } - } - - logger.info(`服务端开机自启已${input.enabled ? '开启' : '关闭'}(机制=${current.mechanism})`); - return getAutostartStatus(); + return autostart.write(input.enabled); } export async function getDiagnostics(): Promise { diff --git a/packages/server/src/modules/term/promotion.service.ts b/packages/server/src/modules/term/promotion.service.ts new file mode 100644 index 0000000..722cae4 --- /dev/null +++ b/packages/server/src/modules/term/promotion.service.ts @@ -0,0 +1,184 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { z } from 'zod'; +import { dayKeyLocal, formatClassName } from '@classhelper/shared'; +import { prisma } from '../../lib/db.js'; +import { env } from '../../config/env.js'; +import { ApiError } from '../../lib/http.js'; +import { exportSnapshot } from '../../lib/snapshot.js'; +import { getSetting, setSetting, SETTING_KEYS } from '../../lib/settings.js'; +import { getMaxTermWeeks } from '../../lib/term.js'; +import { hashPassword } from '../../lib/password.js'; +import { logger } from '../../lib/logger.js'; +import { notifyScheduleChanged } from '../class-plans/class-plans.service.js'; + +export const promotionConfigSchema = z.object({ + month: z.number().int().min(1).max(12), + day: z.number().int().min(1).max(31), + hour: z.number().int().min(0).max(23), + classCount: z.number().int().min(1).max(99).default(1), +}); +export async function promotionStatus(now = new Date()) { + const raw = await getSetting(SETTING_KEYS.promotionAt); + let config = { month: 8, day: 1, hour: 0, classCount: 1 }; + if (raw) { + const parsed = promotionConfigSchema.safeParse(JSON.parse(raw)); + if (parsed.success) config = parsed.data; + } + const due = new Date( + now.getFullYear(), + config.month - 1, + Math.min(config.day, new Date(now.getFullYear(), config.month, 0).getDate()), + config.hour, + ); + const year = now < due ? now.getFullYear() - 1 : now.getFullYear(); + const run = await prisma.promotionRun.findUnique({ where: { year } }); + return { + config, + year, + pending: !run || run.status !== 'done', + runs: await prisma.promotionRun.findMany({ orderBy: { year: 'desc' }, take: 20 }), + today: dayKeyLocal(now), + }; +} +export async function configurePromotion(input: z.infer) { + await setSetting(SETTING_KEYS.promotionAt, JSON.stringify(input)); + return promotionStatus(); +} + +/** 一年一个事务幂等键;快照失败不得升级。启动遗漏只提示补跑,不擅自改演示库。 */ +export async function runPromotion(year: number, dryRun = false) { + const existing = await prisma.promotionRun.findUnique({ where: { year } }); + if (existing?.status === 'done') return existing; + const classes = await prisma.class.findMany({ + where: { archivedYearId: null, grade: { in: ['高一', '高二', '高三'] } }, + }); + const classIds = classes.map((c) => c.id); + const studentCount = await prisma.student.count({ where: { classId: { in: classIds }, status: 'active' } }); + if (dryRun) + return { year, classCount: classes.length, studentCount, status: 'dry-run', snapshotRequired: true }; + const dir = path.resolve( + env.sqliteFilePath ? path.dirname(env.sqliteFilePath) : path.resolve(env.serverRoot, '..', 'data'), + 'promotion-snapshots', + ); + await fs.mkdir(dir, { recursive: true }); + const snapshotPath = path.join(dir, `${year}-${Date.now()}.json`); + await fs.writeFile(snapshotPath, JSON.stringify(await exportSnapshot(prisma, env.databaseProvider)), { + flag: 'wx', + }); + const { config } = await promotionStatus(); + const passwordHash = await hashPassword(env.defaultClassPassword); + const maxWeeks = await getMaxTermWeeks(); + try { + await prisma.$transaction( + async (tx) => { + // 唯一键在事务里争抢,重入/多进程都只能有一个提交成功。 + if (existing) await tx.promotionRun.deleteMany({ where: { year, status: { not: 'done' } } }); + await tx.promotionRun.create({ + data: { year, status: 'running', snapshotPath, classCount: classes.length, studentCount }, + }); + const graduating = classes.filter((c) => c.grade === '高三'); + const cohorts = [...new Set(graduating.map((c) => c.enrollmentYear ?? year - 3))]; + for (const enrollmentYear of cohorts) { + const ids = graduating + .filter((c) => (c.enrollmentYear ?? year - 3) === enrollmentYear) + .map((c) => c.id); + const archived = await tx.archivedYear.create({ + data: { + enrollmentYear, + graduationYear: year, + name: `${enrollmentYear} 级`, + operatorName: '学年自动升级', + classCount: ids.length, + studentCount: await tx.student.count({ where: { classId: { in: ids }, status: 'active' } }), + }, + }); + await tx.class.updateMany({ + where: { id: { in: ids } }, + data: { archivedYearId: archived.id, archivedAt: new Date() }, + }); + await tx.student.updateMany({ + where: { classId: { in: ids }, status: 'active' }, + data: { status: 'graduated', archivedYearId: archived.id, archivedAt: new Date() }, + }); + } + for (const cls of classes.filter((c) => c.grade !== '高三')) + await tx.class.update({ + where: { id: cls.id }, + data: { + grade: cls.grade === '高一' ? '高二' : '高三', + termStartDate: null, + termWeeks: Math.min(cls.termWeeks, maxWeeks), + }, + }); + for (let i = 1; i <= config.classCount; i++) { + if ( + await tx.class.findFirst({ where: { enrollmentYear: year, classIndex: i, archivedYearId: null } }) + ) + continue; + await tx.class.create({ + data: { + name: formatClassName(year, i), + enrollmentYear: year, + classIndex: i, + grade: '高一', + teacherId: null, + code: `N${year}${String(i).padStart(2, '0')}`, + passwordHash, + termWeeks: Math.min(20, maxWeeks), + }, + }); + } + await tx.termWeek.deleteMany(); + await tx.appSetting.upsert({ + where: { key: SETTING_KEYS.termStartDate }, + create: { key: SETTING_KEYS.termStartDate, value: `${year}-09-01` }, + update: { value: `${year}-09-01` }, + }); + await tx.promotionRun.update({ + where: { year }, + data: { + status: 'done', + message: '已升级、归档并建立无班主任/任课关系的新班骨架', + finishedAt: new Date(), + }, + }); + }, + { timeout: 30000 }, + ); + } catch (error) { + if ((await prisma.promotionRun.findUnique({ where: { year } }))?.status === 'done') + return prisma.promotionRun.findUniqueOrThrow({ where: { year } }); + await prisma.promotionRun.upsert({ + where: { year }, + create: { year, status: 'failed', snapshotPath, message: String(error) }, + update: { status: 'failed', message: String(error), snapshotPath }, + }); + throw ApiError.internal(`学年升级失败,事务已回滚:${String(error)}`); + } + await notifyScheduleChanged(); + return prisma.promotionRun.findUniqueOrThrow({ where: { year } }); +} + +let timer: ReturnType | null = null; +export async function startPromotionScheduler() { + if (timer) return; + let observed = new Date(); + if ((await promotionStatus(observed)).pending) + logger.warn('有遗漏的学年升级,请管理员在学期周次页查看预览与快照说明后补跑'); + timer = setInterval(() => { + void (async () => { + const now = new Date(); + const { config } = await promotionStatus(now); + const due = new Date( + now.getFullYear(), + config.month - 1, + Math.min(config.day, new Date(now.getFullYear(), config.month, 0).getDate()), + config.hour, + ); + if (observed < due && now >= due) await runPromotion(now.getFullYear()); + observed = now; + })().catch((e) => logger.error(`自动升级失败:${String(e)}`)); + }, 30_000); + timer.unref(); +} diff --git a/packages/server/src/modules/term/term.module.ts b/packages/server/src/modules/term/term.module.ts index 1b81be7..7f5318c 100644 --- a/packages/server/src/modules/term/term.module.ts +++ b/packages/server/src/modules/term/term.module.ts @@ -1,3 +1,10 @@ +import { z } from 'zod'; +import { + promotionStatus, + promotionConfigSchema, + configurePromotion, + runPromotion, +} from './promotion.service.js'; import { Router } from 'express'; import { sendOk } from '../../lib/http.js'; import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js'; @@ -15,6 +22,23 @@ import * as termService from './term.service.js'; const router = Router(); router.use(authenticate()); +router.get('/promotion', requirePermission('system:manage'), async (_req, res) => + sendOk(res, await promotionStatus()), +); +router.put( + '/promotion', + requirePermission('system:manage'), + validate({ body: promotionConfigSchema }), + async (req, res) => sendOk(res, await configurePromotion(req.body)), +); +router.post( + '/promotion/run', + requirePermission('system:manage'), + validate({ + body: z.object({ year: z.number().int().min(2000).max(2100), dryRun: z.boolean().default(true) }), + }), + async (req, res) => sendOk(res, await runPromotion(req.body.year, req.body.dryRun)), +); /** * 学期周次。 diff --git a/packages/server/src/modules/term/term.schemas.ts b/packages/server/src/modules/term/term.schemas.ts index c32eb18..9983329 100644 --- a/packages/server/src/modules/term/term.schemas.ts +++ b/packages/server/src/modules/term/term.schemas.ts @@ -17,6 +17,7 @@ export const termQuerySchema = z.object({ }); export const updateTermWeeksSchema = z.object({ + maxTermWeeks: z.coerce.number().int().min(1).max(60).optional(), classId: z.string().min(1).nullish(), termStartDate: termStartDateSchema.optional(), /** 整表覆盖:传空数组 = 清空逐周配置,回到按开学日期线性推算 */ diff --git a/packages/server/src/modules/term/term.service.ts b/packages/server/src/modules/term/term.service.ts index a8c1ad7..e119357 100644 --- a/packages/server/src/modules/term/term.service.ts +++ b/packages/server/src/modules/term/term.service.ts @@ -10,7 +10,7 @@ import { prisma } from '../../lib/db.js'; import { ApiError } from '../../lib/http.js'; import type { TokenPayload } from '../../lib/jwt.js'; import { logger } from '../../lib/logger.js'; -import { MAX_TERM_WEEK, loadTermContext, resolveCurrentWeek } from '../../lib/term.js'; +import { getMaxTermWeeks, loadTermContext, resolveCurrentWeek } from '../../lib/term.js'; import { SETTING_KEYS, setSetting } from '../../lib/settings.js'; import { emitToClass } from '../../realtime/bus.js'; import { SOCKET_EVENTS } from '@classhelper/shared'; @@ -40,7 +40,7 @@ export async function getTermWeeks(user: TokenPayload, classId?: string | null): const scope = scopeOf(classId); if (scope) await assertClassAccess(user, scope); - const [weeks, cls] = await Promise.all([ + const [weeks] = await Promise.all([ prisma.termWeek.findMany({ where: { classId: scope }, orderBy: { weekNumber: 'asc' } }), scope ? prisma.class.findUnique({ where: { id: scope }, select: { termStartDate: true, termWeeks: true } }) @@ -52,7 +52,8 @@ export async function getTermWeeks(user: TokenPayload, classId?: string | null): classId: scope, termStartDate: context.termStartDate || env.termStartDate, currentWeek: resolveCurrentWeek(new Date(), context), - maxWeek: cls?.termWeeks && cls.termWeeks > 0 ? cls.termWeeks : MAX_TERM_WEEK, + maxWeek: context.maxWeek, + maxTermWeeks: await getMaxTermWeeks(), configured: weeks.length > 0, weeks: weeks.map((item) => ({ weekNumber: item.weekNumber, @@ -76,6 +77,13 @@ export async function updateTermWeeks( assertCanManageClasses(user); const scope = scopeOf(input.classId); + const maximum = input.maxTermWeeks ?? (await getMaxTermWeeks()); + if (input.maxTermWeeks !== undefined && scope) throw ApiError.badRequest('总周数只能在全校默认范围修改'); + if (input.weeks.some((w) => w.weekNumber > maximum)) throw ApiError.badRequest('教学周次超过全校总周数'); + if (input.maxTermWeeks !== undefined) { + const tooLong = await prisma.class.count({ where: { archivedYearId: null, termWeeks: { gt: maximum } } }); + if (tooLong) throw ApiError.badRequest('请先缩短超过新上限的班级教学周数'); + } const errors = validateTermWeeks(input.weeks); if (errors.length > 0) throw ApiError.badRequest(errors[0]!, { errors }); @@ -121,6 +129,7 @@ export async function updateTermWeeks( } logger.info(`学期周次已更新:范围=${scope || '全校默认'} 周数=${input.weeks.length} 由 ${user.name} 执行`); + if (input.maxTermWeeks !== undefined) await setSetting(SETTING_KEYS.termMaxWeeks, String(maximum)); await publishTermChange(scope); return getTermWeeks(user, scope || null); } diff --git a/packages/server/src/modules/update/client-releases.service.ts b/packages/server/src/modules/update/client-releases.service.ts index 09cae95..42b865f 100644 --- a/packages/server/src/modules/update/client-releases.service.ts +++ b/packages/server/src/modules/update/client-releases.service.ts @@ -1,3 +1,5 @@ +import { getReleaseProgress, setReleaseProgress } from './release-progress.js'; +import { Transform } from 'node:stream'; import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; @@ -81,9 +83,7 @@ async function sha256Of(file: string): Promise { /** GET /api/update/client/releases - 已缓存的安装包(按版本从新到旧) */ export async function listReleases(): Promise { const rows = (await prisma.clientRelease.findMany()) as unknown as ReleaseRow[]; - return rows - .sort((a, b) => compareVersions(b.version, a.version)) - .map((row) => toReleaseDto(row)); + return rows.sort((a, b) => compareVersions(b.version, a.version)).map((row) => toReleaseDto(row)); } /** 服务端缓存的**最新**版本(推送更新时省略 version 就用它) */ @@ -116,7 +116,9 @@ export async function resolveUpdateInfo(version: string): Promise { +export async function resolveReleaseFile( + version: string, +): Promise<{ file: string; fileName: string } | null> { const row = (await prisma.clientRelease.findUnique({ where: { version } })) as ReleaseRow | null; if (!row) return null; const file = releaseFilePath(row.version, row.fileName); @@ -161,7 +163,9 @@ export async function registerRelease( }, })) as ReleaseRow; - logger.info(`登记客户端安装包 v${version}(${fileName},${stat.size} 字节,sha256=${sha256.slice(0, 12)}…)`); + logger.info( + `登记客户端安装包 v${version}(${fileName},${stat.size} 字节,sha256=${sha256.slice(0, 12)}…)`, + ); return toReleaseDto(row); } @@ -170,7 +174,10 @@ export async function fetchReleaseFromGithub( userId: string, input: FetchClientReleaseInput, ): Promise { + if (['downloading', 'verifying'].includes(getReleaseProgress().stage)) + throw ApiError.conflict('已有安装包正在下载'); const version = input.version; + setReleaseProgress({ version, stage: 'downloading', downloadedBytes: 0, totalBytes: 0, error: '' }); const fileName = clientReleaseFileName(version); const url = clientReleaseDownloadUrl(version); const target = releaseFilePath(version, fileName); @@ -199,14 +206,24 @@ export async function fetchReleaseFromGithub( } try { - await pipeline(Readable.fromWeb(response.body as never), fs.createWriteStream(target)); + setReleaseProgress({ totalBytes: Number(response.headers.get('content-length')) || 0 }); + const counter = new Transform({ + transform(chunk, _encoding, done) { + setReleaseProgress({ downloadedBytes: getReleaseProgress().downloadedBytes + chunk.length }); + done(null, chunk); + }, + }); + await pipeline(Readable.fromWeb(response.body as never), counter, fs.createWriteStream(target)); } catch (error) { fs.rmSync(target, { force: true }); throw ApiError.badRequest(`写入安装包失败:${error instanceof Error ? error.message : String(error)}`); } // 落盘后统一走登记:sha256 / 大小 / 落库都在同一处算 - return registerRelease(userId, { version, fileName }); + setReleaseProgress({ stage: 'verifying' }); + const release = await registerRelease(userId, { version, fileName }); + setReleaseProgress({ stage: 'ready', downloadedBytes: release.size, totalBytes: release.size }); + return release; } /** 删除一份缓存的安装包(元数据 + 磁盘文件) */ diff --git a/packages/server/src/modules/update/release-progress.ts b/packages/server/src/modules/update/release-progress.ts new file mode 100644 index 0000000..970d0c5 --- /dev/null +++ b/packages/server/src/modules/update/release-progress.ts @@ -0,0 +1,27 @@ +import { SOCKET_EVENTS, SOCKET_ROOMS, type ReleaseProgressDto } from '@classhelper/shared'; +import { emitToRooms } from '../../realtime/bus.js'; + +// 下载状态只属于本次进程;重启回到 idle,不会残留无法完成的 downloading。 +let progress: ReleaseProgressDto = { + version: '', + stage: 'idle', + downloadedBytes: 0, + totalBytes: 0, + percent: null, + error: '', +}; +let lastEmit = 0; +export function getReleaseProgress() { + return { ...progress }; +} +export function setReleaseProgress(patch: Partial) { + const stageChanged = patch.stage && patch.stage !== progress.stage; + progress = { ...progress, ...patch }; + progress.percent = progress.totalBytes + ? Math.min(100, Math.round((progress.downloadedBytes / progress.totalBytes) * 100)) + : null; + if (stageChanged || Date.now() - lastEmit >= 250) { + emitToRooms([SOCKET_ROOMS.role('ADMIN')], SOCKET_EVENTS.clientReleaseProgress, getReleaseProgress()); + lastEmit = Date.now(); + } +} diff --git a/packages/server/src/modules/update/update.module.ts b/packages/server/src/modules/update/update.module.ts index c127a08..c2b9f1e 100644 --- a/packages/server/src/modules/update/update.module.ts +++ b/packages/server/src/modules/update/update.module.ts @@ -1,3 +1,4 @@ +import { getReleaseProgress, setReleaseProgress } from './release-progress.js'; import { Router } from 'express'; import { sendCreated, sendOk, ApiError } from '../../lib/http.js'; import { @@ -22,6 +23,9 @@ import { const router = Router(); router.use(authenticate()); +router.get('/client/progress', requirePermission('client:update'), async (_req, res) => + sendOk(res, getReleaseProgress()), +); /** * GET /api/update/check?force=1 - 检查 GitHub 上有没有比本机新的版本 @@ -64,12 +68,24 @@ router.get( */ router.post( '/client/fetch', - requirePermission('client:update-push'), + requirePermission('client:update'), validate({ body: fetchClientReleaseSchema }), async (req, res) => { const user = getAuthUser(req); const input = validatedBody(req); - sendCreated(res, await clientReleases.fetchReleaseFromGithub(user.sub, input), '安装包已下载并登记'); + try { + sendCreated(res, await clientReleases.fetchReleaseFromGithub(user.sub, input), '安装包已下载并登记'); + } catch (error) { + if ( + !(error instanceof ApiError && error.status === 409) && + getReleaseProgress().version === input.version + ) + setReleaseProgress({ + stage: 'failed', + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } }, ); @@ -81,7 +97,7 @@ router.post( */ router.post( '/client/releases', - requirePermission('client:update-push'), + requirePermission('client:update'), validate({ body: registerClientReleaseSchema }), async (req, res) => { const user = getAuthUser(req); @@ -93,7 +109,7 @@ router.post( /** DELETE /api/update/client/releases/:version - 删除缓存(元数据 + 磁盘文件) */ router.delete( '/client/releases/:version', - requirePermission('client:update-push'), + requirePermission('client:update'), validate({ params: clientReleaseVersionParamSchema }), async (req, res) => { const { version } = validatedParams<{ version: string }>(req);