fix(grades,term): 封闭实时成绩泄露并完善并发升级审计

This commit is contained in:
LoveChengke committed 2026-10-17 12:55:40 +08:00
1 parent b990701cde
commit 57069a9a75
8 files changed
+152 -57

No files matched your search

+3 -2
View File
@@ -23,7 +23,7 @@ export async function loadPlans(classId: string) {
export function projectPlan(plan: PlanRow): ScheduleDto[] {
return plan.entries
.filter((e) => e.isEnabled && e.course && e.timePoint.timeType === 'LESSON')
.map((e) => ({
.map<ScheduleDto>((e) => ({
id: e.legacyScheduleId || e.id,
classId: plan.classId,
courseId: e.courseId!,
@@ -43,7 +43,8 @@ export function projectPlan(plan: PlanRow): ScheduleDto[] {
source: 'manual',
createdAt: plan.createdAt.toISOString(),
updatedAt: plan.updatedAt.toISOString(),
}));
}))
.sort((a, b) => a.startTime.localeCompare(b.startTime));
}
/**
@@ -1,3 +1,4 @@
import { activeStudentWhere } from '../../lib/students.js';
import type { DashboardSummary, HomeworkDto, NotificationDto } from '@classhelper/shared';
import {
assertClassAccess,
@@ -64,12 +65,12 @@ export async function getDashboardSummary(user: TokenPayload): Promise<Dashboard
] = await Promise.all([
// 学生数只算在读的:已毕业/转出的学生不属于"本班现在有多少人"
prisma.class.count({ where: classWhere }),
prisma.student.count({ where: { ...where, status: 'active', archivedYearId: null } }),
prisma.student.count({ where: { ...where, ...activeStudentWhere(), archivedYearId: null } }),
prisma.course.count({ where }),
prisma.classPlan.count({ where }),
prisma.homework.count({ where }),
prisma.notification.count({ where }),
prisma.grade.count({ where }),
prisma.grade.count({ where: { ...where, student: activeStudentWhere() } }),
prisma.notification.count({ where: { ...where, reads: { none: personalWhere } } }),
device
? prisma.homework.count({
@@ -87,7 +88,7 @@ export async function getDashboardSummary(user: TokenPayload): Promise<Dashboard
include: {
course: courseSelect,
creator: creatorSelect,
statuses: device ? { where: personalWhere } : true,
statuses: { where: device ? personalWhere : { student: activeStudentWhere() } },
},
orderBy: { createdAt: 'desc' },
take: 5,
@@ -38,6 +38,19 @@ import type {
UpdateGradeLevelsInput,
} from './grades.schemas.js';
/** 个人查询关闭时,实时通道同样只发送刷新通知,不能绕过 HTTP 明细闸门。 */
async function emitGradeUpdated(classId: string, payload: unknown) {
const cls = await prisma.class.findUnique({
where: { id: classId },
select: { studentGradeQueryEnabled: true },
});
emitToClass(
classId,
SOCKET_EVENTS.gradeUpdated,
cls?.studentGradeQueryEnabled ? payload : { classId, collective: true },
);
}
const courseSelect = { select: { id: true, name: true } } as const;
const studentSelect = { select: { id: true, studentNo: true, name: true } } as const;
const classSelect = { select: { name: true } } as const;
@@ -230,7 +243,7 @@ export async function createGrade(user: TokenPayload, input: CreateGradeInput):
publishedAt: parseOptionalDate(input.publishedAt) ?? new Date(),
});
emitToClass(dto.classId, SOCKET_EVENTS.gradeUpdated, dto);
await emitGradeUpdated(dto.classId, dto);
return dto;
}
@@ -261,7 +274,7 @@ export async function bulkCreateGrades(user: TokenPayload, input: BulkCreateGrad
}
if (results[0]) {
emitToClass(input.classId, SOCKET_EVENTS.gradeUpdated, {
await emitGradeUpdated(input.classId, {
...results[0],
bulk: true,
count: results.length,
@@ -311,7 +324,7 @@ export async function updateGrade(
});
const dto = toGradeDto(updated);
emitToClass(dto.classId, SOCKET_EVENTS.gradeUpdated, dto);
await emitGradeUpdated(dto.classId, dto);
return dto;
}
@@ -321,7 +334,7 @@ export async function deleteGrade(user: TokenPayload, gradeId: string): Promise<
await assertCanManageSubjectContent(user, current.classId, current.courseId, '删除成绩');
await prisma.grade.delete({ where: { id: gradeId } });
emitToClass(current.classId, SOCKET_EVENTS.gradeUpdated, {
await emitGradeUpdated(current.classId, {
...toGradeDto({ ...current, course: null, student: null, class: null }),
deleted: true,
});
@@ -368,8 +381,7 @@ export async function updateGradeLevels(
const classId = byId.get(item.id)!.classId;
counts.set(classId, (counts.get(classId) ?? 0) + 1);
}
for (const [classId, count] of counts)
emitToClass(classId, SOCKET_EVENTS.gradeUpdated, { bulk: true, count });
for (const [classId, count] of counts) await emitGradeUpdated(classId, { bulk: true, count });
return { updated: input.items.length };
}
@@ -400,7 +412,7 @@ export async function updateGradeLevels(
),
);
emitToClass(classId, SOCKET_EVENTS.gradeUpdated, { bulk: true, count: records.length });
await emitGradeUpdated(classId, { bulk: true, count: records.length });
return { updated: records.length };
}
@@ -151,23 +151,33 @@ router.get('/time-layout', validate({ query: timeLayoutQuerySchema }), async (re
* POST /api/imports/time-layout - 导入 ClassIsland 时间配置
* mode=replace 覆盖 / merge 按开始时间合并;解析失败返回 400 且不修改原配置。
*/
router.post('/time-layout', requirePermission('timetable:manage'), validate({ body: timeLayoutImportSchema }), async (req, res) => {
const result = await importTimeLayout(getAuthUser(req), validatedBody<TimeLayoutImportInput>(req));
sendOk(
res,
result,
result.mode === 'merge'
? `合并导入成功:合并 ${result.merged} 节 / 新增 ${result.replaced} 节`
: `覆盖导入成功:共 ${result.replaced} 节`,
);
});
router.post(
'/time-layout',
requirePermission('timetable:manage'),
validate({ body: timeLayoutImportSchema }),
async (req, res) => {
const result = await importTimeLayout(getAuthUser(req), validatedBody<TimeLayoutImportInput>(req));
sendOk(
res,
result,
result.mode === 'merge'
? `合并导入成功:合并 ${result.merged} 节 / 新增 ${result.replaced} 节`
: `覆盖导入成功:共 ${result.replaced} 节`,
);
},
);
/** DELETE /api/imports/time-layout/:id - 删除某份时间配置 */
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 }, '时间配置已删除');
});
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 }, '时间配置已删除');
},
);
/**
* POST /api/imports/class-plan/preview - 预览 ClassIsland 课程表 JSON(只解析不写库)
@@ -340,22 +340,55 @@ export async function importTimeLayout(
}
const name = input.name?.trim() || '默认时间表';
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 = await prisma.timeLayout.findFirst({
where: { classId: input.classId, name },
include: { points: { orderBy: { position: 'asc' } } },
});
const layout=await saveLayout({classId:input.classId,name,points},current?.id);
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 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};
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> {
@@ -363,6 +396,7 @@ export async function deleteTimeLayout(user: TokenPayload, layoutId: string): Pr
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('仍有课表使用该时间表');
if (await prisma.classPlan.count({ where: { timeLayoutId: layoutId } }))
throw ApiError.conflict('仍有课表使用该时间表');
await prisma.timeLayout.delete({ where: { id: layoutId } });
}
@@ -1,3 +1,4 @@
import { randomUUID } from 'node:crypto';
import fs from 'node:fs/promises';
import path from 'node:path';
import { z } from 'zod';
@@ -61,11 +62,16 @@ export async function runPromotion(year: number, dryRun = false) {
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 snapshotPath = path.join(dir, `${year}-${Date.now()}-${randomUUID()}.json`);
try {
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(snapshotPath, JSON.stringify(await exportSnapshot(prisma, env.databaseProvider)), {
flag: 'wx',
});
} catch (error) {
await recordPromotionFailure(year, snapshotPath, error, classes.length, studentCount);
throw ApiError.internal(`执行前快照失败,未升级任何班级:${String(error)}`);
}
const { config } = await promotionStatus();
const passwordHash = await hashPassword(env.defaultClassPassword);
const maxWeeks = await getMaxTermWeeks();
@@ -149,17 +155,37 @@ export async function runPromotion(year: number, dryRun = false) {
} 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 },
});
await recordPromotionFailure(year, snapshotPath, error, classes.length, studentCount);
throw ApiError.internal(`学年升级失败,事务已回滚:${String(error)}`);
}
await notifyScheduleChanged();
return prisma.promotionRun.findUniqueOrThrow({ where: { year } });
}
/** 失败也留可见审计;事务内检查,不能把并发已成功的年份覆盖成失败。 */
async function recordPromotionFailure(
year: number,
snapshotPath: string,
error: unknown,
classCount: number,
studentCount: number,
) {
await prisma.$transaction(async (tx) => {
const current = await tx.promotionRun.findUnique({ where: { year } });
if (current?.status === 'done') return;
const data = {
status: 'failed',
snapshotPath,
message: String(error),
classCount,
studentCount,
finishedAt: new Date(),
};
if (current) await tx.promotionRun.update({ where: { year }, data });
else await tx.promotionRun.create({ data: { year, ...data } });
});
}
let timer: ReturnType<typeof setInterval> | null = null;
export async function startPromotionScheduler() {
if (timer) return;