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

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

No files matched your search

@@ -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 } });
}