feat(server): 统一课表解析并完善权限范围与年度升级

This commit is contained in:
LoveChengke committed 2026-10-17 09:34:23 +08:00
1 parent 66ef878a71
commit 4dc824c283
54 files changed
+2392 -616

No files matched your search

+21 -13
View File
@@ -24,7 +24,10 @@ import {
import { disconnectPrisma, prisma } from '../src/lib/db.js'; import { disconnectPrisma, prisma } from '../src/lib/db.js';
import { logger } from '../src/lib/logger.js'; import { logger } from '../src/lib/logger.js';
import { hashPassword } from '../src/lib/password.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 = [ const STUDENT_NAMES = [
'王小明', '王小明',
@@ -213,7 +216,7 @@ async function main(): Promise<void> {
data: { data: {
name: courseName, name: courseName,
classId: targetClass.id, 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 }); created.push({ id: course.id, name: course.name });
@@ -316,7 +319,7 @@ async function main(): Promise<void> {
assignDate: dayKeyLocal(new Date()), assignDate: dayKeyLocal(new Date()),
attachmentUrl: attachmentUrl:
seed.courseIndex % 2 === 0 ? 'https://example.com/classhelper/homework-sample.pdf' : null, seed.courseIndex % 2 === 0 ? 'https://example.com/classhelper/homework-sample.pdf' : null,
createdBy: targetClass.teacherId, createdBy: targetClass.teacherId!,
}, },
}); });
homeworks.push(homework); homeworks.push(homework);
@@ -379,7 +382,7 @@ async function main(): Promise<void> {
title: seed.title, title: seed.title,
content: seed.content, content: seed.content,
priority: seed.priority, priority: seed.priority,
createdBy: targetClass.teacherId, createdBy: targetClass.teacherId!,
createdAt: new Date(now - seed.daysAgo * day), createdAt: new Date(now - seed.daysAgo * day),
}, },
}); });
@@ -405,11 +408,7 @@ async function main(): Promise<void> {
const examNames = ['第一次月考', '期中考试']; const examNames = ['第一次月考', '期中考试'];
const customLevels = ['优', '良', '合格'] as const; const customLevels = ['优', '良', '合格'] as const;
const letterLevels = ['A', 'B', 'C', 'D'] as const; const letterLevels = ['A', 'B', 'C', 'D'] as const;
const levelPlan = [ const levelPlan = [{ type: 'percent' as const }, { type: 'custom' as const }, { type: 'letter' as const }];
{ type: 'percent' as const },
{ type: 'custom' as const },
{ type: 'letter' as const },
];
for (const targetClass of classes) { for (const targetClass of classes) {
const classCourses = coursesByClass.get(targetClass.id) ?? []; const classCourses = coursesByClass.get(targetClass.id) ?? [];
@@ -426,7 +425,8 @@ async function main(): Promise<void> {
? gradeLevel(percent) ? gradeLevel(percent)
: plan.type === 'letter' : plan.type === 'letter'
? (letterLevels[Math.min(letterLevels.length - 1, Math.floor((100 - percent) / 12))] ?? 'D') ? (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({ await prisma.grade.create({
data: { data: {
classId: targetClass.id, classId: targetClass.id,
@@ -579,12 +579,20 @@ async function main(): Promise<void> {
console.log('\n================ 演示账号 ================'); console.log('\n================ 演示账号 ================');
console.log(`管理员 admin / admin123 (${admin.name} · 可执行毕业归档与调班)`); console.log(`管理员 admin / admin123 (${admin.name} · 可执行毕业归档与调班)`);
console.log(`教师 teacher1 / teacher123 (${teacher1.name} · ${classes[0]!.name} 班主任,教数学)`); console.log(
console.log(`教师 teacher2 / teacher123 (${teacher2.name} · ${classes[1]!.name} 班主任,教 ${classes[0]!.name} 语文)`); `教师 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('ClassHelper 班级端(班级码):G101 / G102 / G203,密码 123456');
console.log('(学生只有名单、没有账号:成绩/未交/叫人按学号记录)'); console.log('(学生只有名单、没有账号:成绩/未交/叫人按学号记录)');
console.log(`已在读班级:${classes.map((item) => item.name).join(' / ')}`); 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'); console.log('==========================================\n');
} }
+2
View File
@@ -11,6 +11,7 @@ import { backfillNotificationDates } from './lib/backfill-notification-dates.js'
import { refreshDeveloperMode } from './lib/settings.js'; import { refreshDeveloperMode } from './lib/settings.js';
import { resolveWebDistDir } from './lib/web-static.js'; import { resolveWebDistDir } from './lib/web-static.js';
import { initRealtime } from './realtime/socket.js'; import { initRealtime } from './realtime/socket.js';
import { startPromotionScheduler } from './modules/term/promotion.service.js';
import { startBackupScheduler } from './modules/database/database.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`); logger.info(`WebSocket:ws://${displayHost}:${env.port}/socket.io`);
// 数据库管理模块的定时备份调度(配置与"到点时间"持久化在 data/database-settings.json) // 数据库管理模块的定时备份调度(配置与"到点时间"持久化在 data/database-settings.json)
startBackupScheduler(); startBackupScheduler();
void startPromotionScheduler().catch((e) => logger.error(`学年升级调度启动失败:${String(e)}`));
}); });
let shuttingDown = false; let shuttingDown = false;
+19 -11
View File
@@ -1,3 +1,4 @@
import type { PermissionKey as ContractPermissionKey } from '@classhelper/shared';
import { import {
canAssignTeachers, canAssignTeachers,
canManageClasses, canManageClasses,
@@ -9,6 +10,7 @@ import {
resolveClassRole, resolveClassRole,
type ClassRole, type ClassRole,
} from '@classhelper/shared'; } from '@classhelper/shared';
import { teacherScopeWhere } from './permission-scope.js';
import { prisma } from './db.js'; import { prisma } from './db.js';
import { ApiError } from './http.js'; import { ApiError } from './http.js';
import type { TokenPayload } from './jwt.js'; import type { TokenPayload } from './jwt.js';
@@ -64,14 +66,11 @@ export function classDeviceClassId(user: TokenPayload): string | null {
* 这里就是「科任老师」的判定源 —— 以前查的是没有科目的 `ClassTeacher` 协作表, * 这里就是「科任老师」的判定源 —— 以前查的是没有科目的 `ClassTeacher` 协作表,
* 现在查 `Course`,于是"能不能进这个班"与"能不能改这一科的作业成绩"用的是同一份事实。 * 现在查 `Course`,于是"能不能进这个班"与"能不能改这一科的作业成绩"用的是同一份事实。
*/ */
function classMembershipWhere(teacherId: string) {
return {
OR: [{ teacherId }, { courses: { some: { teacherId } } }],
};
}
/** 当前用户可访问的全部班级 id */ /** 当前用户可访问的全部班级 id */
export async function getAccessibleClassIds(user: TokenPayload): Promise<string[]> { export async function getAccessibleClassIds(
user: TokenPayload,
permission?: ContractPermissionKey,
): Promise<string[]> {
if (isAdmin(user)) { if (isAdmin(user)) {
const classes = await prisma.class.findMany({ select: { id: true } }); const classes = await prisma.class.findMany({ select: { id: true } });
return classes.map((item) => item.id); return classes.map((item) => item.id);
@@ -79,7 +78,7 @@ export async function getAccessibleClassIds(user: TokenPayload): Promise<string[
if (isTeacher(user)) { if (isTeacher(user)) {
const classes = await prisma.class.findMany({ const classes = await prisma.class.findMany({
where: classMembershipWhere(user.sub), where: await teacherScopeWhere(user, permission),
select: { id: true }, select: { id: true },
}); });
return classes.map((item) => item.id); return classes.map((item) => item.id);
@@ -92,7 +91,9 @@ export async function getAccessibleClassIds(user: TokenPayload): Promise<string[
export async function canAccessClass(user: TokenPayload, classId: string): Promise<boolean> { export async function canAccessClass(user: TokenPayload, classId: string): Promise<boolean> {
if (isAdmin(user)) return true; if (isAdmin(user)) return true;
if (isTeacher(user)) { 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 count > 0;
} }
return classDeviceClassId(user) === classId; return classDeviceClassId(user) === classId;
@@ -157,6 +158,7 @@ export async function assertCanManageRoster(user: TokenPayload, classId?: string
if (!isAdmin(user)) throw ApiError.forbidden('该操作需要指定班级,只有管理员可以全局执行'); if (!isAdmin(user)) throw ApiError.forbidden('该操作需要指定班级,只有管理员可以全局执行');
return; return;
} }
await assertClassAccess(user, classId);
const classRole = await resolveUserClassRole(user, classId); const classRole = await resolveUserClassRole(user, classId);
if (!canManageRoster(classRole)) { if (!canManageRoster(classRole)) {
throw ApiError.forbidden( throw ApiError.forbidden(
@@ -169,6 +171,7 @@ export async function assertCanManageRoster(user: TokenPayload, classId?: string
/** 课表管理(含时间配置导入):管理员或本班班主任 */ /** 课表管理(含时间配置导入):管理员或本班班主任 */
export async function assertCanManageSchedule(user: TokenPayload, classId: string): Promise<void> { export async function assertCanManageSchedule(user: TokenPayload, classId: string): Promise<void> {
await assertClassAccess(user, classId);
const classRole = await resolveUserClassRole(user, classId); const classRole = await resolveUserClassRole(user, classId);
if (!canManageSchedule(classRole)) { if (!canManageSchedule(classRole)) {
throw ApiError.forbidden( throw ApiError.forbidden(
@@ -201,6 +204,7 @@ export async function assertCanManageGrades(user: TokenPayload, classId?: string
return; return;
} }
await assertClassAccess(user, classId);
const classRole = await resolveUserClassRole(user, classId); const classRole = await resolveUserClassRole(user, classId);
if (canManageGrades(classRole)) return; if (canManageGrades(classRole)) return;
@@ -288,14 +292,18 @@ export function requireClassDeviceClassId(user: TokenPayload): string {
*/ */
export type ClassScope = { mode: 'all' } | { mode: 'in'; classIds: string[] }; export type ClassScope = { mode: 'all' } | { mode: 'in'; classIds: string[] };
export async function resolveClassScope(user: TokenPayload, requestedClassId?: string): Promise<ClassScope> { export async function resolveClassScope(
user: TokenPayload,
requestedClassId?: string,
permission?: ContractPermissionKey,
): Promise<ClassScope> {
if (requestedClassId) { if (requestedClassId) {
await assertClassAccess(user, requestedClassId); await assertClassAccess(user, requestedClassId);
return { mode: 'in', classIds: [requestedClassId] }; return { mode: 'in', classIds: [requestedClassId] };
} }
if (isAdmin(user)) return { mode: 'all' }; if (isAdmin(user)) return { mode: 'all' };
const classIds = await getAccessibleClassIds(user); const classIds = await getAccessibleClassIds(user, permission);
// 没有任何班级时不抛错,返回空集合让列表接口给出空数组,避免新教师首次登录报错 // 没有任何班级时不抛错,返回空集合让列表接口给出空数组,避免新教师首次登录报错
return { mode: 'in', classIds }; return { mode: 'in', classIds };
} }
+131
View File
@@ -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<Awaited<ReturnType<typeof loadPlans>>>[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<ActiveClassPlanDto> {
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,
};
}
@@ -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<string, string>();
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<string>();
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 },
);
}
+4 -2
View File
@@ -187,7 +187,7 @@ export interface ClassLike {
enrollmentYear?: number | null; enrollmentYear?: number | null;
/** 班号;老数据可能为空 */ /** 班号;老数据可能为空 */
classIndex?: number | null; classIndex?: number | null;
teacherId: string; teacherId: string | null;
/** 本学期教学周数(班主任可调,默认 20) */ /** 本学期教学周数(班主任可调,默认 20) */
termWeeks?: number; termWeeks?: number;
/** 班级端按学号查成绩的开关 */ /** 班级端按学号查成绩的开关 */
@@ -213,7 +213,7 @@ export function toClassDto(item: ClassLike): ClassDto {
grade: item.grade, grade: item.grade,
enrollmentYear: item.enrollmentYear ?? null, enrollmentYear: item.enrollmentYear ?? null,
classIndex: item.classIndex ?? null, classIndex: item.classIndex ?? null,
teacherId: item.teacherId, teacherId: item.teacherId ?? '',
termWeeks: item.termWeeks ?? 20, termWeeks: item.termWeeks ?? 20,
// 默认开启:老库补列时的默认值也是 true,两边保持一致 // 默认开启:老库补列时的默认值也是 true,两边保持一致
studentGradeQueryEnabled: item.studentGradeQueryEnabled ?? true, studentGradeQueryEnabled: item.studentGradeQueryEnabled ?? true,
@@ -241,6 +241,8 @@ export interface CourseLike {
export function toCourseDto(item: CourseLike): CourseDto { export function toCourseDto(item: CourseLike): CourseDto {
return { 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, id: item.id,
name: item.name, name: item.name,
teacherId: item.teacherId, teacherId: item.teacherId,
@@ -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<Prisma.ClassWhereInput> {
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 };
}
+16
View File
@@ -1,4 +1,5 @@
import { import {
BUILTIN_TEACHER_GROUPS,
PERMISSION_KEYS, PERMISSION_KEYS,
PERMISSION_LABELS, PERMISSION_LABELS,
ROLE_DEFAULT_PERMISSIONS, ROLE_DEFAULT_PERMISSIONS,
@@ -68,6 +69,20 @@ export function ensureDefaultGroups(): Promise<void> {
return defaultGroupsReady; return defaultGroupsReady;
} }
async function createDefaultGroups(): Promise<void> { async function createDefaultGroups(): Promise<void> {
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) { for (const role of ['ADMIN', 'TEACHER'] as const) {
if (await prisma.permissionGroup.findUnique({ where: { defaultRole: role }, select: { id: true } })) if (await prisma.permissionGroup.findUnique({ where: { defaultRole: role }, select: { id: true } }))
continue; continue;
@@ -86,6 +101,7 @@ async function createDefaultGroups(): Promise<void> {
defaultRole: role, defaultRole: role,
name, name,
builtin: true, builtin: true,
scope: role === 'ADMIN' ? 'ALL' : 'SELF_CLASS',
description: '未分配其他权限组时自动生效;可调整权限。管理员始终保留权限与系统管理入口。', description: '未分配其他权限组时自动生效;可调整权限。管理员始终保留权限与系统管理入口。',
items: { create: ROLE_DEFAULT_PERMISSIONS[role].map((key) => ({ key })) }, items: { create: ROLE_DEFAULT_PERMISSIONS[role].map((key) => ({ key })) },
}, },
+1 -1
View File
@@ -52,7 +52,7 @@ export const attachmentUrlSchema = z
.nullish(); .nullish();
/** 周次:1..30 */ /** 周次:1..30 */
export const weekNumberSchema = z.coerce.number().int().min(1).max(30); export const weekNumberSchema = z.coerce.number().int().min(1).max(60);
/** /**
* 学号:学生的唯一标识与查询键。 * 学号:学生的唯一标识与查询键。
+2 -1
View File
@@ -1,4 +1,5 @@
import { classDeviceClassId, isClassDevice } from './access.js'; import { classDeviceClassId, isClassDevice } from './access.js';
import { activeStudentWhere } from './students.js';
import { prisma } from './db.js'; import { prisma } from './db.js';
import type { TokenPayload } from './jwt.js'; import type { TokenPayload } from './jwt.js';
@@ -26,7 +27,7 @@ export function classSessionClassId(user: TokenPayload): string | null {
/** 该班级的全部学生 id(班级端的"全班"范围) */ /** 该班级的全部学生 id(班级端的"全班"范围) */
export async function listClassStudentIds(classId: string): Promise<string[]> { export async function listClassStudentIds(classId: string): Promise<string[]> {
const students = await prisma.student.findMany({ const students = await prisma.student.findMany({
where: { classId, status: { not: 'inactive' } }, where: { classId, ...activeStudentWhere() },
select: { id: true }, select: { id: true },
}); });
return students.map((item) => item.id); return students.map((item) => item.id);
+2
View File
@@ -28,6 +28,8 @@ export const SETTING_KEYS = {
* 班级自己的 `Class.termStartDate` 仍然优先(见 `lib/term.ts` 的三级回落)。 * 班级自己的 `Class.termStartDate` 仍然优先(见 `lib/term.ts` 的三级回落)。
*/ */
termStartDate: 'term.startDate', termStartDate: 'term.startDate',
termMaxWeeks: 'term.maxWeeks',
promotionAt: 'term.promotionAt',
} as const; } as const;
export type SettingKey = (typeof SETTING_KEYS)[keyof typeof SETTING_KEYS]; export type SettingKey = (typeof SETTING_KEYS)[keyof typeof SETTING_KEYS];
+8
View File
@@ -45,6 +45,14 @@ export const SNAPSHOT_TABLES = [
'StudentClassTransfer', 'StudentClassTransfer',
'Schedule', 'Schedule',
'TimeLayout', 'TimeLayout',
'TimeLayoutItem',
'ClassPlanGroup',
'ClassPlan',
'ClassPlanEntry',
'ScheduledClassPlan',
'PromotionRun',
'TermWeek',
'Exam',
'Homework', 'Homework',
'HomeworkStatus', 'HomeworkStatus',
'Notification', 'Notification',
+7
View File
@@ -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);
}
+9 -6
View File
@@ -1,5 +1,5 @@
import { import {
MAX_TERM_WEEK, getMaxTermWeeks as normalizeMaxWeeks,
resolveCurrentWeek as resolveWeek, resolveCurrentWeek as resolveWeek,
resolveWeekFromRanges, resolveWeekFromRanges,
} from '@classhelper/shared'; } from '@classhelper/shared';
@@ -7,7 +7,9 @@ import { env } from '../config/env.js';
import { prisma } from './db.js'; import { prisma } from './db.js';
import { SETTING_KEYS, getSetting } from './settings.js'; import { SETTING_KEYS, getSetting } from './settings.js';
export { MAX_TERM_WEEK }; export async function getMaxTermWeeks(): Promise<number> {
return normalizeMaxWeeks(await getSetting(SETTING_KEYS.termMaxWeeks));
}
/** 某个班的周次口径(管理员可以为每个班单独配开学日期与逐周区间) */ /** 某个班的周次口径(管理员可以为每个班单独配开学日期与逐周区间) */
export interface TermWeekContext { export interface TermWeekContext {
@@ -27,7 +29,7 @@ export interface TermWeekContext {
*/ */
export function resolveCurrentWeek(now: Date = new Date(), context?: TermWeekContext | null): number { export function resolveCurrentWeek(now: Date = new Date(), context?: TermWeekContext | null): number {
const termStartDate = context?.termStartDate || env.termStartDate; const termStartDate = context?.termStartDate || env.termStartDate;
const maxWeek = context?.maxWeek ?? MAX_TERM_WEEK; const maxWeek = context?.maxWeek ?? normalizeMaxWeeks();
// 配了逐周区间就按区间判定 —— 调休/补课让某一周变长变短时,线性推算会整体错位 // 配了逐周区间就按区间判定 —— 调休/补课让某一周变长变短时,线性推算会整体错位
if (context && context.weeks.length > 0) { if (context && context.weeks.length > 0) {
@@ -48,13 +50,14 @@ export function resolveCurrentWeek(now: Date = new Date(), context?: TermWeekCon
* @param classId 不传/空串表示**只看全校默认**(管理员在「学期周次」页编辑默认口径时就是这种调用) * @param classId 不传/空串表示**只看全校默认**(管理员在「学期周次」页编辑默认口径时就是这种调用)
*/ */
export async function loadTermContext(classId?: string | null): Promise<TermWeekContext> { export async function loadTermContext(classId?: string | null): Promise<TermWeekContext> {
const [globalWeeks, settingStart] = await Promise.all([ const [globalWeeks, settingStart, maxTermWeeks] = await Promise.all([
prisma.termWeek.findMany({ prisma.termWeek.findMany({
where: { classId: '' }, where: { classId: '' },
orderBy: { weekNumber: 'asc' }, orderBy: { weekNumber: 'asc' },
select: { weekNumber: true, startDate: true, endDate: true }, select: { weekNumber: true, startDate: true, endDate: true },
}), }),
getSetting(SETTING_KEYS.termStartDate), getSetting(SETTING_KEYS.termStartDate),
getMaxTermWeeks(),
]); ]);
if (!classId) { if (!classId) {
// 全校默认没有"班级记录"可以存开学日期,因此按「显式设置 → 第 1 周的起始日 → .env」回落 // 全校默认没有"班级记录"可以存开学日期,因此按「显式设置 → 第 1 周的起始日 → .env」回落
@@ -63,7 +66,7 @@ export async function loadTermContext(classId?: string | null): Promise<TermWeek
return { return {
termStartDate: settingStart || firstGlobal || env.termStartDate, termStartDate: settingStart || firstGlobal || env.termStartDate,
weeks: globalWeeks, weeks: globalWeeks,
maxWeek: MAX_TERM_WEEK, maxWeek: maxTermWeeks,
}; };
} }
@@ -85,6 +88,6 @@ export async function loadTermContext(classId?: string | null): Promise<TermWeek
return { return {
termStartDate: cls?.termStartDate || firstWeekStart || settingStart || env.termStartDate, termStartDate: cls?.termStartDate || firstWeekStart || settingStart || env.termStartDate,
weeks, weeks,
maxWeek: cls?.termWeeks && cls.termWeeks > 0 ? cls.termWeeks : MAX_TERM_WEEK, maxWeek: Math.min(cls?.termWeeks || maxTermWeeks, maxTermWeeks),
}; };
} }
+2 -1
View File
@@ -1,3 +1,4 @@
import { activeStudentWhere } from '../../lib/students.js';
import { import {
dayKeyLocal, dayKeyLocal,
type AiActionKind, type AiActionKind,
@@ -267,7 +268,7 @@ async function buildPreview(
const student = const student =
studentName && classId studentName && classId
? await prisma.student.findFirst({ ? await prisma.student.findFirst({
where: { classId, name: studentName }, where: { classId, name: studentName, ...activeStudentWhere() },
select: { id: true, name: true }, select: { id: true, name: true },
}) })
: null; : null;
@@ -1,13 +1,13 @@
import { listTransferred, restoreTransferred } from './transferred.service.js';
import { Router } from 'express'; import { Router } from 'express';
import { sendCreated, sendOk } from '../../lib/http.js'; import { sendOk } from '../../lib/http.js';
import { idParamSchema } from '../../lib/schemas.js'; import { idParamSchema } from '../../lib/schemas.js';
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.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 { defineModule } from '../module.types.js';
import { import {
archiveClassParamSchema, archiveClassParamSchema,
archivedContentQuerySchema, archivedContentQuerySchema,
createArchiveSchema,
listArchivesQuerySchema, listArchivesQuerySchema,
suggestedEnrollmentYears, suggestedEnrollmentYears,
} from './archives.schemas.js'; } from './archives.schemas.js';
@@ -15,6 +15,15 @@ import * as archiveService from './archives.service.js';
const router = Router(); const router = Router();
router.use(authenticate()); 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 - 撤销归档(管理员误操作的退路,不删任何数据) */ /** DELETE /api/archives/:id - 撤销归档(管理员误操作的退路,不删任何数据) */
router.delete( router.delete(
'/:id', '/:id',
@@ -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 };
}
@@ -1,5 +1,6 @@
import { SOCKET_EVENTS, buildCallTitle, dayKeyLocal, type NotificationDto } from '@classhelper/shared'; import { SOCKET_EVENTS, buildCallTitle, dayKeyLocal, type NotificationDto } from '@classhelper/shared';
import { assertCanPublishNotification } from '../../lib/access.js'; import { assertCanPublishNotification } from '../../lib/access.js';
import { isActiveStudent } from '../../lib/students.js';
import { prisma } from '../../lib/db.js'; import { prisma } from '../../lib/db.js';
import { ApiError } from '../../lib/http.js'; import { ApiError } from '../../lib/http.js';
import type { TokenPayload } from '../../lib/jwt.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({ const student = await prisma.student.findUnique({
where: { id: input.studentId }, 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) { if (student.classId !== input.classId) {
throw new ApiError(400, 'BAD_REQUEST', '该学生不在所选班级'); throw new ApiError(400, 'BAD_REQUEST', '该学生不在所选班级');
} }
@@ -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,
});
@@ -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(),
});
@@ -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<typeof layoutSchema>, 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<typeof planSchema>, 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<typeof entriesSchema>) {
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<typeof groupSchema>, 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<typeof datedPlanSchema>) {
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;
}
@@ -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<string, unknown> {
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<string, string>();
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<string, string>();
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<string, { id: string; lessons: string[] }>();
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<string, string>();
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 };
}
@@ -1,5 +1,5 @@
import { z } from 'zod'; import { z } from 'zod';
import { MAX_TERM_WEEK, SUBJECT_CATALOG, isDayKey } from '@classhelper/shared'; import { SUBJECT_CATALOG, isDayKey } from '@classhelper/shared';
import { import {
classIndexSchema, classIndexSchema,
enrollmentYearSchema, enrollmentYearSchema,
@@ -27,11 +27,11 @@ export const createClassSchema = z.object({
.regex(/^[A-Za-z0-9]{4,16}$/, '班级码需为 4~16 位字母或数字') .regex(/^[A-Za-z0-9]{4,16}$/, '班级码需为 4~16 位字母或数字')
.optional(), .optional(),
/** /**
* 本学期教学周数。上限与课表周次口径统一取 MAX_TERM_WEEK(30): * 本学期教学周数。动态上限由服务层统一取 getMaxTermWeeks()(默认30、最多60):
* 周次选择器与 weekNumberSchema 都只到 30,允许更大的值只会让用户设出一个 * 周次选择器与 weekNumberSchema 都只到 30,允许更大的值只会让用户设出一个
* 系统兑现不了的学期长度(第 31 周之后永远为空)。 * 系统兑现不了的学期长度(第 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(), .nullish(),
enrollmentYear: enrollmentYearSchema.optional(), enrollmentYear: enrollmentYearSchema.optional(),
classIndex: classIndexSchema.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(),
}); });
/** 学年升级(批量改年级,不归档) */ /** 学年升级(批量改年级,不归档) */
@@ -26,10 +26,12 @@ import type {
UpdateClassInput, UpdateClassInput,
UpdateStudentGradeQueryInput, UpdateStudentGradeQueryInput,
} from './classes.schemas.js'; } from './classes.schemas.js';
import { activeStudentWhere, isActiveStudent } from '../../lib/students.js';
import { getMaxTermWeeks } from '../../lib/term.js';
import { logger } from '../../lib/logger.js'; import { logger } from '../../lib/logger.js';
const countSelect = { const countSelect = {
students: true, students: { where: activeStudentWhere() },
courses: true, courses: true,
homeworks: true, homeworks: true,
notifications: true, notifications: true,
@@ -48,7 +50,7 @@ const teacherBriefSelect = { select: { id: true, name: true, username: true } }
* 管理员(管理全部班级)或该班级的班主任(要发给学生用,只读展示)。 * 管理员(管理全部班级)或该班级的班主任(要发给学生用,只读展示)。
* 科任老师与 ClassHelper 班级端都看不到别人班的班级码。 * 科任老师与 ClassHelper 班级端都看不到别人班的班级码。
*/ */
function canSeeClassAccount(user: TokenPayload, item: { teacherId: string }): boolean { function canSeeClassAccount(user: TokenPayload, item: { teacherId: string | null }): boolean {
if (user.role === 'ADMIN') return true; if (user.role === 'ADMIN') return true;
return user.role === 'TEACHER' && item.teacherId === user.sub; return user.role === 'TEACHER' && item.teacherId === user.sub;
} }
@@ -103,7 +105,7 @@ export async function getClassDetail(user: TokenPayload, classId: string): Promi
include: { include: {
teacher: teacherBriefSelect, teacher: teacherBriefSelect,
_count: { select: countSelect }, _count: { select: countSelect },
students: { orderBy: { studentNo: 'asc' } }, students: { where: activeStudentWhere(), orderBy: { studentNo: 'asc' } },
courses: { courses: {
orderBy: { name: 'asc' }, orderBy: { name: 'asc' },
include: { teacher: teacherBriefSelect }, include: { teacher: teacherBriefSelect },
@@ -158,6 +160,13 @@ export async function listSubjectTeachers(
/** 创建班级 */ /** 创建班级 */
export async function createClass(user: TokenPayload, input: CreateClassInput): Promise<ClassDto> { export async function createClass(user: TokenPayload, input: CreateClassInput): Promise<ClassDto> {
assertCanManageClasses(user); assertCanManageClasses(user);
if (
'termWeeks' in input &&
typeof input.termWeeks === 'number' &&
input.termWeeks > (await getMaxTermWeeks())
)
throw ApiError.badRequest('班级周数不能超过全校上限');
let teacherId = user.sub; let teacherId = user.sub;
if (input.teacherId && user.role === 'ADMIN') { if (input.teacherId && user.role === 'ADMIN') {
teacherId = await requireTeacher(input.teacherId, '指定的班主任不存在或角色不是教师'); teacherId = await requireTeacher(input.teacherId, '指定的班主任不存在或角色不是教师');
@@ -244,6 +253,7 @@ export async function assignSubjectTeachers(
(await prisma.class.findUniqueOrThrow({ where: { id: classId }, select: { teacherId: true } })) (await prisma.class.findUniqueOrThrow({ where: { id: classId }, select: { teacherId: true } }))
.teacherId; .teacherId;
if (!teacherId) throw ApiError.badRequest('请先指定班主任或为该科指定任课老师');
const existing = assignment.courseId const existing = assignment.courseId
? await prisma.course.findUnique({ ? await prisma.course.findUnique({
where: { id: assignment.courseId }, where: { id: assignment.courseId },
@@ -285,6 +295,13 @@ export async function updateClassAccount(
input: { code?: string; password?: string }, input: { code?: string; password?: string },
): Promise<ClassDto> { ): Promise<ClassDto> {
assertCanManageClasses(user); assertCanManageClasses(user);
if (
'termWeeks' in input &&
typeof input.termWeeks === 'number' &&
input.termWeeks > (await getMaxTermWeeks())
)
throw ApiError.badRequest('班级周数不能超过全校上限');
await updateClassAccountRecord(classId, input); await updateClassAccountRecord(classId, input);
const updated = await prisma.class.findUnique({ 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<StudentDto[]> { export async function listClassStudents(user: TokenPayload, classId: string): Promise<StudentDto[]> {
await assertClassAccess(user, classId); await assertClassAccess(user, classId);
const students = await prisma.student.findMany({ const students = await prisma.student.findMany({
where: { classId, archivedYearId: null }, where: { classId, archivedYearId: null, ...activeStudentWhere() },
include: { class: { select: { name: true, grade: true } } }, include: { class: { select: { name: true, grade: true } } },
orderBy: { studentNo: 'asc' }, orderBy: { studentNo: 'asc' },
}); });
@@ -395,6 +412,8 @@ export async function addStudent(
const existing = await prisma.student.findUnique({ where: { studentNo: input.studentNo } }); const existing = await prisma.student.findUnique({ where: { studentNo: input.studentNo } });
let studentId: string; let studentId: string;
if (existing && !isActiveStudent(existing))
throw ApiError.conflict('学号已被历史学生占用;请使用新学号或在归档页撤回转出');
if (existing) { if (existing) {
studentId = existing.id; studentId = existing.id;
if (existing.classId !== classId) { if (existing.classId !== classId) {
@@ -84,7 +84,7 @@ router.get(
/** POST /api/clients/updates - 统一下发客户端更新 */ /** POST /api/clients/updates - 统一下发客户端更新 */
router.post( router.post(
'/updates', '/updates',
requirePermission('client:update-push'), requirePermission('client:update'),
validate({ body: pushClientUpdateSchema }), validate({ body: pushClientUpdateSchema }),
async (req, res) => { async (req, res) => {
const user = getAuthUser(req); const user = getAuthUser(req);
@@ -2,7 +2,6 @@ import {
CLIENT_COMMAND_EXPIRE_MS, CLIENT_COMMAND_EXPIRE_MS,
CLIENT_COMMAND_LABELS, CLIENT_COMMAND_LABELS,
CLIENT_ONLINE_WINDOW_MS, CLIENT_ONLINE_WINDOW_MS,
weekParityOf,
type ClientUpdateMode, type ClientUpdateMode,
type ClientCommandAckRequest, type ClientCommandAckRequest,
type ClientCommandDto, type ClientCommandDto,
@@ -12,7 +11,7 @@ import {
type ClientPushResult, type ClientPushResult,
type ClientTargetResult, type ClientTargetResult,
} from '@classhelper/shared'; } 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 { resolveClassScope, classScopeWhere } from '../../lib/access.js';
import { prisma } from '../../lib/db.js'; import { prisma } from '../../lib/db.js';
import { ApiError } from '../../lib/http.js'; import { ApiError } from '../../lib/http.js';
@@ -276,11 +275,12 @@ export async function listDevices(
/** 命令去重后的目标设备(保持请求顺序,便于结果一一对应) */ /** 命令去重后的目标设备(保持请求顺序,便于结果一一对应) */
async function findTargetDevices(user: TokenPayload, deviceIds: string[]): Promise<Map<string, DeviceRow>> { async function findTargetDevices(user: TokenPayload, deviceIds: string[]): Promise<Map<string, DeviceRow>> {
const wanted = [...new Set(deviceIds)]; 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({ const rows = (await prisma.clientDevice.findMany({
where: { AND: [scope, { id: { in: wanted } }] }, where: { AND: [scope, { id: { in: wanted } }] },
select: { id: true, name: true, deviceKey: true }, select: { id: true, name: true, deviceKey: true },
})) as unknown as DeviceRow[]; })) as unknown as DeviceRow[];
if (rows.length !== wanted.length) throw ApiError.forbidden('部分设备超出管理范围');
return new Map(rows.map((row) => [row.id, row])); 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 - 下发远控命令(关机/重启/注销/锁屏) * POST /api/clients/commands - 下发远控命令(关机/重启/注销/锁屏)
* *
* 同一台机器上还没执行的旧命令会被**自动撤销**:否则"先关机、后重启"会排队执行, * 同一台机器上还没执行的旧命令会被**自动撤销**:否则"先关机、后重启"会排队执行,
* 机器刚关就被下一条叫起来,看起来像失控。客户端侧还有一次弹窗二次确认。 * 机器刚关就被下一条叫起来,看起来像失控。客户端收到即执行,并以设备标识回执真实结果。
*/ */
export async function createCommands( export async function createCommands(
user: TokenPayload, user: TokenPayload,
@@ -331,7 +331,7 @@ export async function createCommands(
/** 更新推送的目标集合:优先设备,其次班级,最后"全部" */ /** 更新推送的目标集合:优先设备,其次班级,最后"全部" */
async function resolveUpdateTargets(user: TokenPayload, input: PushClientUpdateInput): Promise<DeviceRow[]> { async function resolveUpdateTargets(user: TokenPayload, input: PushClientUpdateInput): Promise<DeviceRow[]> {
const scope = classScopeWhere(await resolveClassScope(user)); const scope = classScopeWhere(await resolveClassScope(user, undefined, 'client:update'));
const deviceIds = [...new Set(input.deviceIds ?? [])]; const deviceIds = [...new Set(input.deviceIds ?? [])];
if (deviceIds.length > 0) { if (deviceIds.length > 0) {
const rows = (await prisma.clientDevice.findMany({ const rows = (await prisma.clientDevice.findMany({
@@ -414,18 +414,8 @@ async function afterSchoolAt(classId: string | null, fallback = '17:00'): Promis
const now = new Date(); const now = new Date();
let end = fallback; let end = fallback;
if (classId) { if (classId) {
const week = resolveCurrentWeek(now, await loadTermContext(classId)); const active = await resolveActiveClassPlan(classId, now);
const last = await prisma.schedule.findFirst({ const last = [...active.schedules].sort((a, b) => b.endTime.localeCompare(a.endTime))[0];
where: {
classId,
dayOfWeek: now.getDay() || 7,
weekStart: { lte: week },
weekEnd: { gte: week },
weekParity: { in: ['ALL', weekParityOf(week)] },
},
orderBy: { endTime: 'desc' },
select: { endTime: true },
});
if (last && last.endTime > end) end = last.endTime; if (last && last.endTime > end) end = last.endTime;
} }
const [hours, minutes] = end.split(':').map(Number); 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 - 移除一台设备记录(换机 / 旧机器清理) */ /** DELETE /api/clients/devices/:id - 移除一台设备记录(换机 / 旧机器清理) */
export async function deleteDevice(user: TokenPayload, deviceId: string): Promise<{ id: string }> { 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({ const device = await prisma.clientDevice.findFirst({
where: { AND: [scope, { id: deviceId }] }, where: { AND: [scope, { id: deviceId }] },
select: { id: true, name: true, deviceKey: true }, select: { id: true, name: true, deviceKey: true },
@@ -1,11 +1,15 @@
import { z } from 'zod'; import { z } from 'zod';
export const createCourseSchema = z.object({ export const createCourseSchema = z.object({
initial: z.string().trim().max(8).optional(),
isOutDoor: z.boolean().optional(),
name: z.string().trim().min(1, '请输入课程名称').max(64), name: z.string().trim().min(1, '请输入课程名称').max(64),
classId: z.string().min(1, '请选择班级'), classId: z.string().min(1, '请选择班级'),
}); });
export const updateCourseSchema = z.object({ 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(), name: z.string().trim().min(1).max(64).optional(),
}); });
@@ -44,7 +44,13 @@ export async function createCourse(user: TokenPayload, input: CreateCourseInput)
} }
const created = await prisma.course.create({ 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 }, include: { teacher: teacherSelect },
}); });
return toCourseDto(created); return toCourseDto(created);
@@ -61,7 +67,11 @@ export async function updateCourse(
const updated = await prisma.course.update({ const updated = await prisma.course.update({
where: { id: courseId }, 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 }, include: { teacher: teacherSelect },
}); });
return toCourseDto(updated); return toCourseDto(updated);
@@ -8,7 +8,7 @@ import {
requireClassDeviceClassId, requireClassDeviceClassId,
} from '../../lib/access.js'; } from '../../lib/access.js';
import { prisma } from '../../lib/db.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 type { TokenPayload } from '../../lib/jwt.js';
import { toHomeworkDto, toNotificationDto } from '../../lib/mappers.js'; import { toHomeworkDto, toNotificationDto } from '../../lib/mappers.js';
import { personalIdWhere, resolvePersonalIds } from '../../lib/session.js'; import { personalIdWhere, resolvePersonalIds } from '../../lib/session.js';
@@ -21,17 +21,17 @@ const creatorSelect = { select: { id: true, name: true, username: true } } as co
* - ClassHelper 班级端:固定为自己绑定的班级; * - ClassHelper 班级端:固定为自己绑定的班级;
* - 教师 / 管理员:优先用显式 classId,**并且必须通过班级访问校验**(与其他接口同口径, * - 教师 / 管理员:优先用显式 classId,**并且必须通过班级访问校验**(与其他接口同口径,
* 否则任意登录用户都能拿 classId 探测别班的教学周数); * 否则任意登录用户都能拿 classId 探测别班的教学周数);
* - 都不传:全局默认(MAX_TERM_WEEK 兜底)。 * - 都不传:全局默认(getMaxTermWeeks 兜底)。
*/ */
export async function resolveTermWeeks(user: TokenPayload, classId?: string): Promise<number> { export async function resolveTermWeeks(user: TokenPayload, classId?: string): Promise<number> {
if (classId) await assertClassAccess(user, classId); if (classId) await assertClassAccess(user, classId);
const targetClassId = classId ?? (isClassDevice(user) ? requireClassDeviceClassId(user) : undefined); const targetClassId = classId ?? (isClassDevice(user) ? requireClassDeviceClassId(user) : undefined);
if (!targetClassId) return MAX_TERM_WEEK; if (!targetClassId) return getMaxTermWeeks();
const record = await prisma.class.findUnique({ const record = await prisma.class.findUnique({
where: { id: targetClassId }, where: { id: targetClassId },
select: { termWeeks: true }, 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<Dashboard
prisma.class.count({ where: classWhere }), prisma.class.count({ where: classWhere }),
prisma.student.count({ where: { ...where, status: 'active', archivedYearId: null } }), prisma.student.count({ where: { ...where, status: 'active', archivedYearId: null } }),
prisma.course.count({ where }), prisma.course.count({ where }),
prisma.schedule.count({ where }), prisma.classPlan.count({ where }),
prisma.homework.count({ where }), prisma.homework.count({ where }),
prisma.notification.count({ where }), prisma.notification.count({ where }),
prisma.grade.count({ where }), prisma.grade.count({ where }),
@@ -13,10 +13,7 @@ import {
type DatabaseSnapshot, type DatabaseSnapshot,
} from '../../lib/snapshot.js'; } from '../../lib/snapshot.js';
import { ApiError } from '../../lib/http.js'; import { ApiError } from '../../lib/http.js';
import type { import type { BackupScheduleInput, TargetConnectionInput } from './database.schemas.js';
BackupScheduleInput,
TargetConnectionInput,
} from './database.schemas.js';
/** /**
* 数据库管理服务:状态检测 / 连接测试 / 备份恢复 / 导入导出 / 定时备份 / 一键切换。 * 数据库管理服务:状态检测 / 连接测试 / 备份恢复 / 导入导出 / 定时备份 / 一键切换。
@@ -71,8 +68,13 @@ function readSchedule(): BackupSchedule {
return { return {
enabled: raw.enabled === true, enabled: raw.enabled === true,
intervalHours: intervalHours:
typeof raw.intervalHours === 'number' && raw.intervalHours >= 1 ? Math.floor(raw.intervalHours) : DEFAULT_SCHEDULE.intervalHours, typeof raw.intervalHours === 'number' && raw.intervalHours >= 1
keepCount: typeof raw.keepCount === 'number' && raw.keepCount >= 1 ? Math.floor(raw.keepCount) : DEFAULT_SCHEDULE.keepCount, ? 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, lastAutoBackupAt: typeof raw.lastAutoBackupAt === 'string' ? raw.lastAutoBackupAt : null,
}; };
} catch { } catch {
@@ -379,12 +381,28 @@ export async function testConnection(target: TargetConnectionInput): Promise<Con
if (!fs.existsSync(absolute)) { if (!fs.existsSync(absolute)) {
const parent = path.dirname(absolute); const parent = path.dirname(absolute);
if (!fs.existsSync(parent)) { if (!fs.existsSync(parent)) {
return { ok: false, provider: target.provider, latencyMs: null, version: null, error: `目录不存在:${parent}` }; return {
ok: false,
provider: target.provider,
latencyMs: null,
version: null,
error: `目录不存在:${parent}`,
};
} }
note = '目标文件不存在,属新库(切换时会自动建表)'; note = '目标文件不存在,属新库(切换时会自动建表)';
} }
url = `file:${absolute.replace(/\\/g, '/')}`; url = `file:${absolute.replace(/\\/g, '/')}`;
} }
// 同一个 SQLite 文件复用活跃连接,避免在 Windows/libsql 下开关第二个原生句柄。
if (target.provider === 'sqlite' && env.databaseProvider === 'sqlite' && url === env.databaseUrl) {
return {
ok: true,
provider: target.provider,
latencyMs: Date.now() - startedAt,
version: await databaseVersion('sqlite', prisma),
note,
};
}
let client: Awaited<ReturnType<typeof createPrismaClient>> | null = null; let client: Awaited<ReturnType<typeof createPrismaClient>> | null = null;
try { try {
client = await createPrismaClient(target.provider, url); client = await createPrismaClient(target.provider, url);
@@ -483,12 +501,12 @@ async function assertTargetEmpty(target: TargetConnectionInput): Promise<string>
try { try {
const rows = const rows =
target.provider === 'sqlite' target.provider === 'sqlite'
? (await client.$queryRawUnsafe( ? ((await client.$queryRawUnsafe(
"SELECT COUNT(*) AS count FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'", "SELECT COUNT(*) AS count FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'",
)) as { count?: bigint | number }[] )) as { count?: bigint | number }[])
: (await client.$queryRawUnsafe( : ((await client.$queryRawUnsafe(
'SELECT COUNT(*) AS count FROM information_schema.tables WHERE table_schema = DATABASE()', '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); const tableCount = Number(rows[0]?.count ?? 0);
if (tableCount > 0) { if (tableCount > 0) {
throw ApiError.badRequest( throw ApiError.badRequest(
@@ -552,7 +570,11 @@ async function compileGeneratedClient(job: SwitchJob): Promise<void> {
} }
/** 跨库迁移的数据写入走独立子进程:运行中的服务进程缓存的是旧方言的 Prisma Client */ /** 跨库迁移的数据写入走独立子进程:运行中的服务进程缓存的是旧方言的 Prisma Client */
function runApplySnapshotTool(job: SwitchJob, target: TargetConnectionInput, snapshotFile: string): Promise<Record<string, number>> { function runApplySnapshotTool(
job: SwitchJob,
target: TargetConnectionInput,
snapshotFile: string,
): Promise<Record<string, number>> {
const distTool = path.join(env.serverRoot, 'dist', 'tools', 'apply-snapshot.js'); const distTool = path.join(env.serverRoot, 'dist', 'tools', 'apply-snapshot.js');
const srcTool = path.join(env.serverRoot, 'src', 'tools', 'apply-snapshot.ts'); const srcTool = path.join(env.serverRoot, 'src', 'tools', 'apply-snapshot.ts');
const tsxCli = path.join(env.serverRoot, 'node_modules', 'tsx', 'dist', 'cli.mjs'); 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) { if (!test.ok) {
throw new Error(test.error ?? '目标库连接失败'); throw new Error(test.error ?? '目标库连接失败');
} }
finishStep(job, '测试目标库连接', test.note ?? `连接正常(${test.latencyMs ?? '-'}ms,${test.version ?? '-'})`); finishStep(
job,
'测试目标库连接',
test.note ?? `连接正常(${test.latencyMs ?? '-'}ms,${test.version ?? '-'})`,
);
// 4. 目标库必须为空 // 4. 目标库必须为空
stepOf(job, '检查目标库为空'); stepOf(job, '检查目标库为空');
@@ -31,6 +31,23 @@ import * as gradeService from './grades.service.js';
const router = Router(); const router = Router();
router.use(authenticate()); 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 班级端查看本班成绩(教师/管理员返回空数组) */ /** GET /api/grades/my - ClassHelper 班级端查看本班成绩(教师/管理员返回空数组) */
router.get('/my', async (req, res) => { router.get('/my', async (req, res) => {
const user = getAuthUser(req); const user = getAuthUser(req);
@@ -1,3 +1,4 @@
import type { CollectiveGradeAnalysisDto as ContractCollectiveGradeAnalysisDto } from '@classhelper/shared';
import { import {
SOCKET_EVENTS, SOCKET_EVENTS,
canQueryStudentGrades, canQueryStudentGrades,
@@ -21,6 +22,7 @@ import {
resolveClassScope, resolveClassScope,
resolveUserClassRole, resolveUserClassRole,
} from '../../lib/access.js'; } from '../../lib/access.js';
import { activeStudentWhere, isActiveStudent } from '../../lib/students.js';
import { prisma } from '../../lib/db.js'; import { prisma } from '../../lib/db.js';
import { ApiError } from '../../lib/http.js'; import { ApiError } from '../../lib/http.js';
import type { TokenPayload } from '../../lib/jwt.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 classSelect = { select: { name: true } } as const;
const include = { course: courseSelect, student: studentSelect, class: classSelect } as const; const include = { course: courseSelect, student: studentSelect, class: classSelect } as const;
async function assertDetailQuery(user: TokenPayload, classId: string): Promise<void> {
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<ContractCollectiveGradeAnalysisDto> {
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 { export interface ListGradeOptions {
classId?: string; classId?: string;
courseId?: string; courseId?: string;
@@ -56,11 +126,12 @@ export interface ListGradeOptions {
* 教师/管理员调用时返回空数组(他们没有"自己的成绩"),请用 `listGrades`。 * 教师/管理员调用时返回空数组(他们没有"自己的成绩"),请用 `listGrades`。
*/ */
export async function listMyGrades(user: TokenPayload): Promise<GradeDto[]> { export async function listMyGrades(user: TokenPayload): Promise<GradeDto[]> {
if (isClassDevice(user)) await assertDetailQuery(user, user.classId ?? user.sub);
const studentIds = await resolvePersonalIds(user); const studentIds = await resolvePersonalIds(user);
if (studentIds.length === 0) return []; if (studentIds.length === 0) return [];
const grades = await prisma.grade.findMany({ const grades = await prisma.grade.findMany({
where: { studentId: { in: studentIds } }, where: { studentId: { in: studentIds }, student: activeStudentWhere() },
include, include,
orderBy: [{ publishedAt: 'desc' }, { examName: 'asc' }], orderBy: [{ publishedAt: 'desc' }, { examName: 'asc' }],
take: 1000, take: 1000,
@@ -71,9 +142,11 @@ export async function listMyGrades(user: TokenPayload): Promise<GradeDto[]> {
/** 教师 / 管理员查看成绩(按可访问班级过滤) */ /** 教师 / 管理员查看成绩(按可访问班级过滤) */
export async function listGrades(user: TokenPayload, options: ListGradeOptions): Promise<GradeDto[]> { export async function listGrades(user: TokenPayload, options: ListGradeOptions): Promise<GradeDto[]> {
const scope = await resolveClassScope(user, options.classId); const scope = await resolveClassScope(user, options.classId);
if (isClassDevice(user)) await assertDetailQuery(user, user.classId ?? user.sub);
const grades = await prisma.grade.findMany({ const grades = await prisma.grade.findMany({
where: { where: {
...classScopeWhere(scope), ...classScopeWhere(scope),
student: activeStudentWhere(),
...(options.courseId ? { courseId: options.courseId } : {}), ...(options.courseId ? { courseId: options.courseId } : {}),
...(options.studentId ? { studentId: options.studentId } : {}), ...(options.studentId ? { studentId: options.studentId } : {}),
...(options.examName ? { examName: options.examName } : {}), ...(options.examName ? { examName: options.examName } : {}),
@@ -202,7 +275,7 @@ export async function updateGrade(
gradeId: string, gradeId: string,
input: UpdateGradeInput, input: UpdateGradeInput,
): Promise<GradeDto> { ): Promise<GradeDto> {
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('成绩记录不存在'); if (!current) throw ApiError.notFound('成绩记录不存在');
// 改的是哪条成绩,就按那条成绩所属的班级 + 科目判权限 // 改的是哪条成绩,就按那条成绩所属的班级 + 科目判权限
@@ -243,7 +316,7 @@ export async function updateGrade(
} }
export async function deleteGrade(user: TokenPayload, gradeId: string): Promise<void> { export async function deleteGrade(user: TokenPayload, gradeId: string): Promise<void> {
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('成绩记录不存在'); if (!current) throw ApiError.notFound('成绩记录不存在');
await assertCanManageSubjectContent(user, current.classId, current.courseId, '删除成绩'); await assertCanManageSubjectContent(user, current.classId, current.courseId, '删除成绩');
@@ -272,7 +345,7 @@ export async function updateGradeLevels(
): Promise<{ updated: number }> { ): Promise<{ updated: number }> {
if (input.items && input.items.length > 0) { if (input.items && input.items.length > 0) {
const records = await prisma.grade.findMany({ 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 }, select: { id: true, classId: true, courseId: true },
}); });
if (records.length !== input.items.length) { if (records.length !== input.items.length) {
@@ -309,6 +382,7 @@ export async function updateGradeLevels(
where: { where: {
classId, classId,
examName: input.examName!, examName: input.examName!,
student: activeStudentWhere(),
...(input.courseId !== undefined ? { courseId: input.courseId ?? null } : {}), ...(input.courseId !== undefined ? { courseId: input.courseId ?? null } : {}),
}, },
select: { id: true, score: true, totalScore: true, level: true }, select: { id: true, score: true, totalScore: true, level: true },
@@ -346,17 +420,20 @@ export async function getStudentGradeDetail(
studentNo: string, studentNo: string,
requestedClassId?: string, requestedClassId?: string,
): Promise<StudentGradeDetailDto> { ): Promise<StudentGradeDetailDto> {
if (isClassDevice(user)) await assertDetailQuery(user, user.classId ?? user.sub);
const scope = await resolveClassScope(user, requestedClassId); const scope = await resolveClassScope(user, requestedClassId);
const student = await prisma.student.findFirst({ 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 } } }, include: { class: { select: { id: true, name: true, studentGradeQueryEnabled: true } } },
}); });
if (!student) throw ApiError.notFound('没找到这个学号的学生,请核对学号是否输入正确'); if (!student) throw ApiError.notFound('没找到这个学号的学生,请核对学号是否输入正确');
if (student.class) { if (student.class) {
const classRole = await resolveUserClassRole(user, student.class.id); const classRole = await resolveUserClassRole(user, student.class.id);
const allowed = canQueryStudentGrades({ const allowed =
isStaff(user) ||
canQueryStudentGrades({
classRole, classRole,
// 老库补列时的默认值是 true(开箱可用),所以 null/undefined 都按开启处理 // 老库补列时的默认值是 true(开箱可用),所以 null/undefined 都按开启处理
gradeQueryEnabled: student.class.studentGradeQueryEnabled ?? true, gradeQueryEnabled: student.class.studentGradeQueryEnabled ?? true,
@@ -395,9 +472,10 @@ export async function getStudentGradeDetailById(
): Promise<StudentGradeDetailDto> { ): Promise<StudentGradeDetailDto> {
const student = await prisma.student.findUnique({ const student = await prisma.student.findUnique({
where: { id: studentId }, 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 ?? ''); await assertClassAccess(user, student.classId ?? '');
return getStudentGradeDetail(user, student.studentNo); return getStudentGradeDetail(user, student.studentNo);
} }
@@ -468,9 +546,9 @@ async function upsertGrade(data: {
async function assertStudentInClass(studentId: string, classId: string): Promise<void> { async function assertStudentInClass(studentId: string, classId: string): Promise<void> {
const student = await prisma.student.findUnique({ const student = await prisma.student.findUnique({
where: { id: studentId }, 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('该学生不属于所选班级'); if (student.classId !== classId) throw ApiError.badRequest('该学生不属于所选班级');
} }
@@ -491,7 +569,11 @@ async function assertCourseInClass(courseId: string, classId: string): Promise<v
export async function listExams(user: TokenPayload, classId: string): Promise<ExamDto[]> { export async function listExams(user: TokenPayload, classId: string): Promise<ExamDto[]> {
await assertClassAccess(user, classId); await assertClassAccess(user, classId);
const [groups, exams] = await Promise.all([ 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({ prisma.exam.findMany({
where: { classId }, where: { classId },
select: { id: true, name: true, countInTotal: true }, select: { id: true, name: true, countInTotal: true },
@@ -580,7 +662,9 @@ export async function getExamRanking(
if (!classRecord) throw ApiError.notFound('班级不存在'); if (!classRecord) throw ApiError.notFound('班级不存在');
const classRole = await resolveUserClassRole(user, classId); const classRole = await resolveUserClassRole(user, classId);
const allowed = canQueryStudentGrades({ const allowed =
isStaff(user) ||
canQueryStudentGrades({
classRole, classRole,
// 老库补列时的默认值是 true(开箱可用),所以 null/undefined 都按开启处理 // 老库补列时的默认值是 true(开箱可用),所以 null/undefined 都按开启处理
gradeQueryEnabled: classRecord.studentGradeQueryEnabled ?? true, gradeQueryEnabled: classRecord.studentGradeQueryEnabled ?? true,
@@ -589,7 +673,7 @@ export async function getExamRanking(
const [grades, exam] = await Promise.all([ const [grades, exam] = await Promise.all([
prisma.grade.findMany({ prisma.grade.findMany({
where: { classId, examName }, where: { classId, examName, student: activeStudentWhere() },
include, include,
orderBy: [{ publishedAt: 'desc' }], orderBy: [{ publishedAt: 'desc' }],
}), }),
@@ -19,6 +19,7 @@ import {
resolveClassScope, resolveClassScope,
} from '../../lib/access.js'; } from '../../lib/access.js';
import { normalizeBulkClassIds, summarizeBulk, toBulkFailure } from '../../lib/bulk.js'; import { normalizeBulkClassIds, summarizeBulk, toBulkFailure } from '../../lib/bulk.js';
import { activeStudentWhere, isActiveStudent } from '../../lib/students.js';
import { prisma } from '../../lib/db.js'; import { prisma } from '../../lib/db.js';
import { ApiError } from '../../lib/http.js'; import { ApiError } from '../../lib/http.js';
import type { TokenPayload } from '../../lib/jwt.js'; import type { TokenPayload } from '../../lib/jwt.js';
@@ -67,16 +68,14 @@ export async function listHomeworks(
...(options.keyword ...(options.keyword
? { OR: [{ title: { contains: options.keyword } }, { content: { contains: options.keyword } }] } ? { OR: [{ title: { contains: options.keyword } }, { content: { contains: options.keyword } }] }
: {}), : {}),
...(device && options.pendingOnly ...(device && options.pendingOnly ? { statuses: { none: { ...personalWhere, completed: true } } } : {}),
? { statuses: { none: { ...personalWhere, completed: true } } }
: {}),
// 按天查看:作业「属于哪一天」看 assignDate(本地日期),不是 createdAt // 按天查看:作业「属于哪一天」看 assignDate(本地日期),不是 createdAt
...(options.date ? { assignDate: options.date } : {}), ...(options.date ? { assignDate: options.date } : {}),
}, },
include: { include: {
course: courseSelect, course: courseSelect,
creator: creatorSelect, creator: creatorSelect,
statuses: device ? { where: personalWhere } : true, statuses: device ? { where: personalWhere } : { where: { student: activeStudentWhere() } },
}, },
orderBy: [{ assignDate: 'desc' }, { createdAt: 'desc' }], orderBy: [{ assignDate: 'desc' }, { createdAt: 'desc' }],
take: 200, take: 200,
@@ -127,7 +126,9 @@ export async function getHomework(user: TokenPayload, homeworkId: string): Promi
include: { include: {
course: courseSelect, course: courseSelect,
creator: creatorSelect, creator: creatorSelect,
statuses: device ? { where: personalIdWhere(personalIds) } : true, statuses: device
? { where: personalIdWhere(personalIds) }
: { where: { student: activeStudentWhere() } },
}, },
}); });
if (!homework) throw ApiError.notFound('作业不存在'); if (!homework) throw ApiError.notFound('作业不存在');
@@ -165,6 +166,7 @@ export async function createHomework(user: TokenPayload, input: CreateHomeworkIn
select: { teacherId: true }, select: { teacherId: true },
}); });
if (!owner) throw ApiError.notFound('班级不存在'); if (!owner) throw ApiError.notFound('班级不存在');
if (!owner.teacherId) throw ApiError.badRequest('本班尚未指定班主任,暂不能由班级端录入作业');
createdBy = owner.teacherId; createdBy = owner.teacherId;
} }
@@ -179,7 +181,11 @@ export async function createHomework(user: TokenPayload, input: CreateHomeworkIn
assignDate: input.assignDate ?? dayKeyLocal(new Date()), assignDate: input.assignDate ?? dayKeyLocal(new Date()),
createdBy, createdBy,
}, },
include: { course: courseSelect, creator: creatorSelect, statuses: true }, include: {
course: courseSelect,
creator: creatorSelect,
statuses: { where: { student: activeStudentWhere() } },
},
}); });
const dto = toHomeworkDto(created, { withStatus: true }); const dto = toHomeworkDto(created, { withStatus: true });
@@ -268,7 +274,11 @@ export async function updateHomework(
...(input.attachmentUrl !== undefined ? { attachmentUrl: input.attachmentUrl ?? null } : {}), ...(input.attachmentUrl !== undefined ? { attachmentUrl: input.attachmentUrl ?? null } : {}),
...(input.assignDate !== undefined ? { assignDate: input.assignDate } : {}), ...(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 }); const dto = toHomeworkDto(updated, { withStatus: true });
@@ -340,9 +350,9 @@ export async function updateHomeworkStatus(
} }
const target = await prisma.student.findUnique({ const target = await prisma.student.findUnique({
where: { id: input.studentId }, 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)'); throw ApiError.badRequest('请指定该作业所在班级的学生(studentId)');
} }
@@ -363,7 +373,7 @@ export async function updateHomeworkStatus(
async function buildSubmissions(classId: string, homeworkId: string): Promise<HomeworkSubmissionsDto> { async function buildSubmissions(classId: string, homeworkId: string): Promise<HomeworkSubmissionsDto> {
const [students, statuses] = await Promise.all([ const [students, statuses] = await Promise.all([
prisma.student.findMany({ prisma.student.findMany({
where: { classId, archivedYearId: null }, where: { classId, archivedYearId: null, ...activeStudentWhere() },
select: { id: true, name: true, studentNo: true }, select: { id: true, name: true, studentNo: true },
orderBy: { studentNo: 'asc' }, orderBy: { studentNo: 'asc' },
}), }),
@@ -426,7 +436,7 @@ export async function updateHomeworkSubmissions(
assertCanManageSubmissions(user); assertCanManageSubmissions(user);
const students = await prisma.student.findMany({ const students = await prisma.student.findMany({
where: { classId: homework.classId, archivedYearId: null }, where: { classId: homework.classId, archivedYearId: null, ...activeStudentWhere() },
select: { id: true }, select: { id: true },
}); });
if (students.length === 0) throw ApiError.badRequest('当前班级还没有学生,请先在名单里添加学生'); if (students.length === 0) throw ApiError.badRequest('当前班级还没有学生,请先在名单里添加学生');
@@ -1,7 +1,6 @@
import { importProfile } from '../class-plans/profile-import.service.js';
import { prisma } from '../../lib/db.js'; import { prisma } from '../../lib/db.js';
import { ApiError } from '../../lib/http.js';
import type { TokenPayload } from '../../lib/jwt.js'; import type { TokenPayload } from '../../lib/jwt.js';
import { logger } from '../../lib/logger.js';
import { assertCanManageSchedule } from '../../lib/access.js'; import { assertCanManageSchedule } from '../../lib/access.js';
import { weekParityFromDiv } from '@classhelper/shared'; import { weekParityFromDiv } from '@classhelper/shared';
import type { ClassPlanImportInput } from './imports.schemas.js'; import type { ClassPlanImportInput } from './imports.schemas.js';
@@ -20,7 +19,7 @@ import type { ClassPlanImportInput } from './imports.schemas.js';
* ``` * ```
* *
* 关键语义(与 ClassIsland 源码一致): * 关键语义(与 ClassIsland 源码一致):
* - `TimeRule.WeekDay`:0=周日、1=周一 … 6=周六; * - `TimeRule.WeekDay`:0=不按天、1=周一 … 7=周日;
* - `TimeRule.WeekCountDiv`:0=每周;1=单周;2=双周(`WeekCountDivTotal=2` 时); * - `TimeRule.WeekCountDiv`:0=每周;1=单周;2=双周(`WeekCountDivTotal=2` 时);
* 一般规则:`WeekCountDivTotal = n` 时,第 `WeekCountDiv` 周启用(1-based); * 一般规则:`WeekCountDivTotal = n` 时,第 `WeekCountDiv` 周启用(1-based);
* - `Classes[i]` 对应 `Layouts` 里**第 i 个 `TimeType === 0`(上课)的点**,课间/分割线不占位; * - `Classes[i]` 对应 `Layouts` 里**第 i 个 `TimeType === 0`(上课)的点**,课间/分割线不占位;
@@ -184,18 +183,9 @@ export function parseClassIslandClassPlan(input: unknown): ClassPlanParseResult
const rule = asRecord(plan.TimeRule ?? plan.timeRule) ?? {}; const rule = asRecord(plan.TimeRule ?? plan.timeRule) ?? {};
const rawWeekDay = Number(rule.WeekDay ?? rule.weekDay ?? 0); const rawWeekDay = Number(rule.WeekDay ?? rule.weekDay ?? 0);
// ClassIsland:0=周日 … 6=周六 → 我们:1=周一 … 7=周日 // ClassIsland:0=周日 … 6=周六 → 我们:1=周一 … 7=周日
const dayOfWeek = Number.isFinite(rawWeekDay) const dayOfWeek = Number.isFinite(rawWeekDay) ? Math.min(7, Math.max(0, rawWeekDay)) : 1;
? rawWeekDay === 0
? 7
: Math.min(7, Math.max(1, rawWeekDay))
: 1;
const weekCountDiv = Number(rule.WeekCountDiv ?? rule.weekCountDiv ?? 0) || 0; const weekCountDiv = Number(rule.WeekCountDiv ?? rule.weekCountDiv ?? 0) || 0;
const weekCountDivTotal = Number(rule.WeekCountDivTotal ?? rule.weekCountDivTotal ?? 2) || 2; 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 weekParity = resolveParity(weekCountDiv, weekCountDivTotal);
const layoutId = String(plan.TimeLayoutId ?? plan.timeLayoutId ?? ''); const layoutId = String(plan.TimeLayoutId ?? plan.timeLayoutId ?? '');
@@ -275,109 +265,6 @@ export async function previewClassPlan(
* - replace:先清空该班课表再写入(失败回滚:整个过程在一个事务里); * - replace:先清空该班课表再写入(失败回滚:整个过程在一个事务里);
* - merge:按 `星期 + 开始时间 + 单双周` 去重,存在则更新时间/课程,否则新增。 * - merge:按 `星期 + 开始时间 + 单双周` 去重,存在则更新时间/课程,否则新增。
*/ */
export async function importClassPlan( export async function importClassPlan(user: TokenPayload, input: ClassPlanImportInput) {
user: TokenPayload, return importProfile(user, input.classId, input.payload, input.mode);
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,
};
} }
@@ -1,7 +1,7 @@
import { Router } from 'express'; import { Router } from 'express';
import { ApiError, sendOk } from '../../lib/http.js'; import { ApiError, sendOk } from '../../lib/http.js';
import { idParamSchema } from '../../lib/schemas.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 { validate, validatedBody, validatedParams, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js'; import { defineModule } from '../module.types.js';
import { import {
@@ -151,7 +151,7 @@ router.get('/time-layout', validate({ query: timeLayoutQuerySchema }), async (re
* POST /api/imports/time-layout - 导入 ClassIsland 时间配置 * POST /api/imports/time-layout - 导入 ClassIsland 时间配置
* mode=replace 覆盖 / merge 按开始时间合并;解析失败返回 400 且不修改原配置。 * 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<TimeLayoutImportInput>(req)); const result = await importTimeLayout(getAuthUser(req), validatedBody<TimeLayoutImportInput>(req));
sendOk( sendOk(
res, res,
@@ -163,7 +163,7 @@ router.post('/time-layout', validate({ body: timeLayoutImportSchema }), async (r
}); });
/** DELETE /api/imports/time-layout/:id - 删除某份时间配置 */ /** 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); const { id } = validatedParams<{ id: string }>(req);
await deleteTimeLayout(getAuthUser(req), id); await deleteTimeLayout(getAuthUser(req), id);
sendOk(res, { id }, '时间配置已删除'); sendOk(res, { id }, '时间配置已删除');
@@ -1,5 +1,6 @@
import * as XLSX from 'xlsx'; import * as XLSX from 'xlsx';
import { formatClassName, normalizeStudentNo, type TableImportKind } from '@classhelper/shared'; import { formatClassName, normalizeStudentNo, type TableImportKind } from '@classhelper/shared';
import { activeStudentWhere, isActiveStudent } from '../../lib/students.js';
import { prisma } from '../../lib/db.js'; import { prisma } from '../../lib/db.js';
import { env } from '../../config/env.js'; import { env } from '../../config/env.js';
import { ApiError } from '../../lib/http.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 parsed = parseTable(buffer, input.fileName);
const students = await prisma.student.findMany({ const students = await prisma.student.findMany({
where: { classId }, where: { classId, ...activeStudentWhere() },
select: { id: true, studentNo: true, name: true }, 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({ const existing = await prisma.student.findUnique({
where: { studentNo }, where: { studentNo },
select: { id: true, classId: true }, select: { id: true, classId: true, status: true },
}); });
if (existing && !isActiveStudent(existing))
throw ApiError.conflict('该学号已被历史学生占用,不能通过导入复用');
if (existing) { if (existing) {
if (input.mode === 'append') { if (input.mode === 'append') {
skipped += 1; skipped += 1;
@@ -737,7 +740,7 @@ export async function commitClassTeachers(
where: { archivedYearId: null }, where: { archivedYearId: null },
select: { id: true, name: true, teacherId: true }, select: { id: true, name: true, teacherId: true },
}); });
const classByName = new Map<string, { id: string; name: string; teacherId: string }>(); const classByName = new Map<string, { id: string; name: string; teacherId: string | null }>();
for (const item of classes) { for (const item of classes) {
const key = item.name.toLowerCase(); const key = item.name.toLowerCase();
if (classByName.has(key)) classByName.delete(key); if (classByName.has(key)) classByName.delete(key);
@@ -1,3 +1,4 @@
import { saveLayout } from '../class-plans/class-plans.service.js';
import { prisma } from '../../lib/db.js'; import { prisma } from '../../lib/db.js';
import { ApiError } from '../../lib/http.js'; import { ApiError } from '../../lib/http.js';
import type { TokenPayload } from '../../lib/jwt.js'; import type { TokenPayload } from '../../lib/jwt.js';
@@ -232,7 +233,7 @@ export function parseClassIslandTimeLayout(input: unknown): TimeLayoutParseResul
export interface TimeLayoutDto { export interface TimeLayoutDto {
id: string; id: string;
classId: string; classId: string | null;
name: string; name: string;
source: string; source: string;
items: ParsedTimeLayoutItem[]; items: ParsedTimeLayoutItem[];
@@ -242,10 +243,18 @@ export interface TimeLayoutDto {
function toDto(record: { function toDto(record: {
id: string; id: string;
classId: string; classId: string | null;
name: string; name: string;
source: string; source: string;
items: string; items: string;
points?: Array<{
position: number;
name: string;
startTime: string;
endTime: string;
timeType: string;
isHideDefault: boolean;
}>;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
}): TimeLayoutDto { }): TimeLayoutDto {
@@ -255,6 +264,21 @@ function toDto(record: {
} catch { } catch {
items = []; 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 { return {
id: record.id, id: record.id,
classId: record.classId, classId: record.classId,
@@ -284,13 +308,17 @@ function safeJson(text: string): unknown {
export async function listTimeLayouts(user: TokenPayload, classId: string): Promise<TimeLayoutDto[]> { export async function listTimeLayouts(user: TokenPayload, classId: string): Promise<TimeLayoutDto[]> {
await assertCanManageSchedule(user, classId); 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); return records.map(toDto);
} }
/** /**
* 导入(写入):解析 → 有错误直接 400(不写库)→ 按 replace/merge 落库。 * 导入(写入):解析 → 有错误直接 400(不写库)→ 按 replace/merge 落库。
* 权限与"课表管理"一致:管理员或本班班主任。 * 写入路由要求 timetable:manage,班级范围仍由本服务校验。
*/ */
export async function importTimeLayout( export async function importTimeLayout(
user: TokenPayload, user: TokenPayload,
@@ -312,76 +340,29 @@ export async function importTimeLayout(
} }
const name = input.name?.trim() || '默认时间表'; const name = input.name?.trim() || '默认时间表';
const existing = await prisma.timeLayout.findMany({ const current = await prisma.timeLayout.findFirst({where:{classId:input.classId,name},include:{points:{orderBy:{position:'asc'}}}});
where: { classId: input.classId }, const previous = current ? toDto(current).items : [];
orderBy: { updatedAt: 'desc' }, 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<string>();
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; const layout=await saveLayout({classId:input.classId,name,points},current?.id);
logger.info(`课表时间配置导入:班级=${input.classId} 模式=${input.mode} 共 ${combined.length} 个时间点`);
if (input.mode === 'merge' && current) { return {layout:toDto(layout),mode:input.mode,warnings:parsed.warnings,replaced:parsed.items.length-merged,merged};
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,
};
} }
export async function deleteTimeLayout(user: TokenPayload, layoutId: string): Promise<void> { export async function deleteTimeLayout(user: TokenPayload, layoutId: string): Promise<void> {
const record = await prisma.timeLayout.findUnique({ where: { id: layoutId } }); const record = await prisma.timeLayout.findUnique({ where: { id: layoutId } });
if (!record) throw ApiError.notFound('时间配置不存在'); if (!record) throw ApiError.notFound('时间配置不存在');
if (!record.classId) throw ApiError.forbidden('全校时间表请在时间表编辑器管理');
await assertCanManageSchedule(user, record.classId); await assertCanManageSchedule(user, record.classId);
if(await prisma.classPlan.count({where:{timeLayoutId:layoutId}}))throw ApiError.conflict('仍有课表使用该时间表');
await prisma.timeLayout.delete({ where: { id: layoutId } }); await prisma.timeLayout.delete({ where: { id: layoutId } });
} }
@@ -16,6 +16,7 @@ import {
classScopeWhere, classScopeWhere,
resolveClassScope, resolveClassScope,
} from '../../lib/access.js'; } from '../../lib/access.js';
import { activeStudentWhere } from '../../lib/students.js';
import { prisma } from '../../lib/db.js'; import { prisma } from '../../lib/db.js';
import { ApiError } from '../../lib/http.js'; import { ApiError } from '../../lib/http.js';
import type { TokenPayload } from '../../lib/jwt.js'; import type { TokenPayload } from '../../lib/jwt.js';
@@ -87,7 +88,7 @@ export async function listNotifications(
}, },
include: { include: {
creator: creatorSelect, creator: creatorSelect,
reads: isStaff ? true : { where: personalWhere }, reads: isStaff ? { where: { student: activeStudentWhere() } } : { where: personalWhere },
}, },
orderBy: [{ createdAt: 'desc' }], orderBy: [{ createdAt: 'desc' }],
take: 200, take: 200,
@@ -22,6 +22,8 @@ const keysSchema = z.array(permissionKeySchema).max(200, '一次最多提交 200
export const createPermissionGroupSchema = z.object({ export const createPermissionGroupSchema = z.object({
name: z.string().trim().min(1, '请输入权限组名称').max(40, '权限组名称最多 40 个字'), name: z.string().trim().min(1, '请输入权限组名称').max(40, '权限组名称最多 40 个字'),
description: z.string().trim().max(200, '描述最多 200 个字').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(), keys: keysSchema.optional(),
}); });
@@ -34,6 +36,8 @@ export const createPermissionGroupSchema = z.object({
export const updatePermissionGroupSchema = z.object({ export const updatePermissionGroupSchema = z.object({
name: z.string().trim().min(1, '请输入权限组名称').max(40, '权限组名称最多 40 个字').optional(), name: z.string().trim().min(1, '请输入权限组名称').max(40, '权限组名称最多 40 个字').optional(),
description: z.string().trim().max(200, '描述最多 200 个字').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(), keys: keysSchema.optional(),
}); });
@@ -36,6 +36,8 @@ const GROUP_SELECT = {
description: true, description: true,
builtin: true, builtin: true,
defaultRole: true, defaultRole: true,
scope: true,
scopeValue: true,
createdAt: true, createdAt: true,
updatedAt: true, updatedAt: true,
items: { select: { key: true } }, items: { select: { key: true } },
@@ -48,6 +50,8 @@ type GroupRow = {
description: string; description: string;
builtin: boolean; builtin: boolean;
defaultRole: string | null; defaultRole: string | null;
scope: string;
scopeValue: string;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
items: { key: string }[]; items: { key: string }[];
@@ -91,6 +95,8 @@ function normalizeKeys(keys: readonly string[] | undefined): PermissionKey[] {
function toGroupDto(row: GroupRow): PermissionGroupDto { function toGroupDto(row: GroupRow): PermissionGroupDto {
return { return {
id: row.id, id: row.id,
scope: row.scope as PermissionGroupDto['scope'],
scopeValue: row.scopeValue,
name: row.name, name: row.name,
description: row.description, description: row.description,
builtin: row.builtin, builtin: row.builtin,
@@ -156,6 +162,8 @@ export async function createGroup(input: CreatePermissionGroupRequest): Promise<
const created = await prisma.permissionGroup.create({ const created = await prisma.permissionGroup.create({
data: { data: {
name, name,
scope: input.scope ?? 'SELF_CLASS',
scopeValue: input.scopeValue ?? '',
description: input.description?.trim() ?? '', description: input.description?.trim() ?? '',
items: { create: keys.map((key) => ({ key })) }, items: { create: keys.map((key) => ({ key })) },
}, },
@@ -188,6 +196,8 @@ export async function updateGroup(
where: { id: groupId }, where: { id: groupId },
data: { data: {
...(name ? { name } : {}), ...(name ? { name } : {}),
...(input.scope ? { scope: input.scope } : {}),
...(input.scopeValue !== undefined ? { scopeValue: input.scopeValue } : {}),
...(input.description !== undefined ? { description: input.description.trim() } : {}), ...(input.description !== undefined ? { description: input.description.trim() } : {}),
}, },
}); });
+8
View File
@@ -1,3 +1,8 @@
import {
classPlansModule,
timeLayoutsModule,
classPlanGroupsModule,
} from './class-plans/class-plans.module.js';
import { aiModule } from './ai/ai.module.js'; import { aiModule } from './ai/ai.module.js';
import { archivesModule } from './archives/archives.module.js'; import { archivesModule } from './archives/archives.module.js';
import { authModule } from './auth/auth.module.js'; import { authModule } from './auth/auth.module.js';
@@ -34,6 +39,9 @@ export const apiModules: ApiModule[] = [
clientsModule, clientsModule,
coursesModule, coursesModule,
schedulesModule, schedulesModule,
classPlansModule,
timeLayoutsModule,
classPlanGroupsModule,
homeworksModule, homeworksModule,
notificationsModule, notificationsModule,
permissionsModule, permissionsModule,
@@ -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<ScheduleDto[]> {
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<string, ScheduleDto>();
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<ScheduleWeekView> {
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<ClassStatusDto> {
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<string, string>();
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<ScheduleDto> {
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<string, string>(),
};
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<ScheduleDto> {
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);
}
@@ -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 { sendCreated, sendOk } from '../../lib/http.js';
import { idParamSchema } from '../../lib/schemas.js'; import { idParamSchema } from '../../lib/schemas.js';
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js'; import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
@@ -17,6 +23,74 @@ import * as scheduleService from './schedules.service.js';
const router = Router(); const router = Router();
router.use(authenticate()); 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= - 课表列表 */ /** GET /api/schedules?classId=&week=&dayOfWeek= - 课表列表 */
router.get('/', validate({ query: listSchedulesQuerySchema }), async (req, res) => { router.get('/', validate({ query: listSchedulesQuerySchema }), async (req, res) => {
@@ -1,207 +1 @@
import { export * from './schedules.facade.js';
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<ScheduleDto[]> {
const scope = await resolveClassScope(user, options.classId);
const week = options.week;
const schedules = await prisma.schedule.findMany({
where: {
...classScopeWhere(scope),
...(week ? { weekStart: { lte: week }, weekEnd: { gte: week } } : {}),
// 单双周: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<ScheduleWeekView> {
// 当前周次按**本班**的口径算(管理员可以为每个班单独配开学日期与逐周区间)
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<ClassStatusDto> {
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<ClassStatusDto> {
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<ScheduleDto> {
// 教学周数由班级设置决定(班主任可调,默认 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<ScheduleDto> {
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<void> {
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<void> {
const course = await prisma.course.findUnique({ where: { id: courseId }, select: { classId: true } });
if (!course) throw ApiError.badRequest('课程不存在');
if (course.classId !== classId) throw ApiError.badRequest('该课程不属于所选班级');
}
@@ -7,6 +7,7 @@ import {
isAdmin, isAdmin,
resolveClassScope, resolveClassScope,
} from '../../lib/access.js'; } from '../../lib/access.js';
import { activeStudentWhere, isActiveStudent } from '../../lib/students.js';
import { prisma } from '../../lib/db.js'; import { prisma } from '../../lib/db.js';
import { ApiError } from '../../lib/http.js'; import { ApiError } from '../../lib/http.js';
import type { TokenPayload } from '../../lib/jwt.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({ const students = await prisma.student.findMany({
where: { where: {
...classScopeWhere(scope), ...classScopeWhere(scope),
...(options.status ? { status: options.status } : {}), ...activeStudentWhere(),
...(options.status ? { AND: [{ status: options.status }, activeStudentWhere()] } : {}),
// 默认不显示已归档(毕业 / 转出)的学生:他们属于归档页,不该混在在读名单里 // 默认不显示已归档(毕业 / 转出)的学生:他们属于归档页,不该混在在读名单里
...(options.includeArchived ? {} : { archivedYearId: null }), ...(options.includeArchived ? {} : { archivedYearId: null }),
...(options.keyword ...(options.keyword
@@ -58,7 +60,11 @@ export async function listStudents(user: TokenPayload, options: ListStudentsQuer
/** 按学号取学生(学生没账号,因此没有"按 id 取自己"的入口) */ /** 按学号取学生(学生没账号,因此没有"按 id 取自己"的入口) */
export async function findStudentByNo(studentNo: string, classId?: string): Promise<StudentDto | null> { export async function findStudentByNo(studentNo: string, classId?: string): Promise<StudentDto | null> {
const record = await prisma.student.findFirst({ const record = await prisma.student.findFirst({
where: { studentNo: normalizeStudentNo(studentNo), ...(classId ? { classId } : {}) }, where: {
...activeStudentWhere(),
studentNo: normalizeStudentNo(studentNo),
...(classId ? { classId } : {}),
},
include: withClass, include: withClass,
}); });
return record ? toStudentDto(record) : null; return record ? toStudentDto(record) : null;
@@ -98,7 +104,7 @@ export async function updateStudent(
input: UpdateStudentInput, input: UpdateStudentInput,
): Promise<StudentDto> { ): Promise<StudentDto> {
const student = await prisma.student.findUnique({ where: { id: studentId } }); 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); await assertCanManageRoster(user, student.classId ?? undefined);
if (input.studentNo && input.studentNo !== student.studentNo) { if (input.studentNo && input.studentNo !== student.studentNo) {
@@ -155,7 +161,9 @@ export async function transferStudents(
): Promise<{ moved: number; transfers: StudentTransferDto[] }> { ): Promise<{ moved: number; transfers: StudentTransferDto[] }> {
if (!isAdmin(user)) throw ApiError.forbidden('只有管理员可以调班'); 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) { if (students.length !== input.studentIds.length) {
throw ApiError.badRequest('部分学生不存在,请刷新后重试'); throw ApiError.badRequest('部分学生不存在,请刷新后重试');
} }
@@ -264,6 +272,7 @@ export async function listTransfers(
} }
const where = { const where = {
student: activeStudentWhere(),
...(options.studentId ? { studentId: options.studentId } : {}), ...(options.studentId ? { studentId: options.studentId } : {}),
...(options.classId ? { OR: [{ fromClassId: options.classId }, { toClassId: options.classId }] } : {}), ...(options.classId ? { OR: [{ fromClassId: options.classId }, { toClassId: options.classId }] } : {}),
}; };
@@ -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 };
}
@@ -1,3 +1,4 @@
import { createAutostartController } from './autostart.js';
import { spawnSync } from 'node:child_process'; import { spawnSync } from 'node:child_process';
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
@@ -9,7 +10,6 @@ import {
} from '@classhelper/shared'; } from '@classhelper/shared';
import { env } from '../../config/env.js'; import { env } from '../../config/env.js';
import { prisma } from '../../lib/db.js'; import { prisma } from '../../lib/db.js';
import { ApiError } from '../../lib/http.js';
import type { TokenPayload } from '../../lib/jwt.js'; import type { TokenPayload } from '../../lib/jwt.js';
import { logger } from '../../lib/logger.js'; import { logger } from '../../lib/logger.js';
import { SETTING_KEYS, getBooleanSetting, setBooleanSetting } from '../../lib/settings.js'; import { SETTING_KEYS, getBooleanSetting, setBooleanSetting } from '../../lib/settings.js';
@@ -25,21 +25,12 @@ import type { UpdateAutostartInput, UpdateSystemSettingsInput } from './system.s
* 1. 只在"装好的形态"里放行(开发机、免安装包、容器里返回 `supported:false`); * 1. 只在"装好的形态"里放行(开发机、免安装包、容器里返回 `supported:false`);
* 2. 失败原因是运维性质的(权限不足、单元文件不存在),一律带原文回给管理员,不吞成 500。 * 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 } { function runCommand(file: string, args: string[]): { ok: boolean; output: string } {
const result = spawnSync(file, args, { encoding: 'utf8', windowsHide: true }); const result = spawnSync(file, args, { encoding: 'utf8', windowsHide: true });
const output = `${result.stdout ?? ''}${result.stderr ?? ''}`.trim(); const output = `${result.stdout ?? ''}${result.stderr ?? ''}`.trim();
return { ok: result.status === 0, output: output || (result.error ? result.error.message : '') }; 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<SystemSettingsDto> { export async function getSystemSettings(): Promise<SystemSettingsDto> {
return { developerMode: await getBooleanSetting(SETTING_KEYS.developerMode, false) }; return { developerMode: await getBooleanSetting(SETTING_KEYS.developerMode, false) };
} }
@@ -55,74 +46,17 @@ export async function updateSystemSettings(
return getSystemSettings(); return getSystemSettings();
} }
const autostart = createAutostartController({
platform: process.platform,
root: path.resolve(env.serverRoot, '..'),
exists: fs.existsSync,
run: runCommand,
});
export async function getAutostartStatus(): Promise<AutostartStatusDto> { export async function getAutostartStatus(): Promise<AutostartStatusDto> {
if (process.platform === 'win32') { return autostart.read();
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})不支持切换开机自启`,
};
}
export async function updateAutostart(input: UpdateAutostartInput): Promise<AutostartStatusDto> { export async function updateAutostart(input: UpdateAutostartInput): Promise<AutostartStatusDto> {
const current = await getAutostartStatus(); return autostart.write(input.enabled);
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();
} }
export async function getDiagnostics(): Promise<SystemDiagnosticsDto> { export async function getDiagnostics(): Promise<SystemDiagnosticsDto> {
@@ -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<typeof promotionConfigSchema>) {
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<typeof setInterval> | 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();
}
@@ -1,3 +1,10 @@
import { z } from 'zod';
import {
promotionStatus,
promotionConfigSchema,
configurePromotion,
runPromotion,
} from './promotion.service.js';
import { Router } from 'express'; import { Router } from 'express';
import { sendOk } from '../../lib/http.js'; import { sendOk } from '../../lib/http.js';
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js'; import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
@@ -15,6 +22,23 @@ import * as termService from './term.service.js';
const router = Router(); const router = Router();
router.use(authenticate()); 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)),
);
/** /**
* 学期周次。 * 学期周次。
@@ -17,6 +17,7 @@ export const termQuerySchema = z.object({
}); });
export const updateTermWeeksSchema = z.object({ export const updateTermWeeksSchema = z.object({
maxTermWeeks: z.coerce.number().int().min(1).max(60).optional(),
classId: z.string().min(1).nullish(), classId: z.string().min(1).nullish(),
termStartDate: termStartDateSchema.optional(), termStartDate: termStartDateSchema.optional(),
/** 整表覆盖:传空数组 = 清空逐周配置,回到按开学日期线性推算 */ /** 整表覆盖:传空数组 = 清空逐周配置,回到按开学日期线性推算 */
@@ -10,7 +10,7 @@ import { prisma } from '../../lib/db.js';
import { ApiError } from '../../lib/http.js'; import { ApiError } from '../../lib/http.js';
import type { TokenPayload } from '../../lib/jwt.js'; import type { TokenPayload } from '../../lib/jwt.js';
import { logger } from '../../lib/logger.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 { SETTING_KEYS, setSetting } from '../../lib/settings.js';
import { emitToClass } from '../../realtime/bus.js'; import { emitToClass } from '../../realtime/bus.js';
import { SOCKET_EVENTS } from '@classhelper/shared'; import { SOCKET_EVENTS } from '@classhelper/shared';
@@ -40,7 +40,7 @@ export async function getTermWeeks(user: TokenPayload, classId?: string | null):
const scope = scopeOf(classId); const scope = scopeOf(classId);
if (scope) await assertClassAccess(user, scope); 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' } }), prisma.termWeek.findMany({ where: { classId: scope }, orderBy: { weekNumber: 'asc' } }),
scope scope
? prisma.class.findUnique({ where: { id: scope }, select: { termStartDate: true, termWeeks: true } }) ? 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, classId: scope,
termStartDate: context.termStartDate || env.termStartDate, termStartDate: context.termStartDate || env.termStartDate,
currentWeek: resolveCurrentWeek(new Date(), context), 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, configured: weeks.length > 0,
weeks: weeks.map((item) => ({ weeks: weeks.map((item) => ({
weekNumber: item.weekNumber, weekNumber: item.weekNumber,
@@ -76,6 +77,13 @@ export async function updateTermWeeks(
assertCanManageClasses(user); assertCanManageClasses(user);
const scope = scopeOf(input.classId); 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); const errors = validateTermWeeks(input.weeks);
if (errors.length > 0) throw ApiError.badRequest(errors[0]!, { errors }); 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} 执行`); logger.info(`学期周次已更新:范围=${scope || '全校默认'} 周数=${input.weeks.length} 由 ${user.name} 执行`);
if (input.maxTermWeeks !== undefined) await setSetting(SETTING_KEYS.termMaxWeeks, String(maximum));
await publishTermChange(scope); await publishTermChange(scope);
return getTermWeeks(user, scope || null); return getTermWeeks(user, scope || null);
} }
@@ -1,3 +1,5 @@
import { getReleaseProgress, setReleaseProgress } from './release-progress.js';
import { Transform } from 'node:stream';
import crypto from 'node:crypto'; import crypto from 'node:crypto';
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
@@ -81,9 +83,7 @@ async function sha256Of(file: string): Promise<string> {
/** GET /api/update/client/releases - 已缓存的安装包(按版本从新到旧) */ /** GET /api/update/client/releases - 已缓存的安装包(按版本从新到旧) */
export async function listReleases(): Promise<ClientReleaseDto[]> { export async function listReleases(): Promise<ClientReleaseDto[]> {
const rows = (await prisma.clientRelease.findMany()) as unknown as ReleaseRow[]; const rows = (await prisma.clientRelease.findMany()) as unknown as ReleaseRow[];
return rows return rows.sort((a, b) => compareVersions(b.version, a.version)).map((row) => toReleaseDto(row));
.sort((a, b) => compareVersions(b.version, a.version))
.map((row) => toReleaseDto(row));
} }
/** 服务端缓存的**最新**版本(推送更新时省略 version 就用它) */ /** 服务端缓存的**最新**版本(推送更新时省略 version 就用它) */
@@ -116,7 +116,9 @@ export async function resolveUpdateInfo(version: string): Promise<ClientUpdateIn
} }
/** 绝对路径(下载路由用);文件不存在返回 null */ /** 绝对路径(下载路由用);文件不存在返回 null */
export async function resolveReleaseFile(version: string): Promise<{ file: string; fileName: string } | null> { export async function resolveReleaseFile(
version: string,
): Promise<{ file: string; fileName: string } | null> {
const row = (await prisma.clientRelease.findUnique({ where: { version } })) as ReleaseRow | null; const row = (await prisma.clientRelease.findUnique({ where: { version } })) as ReleaseRow | null;
if (!row) return null; if (!row) return null;
const file = releaseFilePath(row.version, row.fileName); const file = releaseFilePath(row.version, row.fileName);
@@ -161,7 +163,9 @@ export async function registerRelease(
}, },
})) as ReleaseRow; })) 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); return toReleaseDto(row);
} }
@@ -170,7 +174,10 @@ export async function fetchReleaseFromGithub(
userId: string, userId: string,
input: FetchClientReleaseInput, input: FetchClientReleaseInput,
): Promise<ClientReleaseDto> { ): Promise<ClientReleaseDto> {
if (['downloading', 'verifying'].includes(getReleaseProgress().stage))
throw ApiError.conflict('已有安装包正在下载');
const version = input.version; const version = input.version;
setReleaseProgress({ version, stage: 'downloading', downloadedBytes: 0, totalBytes: 0, error: '' });
const fileName = clientReleaseFileName(version); const fileName = clientReleaseFileName(version);
const url = clientReleaseDownloadUrl(version); const url = clientReleaseDownloadUrl(version);
const target = releaseFilePath(version, fileName); const target = releaseFilePath(version, fileName);
@@ -199,14 +206,24 @@ export async function fetchReleaseFromGithub(
} }
try { 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) { } catch (error) {
fs.rmSync(target, { force: true }); fs.rmSync(target, { force: true });
throw ApiError.badRequest(`写入安装包失败:${error instanceof Error ? error.message : String(error)}`); throw ApiError.badRequest(`写入安装包失败:${error instanceof Error ? error.message : String(error)}`);
} }
// 落盘后统一走登记:sha256 / 大小 / 落库都在同一处算 // 落盘后统一走登记: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;
} }
/** 删除一份缓存的安装包(元数据 + 磁盘文件) */ /** 删除一份缓存的安装包(元数据 + 磁盘文件) */
@@ -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<ReleaseProgressDto>) {
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();
}
}
@@ -1,3 +1,4 @@
import { getReleaseProgress, setReleaseProgress } from './release-progress.js';
import { Router } from 'express'; import { Router } from 'express';
import { sendCreated, sendOk, ApiError } from '../../lib/http.js'; import { sendCreated, sendOk, ApiError } from '../../lib/http.js';
import { import {
@@ -22,6 +23,9 @@ import {
const router = Router(); const router = Router();
router.use(authenticate()); router.use(authenticate());
router.get('/client/progress', requirePermission('client:update'), async (_req, res) =>
sendOk(res, getReleaseProgress()),
);
/** /**
* GET /api/update/check?force=1 - 检查 GitHub 上有没有比本机新的版本 * GET /api/update/check?force=1 - 检查 GitHub 上有没有比本机新的版本
@@ -64,12 +68,24 @@ router.get(
*/ */
router.post( router.post(
'/client/fetch', '/client/fetch',
requirePermission('client:update-push'), requirePermission('client:update'),
validate({ body: fetchClientReleaseSchema }), validate({ body: fetchClientReleaseSchema }),
async (req, res) => { async (req, res) => {
const user = getAuthUser(req); const user = getAuthUser(req);
const input = validatedBody<FetchClientReleaseInput>(req); const input = validatedBody<FetchClientReleaseInput>(req);
try {
sendCreated(res, await clientReleases.fetchReleaseFromGithub(user.sub, input), '安装包已下载并登记'); 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( router.post(
'/client/releases', '/client/releases',
requirePermission('client:update-push'), requirePermission('client:update'),
validate({ body: registerClientReleaseSchema }), validate({ body: registerClientReleaseSchema }),
async (req, res) => { async (req, res) => {
const user = getAuthUser(req); const user = getAuthUser(req);
@@ -93,7 +109,7 @@ router.post(
/** DELETE /api/update/client/releases/:version - 删除缓存(元数据 + 磁盘文件) */ /** DELETE /api/update/client/releases/:version - 删除缓存(元数据 + 磁盘文件) */
router.delete( router.delete(
'/client/releases/:version', '/client/releases/:version',
requirePermission('client:update-push'), requirePermission('client:update'),
validate({ params: clientReleaseVersionParamSchema }), validate({ params: clientReleaseVersionParamSchema }),
async (req, res) => { async (req, res) => {
const { version } = validatedParams<{ version: string }>(req); const { version } = validatedParams<{ version: string }>(req);