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[] { export function projectPlan(plan: PlanRow): ScheduleDto[] {
return plan.entries return plan.entries
.filter((e) => e.isEnabled && e.course && e.timePoint.timeType === 'LESSON') .filter((e) => e.isEnabled && e.course && e.timePoint.timeType === 'LESSON')
.map((e) => ({ .map<ScheduleDto>((e) => ({
id: e.legacyScheduleId || e.id, id: e.legacyScheduleId || e.id,
classId: plan.classId, classId: plan.classId,
courseId: e.courseId!, courseId: e.courseId!,
@@ -43,7 +43,8 @@ export function projectPlan(plan: PlanRow): ScheduleDto[] {
source: 'manual', source: 'manual',
createdAt: plan.createdAt.toISOString(), createdAt: plan.createdAt.toISOString(),
updatedAt: plan.updatedAt.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 type { DashboardSummary, HomeworkDto, NotificationDto } from '@classhelper/shared';
import { import {
assertClassAccess, assertClassAccess,
@@ -64,12 +65,12 @@ export async function getDashboardSummary(user: TokenPayload): Promise<Dashboard
] = await Promise.all([ ] = await Promise.all([
// 学生数只算在读的:已毕业/转出的学生不属于"本班现在有多少人" // 学生数只算在读的:已毕业/转出的学生不属于"本班现在有多少人"
prisma.class.count({ where: classWhere }), 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.course.count({ where }),
prisma.classPlan.count({ where }), prisma.classPlan.count({ where }),
prisma.homework.count({ where }), prisma.homework.count({ where }),
prisma.notification.count({ where }), prisma.notification.count({ where }),
prisma.grade.count({ where }), prisma.grade.count({ where: { ...where, student: activeStudentWhere() } }),
prisma.notification.count({ where: { ...where, reads: { none: personalWhere } } }), prisma.notification.count({ where: { ...where, reads: { none: personalWhere } } }),
device device
? prisma.homework.count({ ? prisma.homework.count({
@@ -87,7 +88,7 @@ export async function getDashboardSummary(user: TokenPayload): Promise<Dashboard
include: { include: {
course: courseSelect, course: courseSelect,
creator: creatorSelect, creator: creatorSelect,
statuses: device ? { where: personalWhere } : true, statuses: { where: device ? personalWhere : { student: activeStudentWhere() } },
}, },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
take: 5, take: 5,
@@ -38,6 +38,19 @@ import type {
UpdateGradeLevelsInput, UpdateGradeLevelsInput,
} from './grades.schemas.js'; } 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 courseSelect = { select: { id: true, name: true } } as const;
const studentSelect = { select: { id: true, studentNo: true, name: true } } as const; const studentSelect = { select: { id: true, studentNo: true, name: true } } as const;
const classSelect = { select: { 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(), publishedAt: parseOptionalDate(input.publishedAt) ?? new Date(),
}); });
emitToClass(dto.classId, SOCKET_EVENTS.gradeUpdated, dto); await emitGradeUpdated(dto.classId, dto);
return dto; return dto;
} }
@@ -261,7 +274,7 @@ export async function bulkCreateGrades(user: TokenPayload, input: BulkCreateGrad
} }
if (results[0]) { if (results[0]) {
emitToClass(input.classId, SOCKET_EVENTS.gradeUpdated, { await emitGradeUpdated(input.classId, {
...results[0], ...results[0],
bulk: true, bulk: true,
count: results.length, count: results.length,
@@ -311,7 +324,7 @@ export async function updateGrade(
}); });
const dto = toGradeDto(updated); const dto = toGradeDto(updated);
emitToClass(dto.classId, SOCKET_EVENTS.gradeUpdated, dto); await emitGradeUpdated(dto.classId, dto);
return dto; return dto;
} }
@@ -321,7 +334,7 @@ export async function deleteGrade(user: TokenPayload, gradeId: string): Promise<
await assertCanManageSubjectContent(user, current.classId, current.courseId, '删除成绩'); await assertCanManageSubjectContent(user, current.classId, current.courseId, '删除成绩');
await prisma.grade.delete({ where: { id: gradeId } }); 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 }), ...toGradeDto({ ...current, course: null, student: null, class: null }),
deleted: true, deleted: true,
}); });
@@ -368,8 +381,7 @@ export async function updateGradeLevels(
const classId = byId.get(item.id)!.classId; const classId = byId.get(item.id)!.classId;
counts.set(classId, (counts.get(classId) ?? 0) + 1); counts.set(classId, (counts.get(classId) ?? 0) + 1);
} }
for (const [classId, count] of counts) for (const [classId, count] of counts) await emitGradeUpdated(classId, { bulk: true, count });
emitToClass(classId, SOCKET_EVENTS.gradeUpdated, { bulk: true, count });
return { updated: input.items.length }; 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 }; return { updated: records.length };
} }
@@ -151,23 +151,33 @@ router.get('/time-layout', validate({ query: timeLayoutQuerySchema }), async (re
* POST /api/imports/time-layout - 导入 ClassIsland 时间配置 * POST /api/imports/time-layout - 导入 ClassIsland 时间配置
* mode=replace 覆盖 / merge 按开始时间合并;解析失败返回 400 且不修改原配置。 * mode=replace 覆盖 / merge 按开始时间合并;解析失败返回 400 且不修改原配置。
*/ */
router.post('/time-layout', requirePermission('timetable:manage'), validate({ body: timeLayoutImportSchema }), async (req, res) => { router.post(
const result = await importTimeLayout(getAuthUser(req), validatedBody<TimeLayoutImportInput>(req)); '/time-layout',
sendOk( requirePermission('timetable:manage'),
res, validate({ body: timeLayoutImportSchema }),
result, async (req, res) => {
result.mode === 'merge' const result = await importTimeLayout(getAuthUser(req), validatedBody<TimeLayoutImportInput>(req));
? `合并导入成功:合并 ${result.merged} 节 / 新增 ${result.replaced} 节` sendOk(
: `覆盖导入成功:共 ${result.replaced} 节`, res,
); result,
}); result.mode === 'merge'
? `合并导入成功:合并 ${result.merged} 节 / 新增 ${result.replaced} 节`
: `覆盖导入成功:共 ${result.replaced} 节`,
);
},
);
/** DELETE /api/imports/time-layout/:id - 删除某份时间配置 */ /** DELETE /api/imports/time-layout/:id - 删除某份时间配置 */
router.delete('/time-layout/:id', requirePermission('timetable:manage'), validate({ params: idParamSchema }), async (req, res) => { router.delete(
const { id } = validatedParams<{ id: string }>(req); '/time-layout/:id',
await deleteTimeLayout(getAuthUser(req), id); requirePermission('timetable:manage'),
sendOk(res, { id }, '时间配置已删除'); 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(只解析不写库) * POST /api/imports/class-plan/preview - 预览 ClassIsland 课程表 JSON(只解析不写库)
@@ -340,22 +340,55 @@ export async function importTimeLayout(
} }
const name = input.name?.trim() || '默认时间表'; const name = input.name?.trim() || '默认时间表';
const current = await prisma.timeLayout.findFirst({where:{classId:input.classId,name},include:{points:{orderBy:{position:'asc'}}}}); const current = await prisma.timeLayout.findFirst({
const previous = current ? toDto(current).items : []; where: { classId: input.classId, name },
const starts=new Set(parsed.items.map(p=>p.startTime)); include: { points: { orderBy: { position: 'asc' } } },
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); 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} 个时间点`); 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> { 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) throw ApiError.notFound('时间配置不存在');
if (!record.classId) throw ApiError.forbidden('全校时间表请在时间表编辑器管理'); if (!record.classId) throw ApiError.forbidden('全校时间表请在时间表编辑器管理');
await assertCanManageSchedule(user, record.classId); await assertCanManageSchedule(user, record.classId);
if(await prisma.classPlan.count({where:{timeLayoutId:layoutId}}))throw ApiError.conflict('仍有课表使用该时间表'); if (await prisma.classPlan.count({ where: { timeLayoutId: layoutId } }))
throw ApiError.conflict('仍有课表使用该时间表');
await prisma.timeLayout.delete({ where: { id: layoutId } }); await prisma.timeLayout.delete({ where: { id: layoutId } });
} }
@@ -1,3 +1,4 @@
import { randomUUID } from 'node:crypto';
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import { z } from 'zod'; 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'), env.sqliteFilePath ? path.dirname(env.sqliteFilePath) : path.resolve(env.serverRoot, '..', 'data'),
'promotion-snapshots', 'promotion-snapshots',
); );
await fs.mkdir(dir, { recursive: true }); const snapshotPath = path.join(dir, `${year}-${Date.now()}-${randomUUID()}.json`);
const snapshotPath = path.join(dir, `${year}-${Date.now()}.json`); try {
await fs.writeFile(snapshotPath, JSON.stringify(await exportSnapshot(prisma, env.databaseProvider)), { await fs.mkdir(dir, { recursive: true });
flag: 'wx', 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 { config } = await promotionStatus();
const passwordHash = await hashPassword(env.defaultClassPassword); const passwordHash = await hashPassword(env.defaultClassPassword);
const maxWeeks = await getMaxTermWeeks(); const maxWeeks = await getMaxTermWeeks();
@@ -149,17 +155,37 @@ export async function runPromotion(year: number, dryRun = false) {
} catch (error) { } catch (error) {
if ((await prisma.promotionRun.findUnique({ where: { year } }))?.status === 'done') if ((await prisma.promotionRun.findUnique({ where: { year } }))?.status === 'done')
return prisma.promotionRun.findUniqueOrThrow({ where: { year } }); return prisma.promotionRun.findUniqueOrThrow({ where: { year } });
await prisma.promotionRun.upsert({ await recordPromotionFailure(year, snapshotPath, error, classes.length, studentCount);
where: { year },
create: { year, status: 'failed', snapshotPath, message: String(error) },
update: { status: 'failed', message: String(error), snapshotPath },
});
throw ApiError.internal(`学年升级失败,事务已回滚:${String(error)}`); throw ApiError.internal(`学年升级失败,事务已回滚:${String(error)}`);
} }
await notifyScheduleChanged(); await notifyScheduleChanged();
return prisma.promotionRun.findUniqueOrThrow({ where: { year } }); 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; let timer: ReturnType<typeof setInterval> | null = null;
export async function startPromotionScheduler() { export async function startPromotionScheduler() {
if (timer) return; if (timer) return;
+7 -2
View File
@@ -1,4 +1,7 @@
import type { PermissionScope as ContractPermissionScope, ReleaseProgressDto as ContractReleaseProgressDto } from './class-plan.js'; import type {
PermissionScope as ContractPermissionScope,
ReleaseProgressDto as ContractReleaseProgressDto,
} from './class-plan.js';
/** /**
* 三端共享的领域类型定义。 * 三端共享的领域类型定义。
* 这里的类型是后端 REST API / WebSocket 契约的唯一来源, * 这里的类型是后端 REST API / WebSocket 契约的唯一来源,
@@ -885,6 +888,8 @@ export interface BulkCreateHomeworkRequest {
/* ------------------------------------------------------------------ 成绩 */ /* ------------------------------------------------------------------ 成绩 */
export type GradeUpdateEvent = GradeDto | { classId: string; collective: true };
export interface GradeDto { export interface GradeDto {
id: string; id: string;
classId: string; classId: string;
@@ -1194,7 +1199,7 @@ export interface ServerToClientEvents {
/** deleted=true 表示该作业已被删除,客户端应从本地缓存中移除 */ /** deleted=true 表示该作业已被删除,客户端应从本地缓存中移除 */
'homework:updated': (payload: HomeworkDto & { deleted?: boolean }) => void; 'homework:updated': (payload: HomeworkDto & { deleted?: boolean }) => void;
'homework:status': (payload: HomeworkStatusDto & { classId: string }) => void; 'homework:status': (payload: HomeworkStatusDto & { classId: string }) => void;
'grade:updated': (payload: GradeDto) => void; 'grade:updated': (payload: GradeUpdateEvent) => void;
'schedule:updated': (payload: { 'schedule:updated': (payload: {
classId: string; classId: string;
/** imported = 导入或整体刷新课表 */ /** imported = 导入或整体刷新课表 */
+9 -3
View File
@@ -5,7 +5,7 @@ import { io, type Socket } from 'socket.io-client';
import { import {
PRIORITY_LABELS, PRIORITY_LABELS,
SOCKET_EVENTS, SOCKET_EVENTS,
type GradeDto, type GradeUpdateEvent,
type HomeworkDto, type HomeworkDto,
type NotificationDto, type NotificationDto,
type ScheduleDto, type ScheduleDto,
@@ -96,9 +96,15 @@ export const useRealtimeStore = defineStore('realtime', () => {
dispatch(SOCKET_EVENTS.homeworkStatus, payload); dispatch(SOCKET_EVENTS.homeworkStatus, payload);
}); });
instance.on(SOCKET_EVENTS.gradeUpdated, (payload: GradeDto) => { instance.on(SOCKET_EVENTS.gradeUpdated, (payload: GradeUpdateEvent) => {
markEvent(SOCKET_EVENTS.gradeUpdated); markEvent(SOCKET_EVENTS.gradeUpdated);
notify('success', '成绩已更新', `${payload.examName}:${payload.score}/${payload.totalScore}`); notify(
'success',
'成绩已更新',
'score' in payload
? `${payload.examName}:${payload.score}/${payload.totalScore}`
: '班级成绩分析已更新',
);
dispatch(SOCKET_EVENTS.gradeUpdated, payload); dispatch(SOCKET_EVENTS.gradeUpdated, payload);
}); });