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