From 57069a9a75fc297c0dbd460150835f18ae2261be Mon Sep 17 00:00:00 2001 From: LoveChengke Date: Sat, 17 Oct 2026 12:55:40 +0800 Subject: [PATCH] =?UTF-8?q?fix(grades,term):=20=E5=B0=81=E9=97=AD=E5=AE=9E?= =?UTF-8?q?=E6=97=B6=E6=88=90=E7=BB=A9=E6=B3=84=E9=9C=B2=E5=B9=B6=E5=AE=8C?= =?UTF-8?q?=E5=96=84=E5=B9=B6=E5=8F=91=E5=8D=87=E7=BA=A7=E5=AE=A1=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/server/src/lib/class-plan.ts | 5 +- .../modules/dashboard/dashboard.service.ts | 7 +- .../src/modules/grades/grades.service.ts | 26 ++++++-- .../src/modules/imports/imports.module.ts | 40 +++++++----- .../modules/imports/time-layout.service.ts | 64 ++++++++++++++----- .../src/modules/term/promotion.service.ts | 46 ++++++++++--- packages/shared/src/types.ts | 9 ++- packages/web-admin/src/stores/realtime.ts | 12 +++- 8 files changed, 152 insertions(+), 57 deletions(-) diff --git a/packages/server/src/lib/class-plan.ts b/packages/server/src/lib/class-plan.ts index 3e7b3b8..226f36d 100644 --- a/packages/server/src/lib/class-plan.ts +++ b/packages/server/src/lib/class-plan.ts @@ -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((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)); } /** diff --git a/packages/server/src/modules/dashboard/dashboard.service.ts b/packages/server/src/modules/dashboard/dashboard.service.ts index 428835c..9077481 100644 --- a/packages/server/src/modules/dashboard/dashboard.service.ts +++ b/packages/server/src/modules/dashboard/dashboard.service.ts @@ -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 { - const result = await importTimeLayout(getAuthUser(req), validatedBody(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(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(只解析不写库) diff --git a/packages/server/src/modules/imports/time-layout.service.ts b/packages/server/src/modules/imports/time-layout.service.ts index 6dcf619..c6810ec 100644 --- a/packages/server/src/modules/imports/time-layout.service.ts +++ b/packages/server/src/modules/imports/time-layout.service.ts @@ -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(); - 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(); + 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 { @@ -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 } }); } diff --git a/packages/server/src/modules/term/promotion.service.ts b/packages/server/src/modules/term/promotion.service.ts index 722cae4..781390c 100644 --- a/packages/server/src/modules/term/promotion.service.ts +++ b/packages/server/src/modules/term/promotion.service.ts @@ -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 | null = null; export async function startPromotionScheduler() { if (timer) return; diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index ecbe1fd..ee09326 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -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 契约的唯一来源, @@ -885,6 +888,8 @@ export interface BulkCreateHomeworkRequest { /* ------------------------------------------------------------------ 成绩 */ +export type GradeUpdateEvent = GradeDto | { classId: string; collective: true }; + export interface GradeDto { id: string; classId: string; @@ -1194,7 +1199,7 @@ export interface ServerToClientEvents { /** deleted=true 表示该作业已被删除,客户端应从本地缓存中移除 */ 'homework:updated': (payload: HomeworkDto & { deleted?: boolean }) => void; 'homework:status': (payload: HomeworkStatusDto & { classId: string }) => void; - 'grade:updated': (payload: GradeDto) => void; + 'grade:updated': (payload: GradeUpdateEvent) => void; 'schedule:updated': (payload: { classId: string; /** imported = 导入或整体刷新课表 */ diff --git a/packages/web-admin/src/stores/realtime.ts b/packages/web-admin/src/stores/realtime.ts index 69df5d7..0e7f8c4 100644 --- a/packages/web-admin/src/stores/realtime.ts +++ b/packages/web-admin/src/stores/realtime.ts @@ -5,7 +5,7 @@ import { io, type Socket } from 'socket.io-client'; import { PRIORITY_LABELS, SOCKET_EVENTS, - type GradeDto, + type GradeUpdateEvent, type HomeworkDto, type NotificationDto, type ScheduleDto, @@ -96,9 +96,15 @@ export const useRealtimeStore = defineStore('realtime', () => { dispatch(SOCKET_EVENTS.homeworkStatus, payload); }); - instance.on(SOCKET_EVENTS.gradeUpdated, (payload: GradeDto) => { + instance.on(SOCKET_EVENTS.gradeUpdated, (payload: GradeUpdateEvent) => { 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); });