import { assertAdmin, assertHeadOnly } from '../../lib/authz/index.js'; import { buildAutoTermWeeks, validateTermWeeks, type HolidaySuggestionDto, type TermWeeksDto, } from '@classhelper/shared'; import { assertClassAccess } from '../../lib/access.js'; import { assertPermission } from '../../lib/permissions.js'; 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 { 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'; import type { AutoTermWeeksInput, UpdateTermWeeksInput } from './term.schemas.js'; /** * 学期周次(具有 term:write 的员工可写)。 * * 为什么需要逐周配置:`TERM_START_DATE + 每周七天`的线性推算遇到法定节假日调休、 * 周末补课、错峰开学就会**整体错位**——而课表、作业、成绩都按周次组织,错一周就全错。 * 因此管理员可以: * 1. 先按开学日期一键生成逐周区间(`autoTermWeeks`); * 2. 再逐周微调(调休那一周拉长、开学前那几天并进第 1 周…); * 3. 也可以直接改某个班的开学日期(`Class.termStartDate`)。 * * `classId = ''` 表示**全校默认**;某个班有自己的配置时优先用它。 * 改完之后广播课表更新,客户端重新读取当前周次。 */ /** 把 null/undefined 归一成空串(空串 = 全校默认) */ function scopeOf(classId?: string | null): string { return classId ?? ''; } /** 读取某个范围的周次配置 */ export async function getTermWeeks(user: TokenPayload, classId?: string | null): Promise { const scope = user.role === 'CLASS_DEVICE' ? (user.classId ?? user.sub) : scopeOf(classId); if (user.role === 'CLASS_DEVICE' && classId && classId !== scope) throw ApiError.forbidden('不能读取其他班级周次'); if (!scope) await assertPermission(user, 'term:read'); if (scope) await assertClassAccess(user, scope, 'term:read'); return readTermWeeks(scope); } async function readTermWeeks(scope: string): Promise { 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 } }) : Promise.resolve(null), ]); const context = await loadTermContext(scope || null); return { classId: scope, termStartDate: context.termStartDate || env.termStartDate, currentWeek: resolveCurrentWeek(new Date(), context), maxWeek: context.maxWeek, maxTermWeeks: await getMaxTermWeeks(), configured: weeks.length > 0, weeks: weeks.map((item) => ({ weekNumber: item.weekNumber, startDate: item.startDate, endDate: item.endDate, note: item.note, })), }; } /** * 保存逐周配置(整表覆盖)。需要 term:write。 * * 校验:日期格式、起止顺序、**区间不重叠**(重叠会让"当前第几周"出现歧义)。 * 保存后把新的周次口径下发给该班所有在线设备。 */ export async function updateTermWeeks( user: TokenPayload, input: UpdateTermWeeksInput, ): Promise { const scope = scopeOf(input.classId); if (scope) await assertHeadOnly(user, 'class:settings', scope); else assertAdmin(user); 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 }); // 重复的周次号会让唯一约束直接抛 500,这里给一句人话 const seen = new Set(); for (const week of input.weeks) { if (seen.has(week.weekNumber)) { throw ApiError.badRequest(`第 ${week.weekNumber} 周出现了两次`); } seen.add(week.weekNumber); } if (scope) { const exists = await prisma.class.findUnique({ where: { id: scope }, select: { id: true } }); if (!exists) throw ApiError.notFound('班级不存在'); } await prisma.$transaction([ prisma.termWeek.deleteMany({ where: { classId: scope } }), ...(input.weeks.length > 0 ? [ prisma.termWeek.createMany({ data: input.weeks.map((week) => ({ classId: scope, weekNumber: week.weekNumber, startDate: week.startDate, endDate: week.endDate, note: week.note ?? '', })), }), ] : []), ...(input.termStartDate && scope ? [prisma.class.update({ where: { id: scope }, data: { termStartDate: input.termStartDate } })] : []), ]); // 全校默认没有"班级记录"可以挂开学日期,写进全局设置(`term.startDate`)。 // 需求:「服务端可设置学期开始日期,以保证当前周次正确,管理员也可以随时纠正」—— // 原先这一级只能来自 `.env`,改一次要动文件 + 重启。 if (input.termStartDate && !scope) { await setSetting(SETTING_KEYS.termStartDate, input.termStartDate); } logger.info(`学期周次已更新:范围=${scope || '全校默认'} 周数=${input.weeks.length} 由 ${user.name} 执行`); if (input.maxTermWeeks !== undefined) await setSetting(SETTING_KEYS.termMaxWeeks, String(maximum)); await publishTermChange(scope); return readTermWeeks(scope); } /** * 按学期开始日期**一键生成**逐周区间(管理员再逐周微调)。需要 term:write。 * * 这是配置的起点:先生成一份"标准学期",再把调休那几周改掉, * 比从空白开始一周周填要快得多。 */ export async function autoTermWeeks(user: TokenPayload, input: AutoTermWeeksInput): Promise { const scope = scopeOf(input.classId); if (scope) await assertHeadOnly(user, 'class:settings', scope); else assertAdmin(user); const context = await loadTermContext(scope || null); const termStartDate = input.termStartDate || context.termStartDate || env.termStartDate; const maxWeek = input.maxWeek ?? context.maxWeek; const weeks = buildAutoTermWeeks(termStartDate, maxWeek); if (weeks.length === 0) throw ApiError.badRequest('请先填写学期开始日期(第 1 教学周的周一)'); return updateTermWeeks(user, { classId: scope || null, termStartDate, weeks }); } /* ------------------------------------------------------------------ 调休建议(联网,可选) */ /** 建议结果的缓存(1 小时):节假日数据一年才变几次,不必每次点都打外网 */ const holidayCache = new Map(); const HOLIDAY_CACHE_TTL_MS = 60 * 60 * 1000; /** * 联网拉取法定节假日安排,给出「哪几天不上课」的建议。 * * **`ok:false` 是设计内的结果**:机房与教室机器常常只有内网,离线 / 超时 / 限流 * 一律收敛成 `ok:false` + 一句人话,绝不抛 500(与更新检查同一种处理思路)。 * 而且它只**给建议**,不会自动改任何数据 —— 管理员确认后才应用到周次上。 */ export async function suggestHolidays(year?: number): Promise { const targetYear = year ?? new Date().getFullYear(); const cached = holidayCache.get(targetYear); if (cached && Date.now() - cached.at < HOLIDAY_CACHE_TTL_MS) return cached.data; const fallback = (error: string): HolidaySuggestionDto => ({ ok: false, error, year: targetYear, holidays: [], checkedAt: new Date().toISOString(), }); try { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 8000); const response = await fetch(`https://timor.tech/api/holiday/year/${targetYear}`, { signal: controller.signal, headers: { 'user-agent': 'ClassHelper-TermWeeks' }, }).finally(() => clearTimeout(timer)); if (!response.ok) { const data = fallback(`节假日服务返回 ${response.status}`); holidayCache.set(targetYear, { at: Date.now(), data }); return data; } const payload = (await response.json()) as { holiday?: Record; }; const holidays = Object.values(payload.holiday ?? {}) .filter((item) => item.holiday === true) .map((item) => ({ name: item.name, startDate: item.date, endDate: item.date, })) .sort((a, b) => a.startDate.localeCompare(b.startDate)); const data: HolidaySuggestionDto = { ok: true, error: null, year: targetYear, holidays, checkedAt: new Date().toISOString(), }; holidayCache.set(targetYear, { at: Date.now(), data }); return data; } catch (error) { const reason = error instanceof Error && error.name === 'AbortError' ? '请求超时(服务器可能没有外网)' : `无法获取节假日数据:${error instanceof Error ? error.message : String(error)}`; const data = fallback(reason); // 失败结果同样进缓存:否则断网时点一次按钮就要白等一个超时 holidayCache.set(targetYear, { at: Date.now(), data }); return data; } } /* ------------------------------------------------------------------ 内部工具 */ /** 周次变更后通知班级客户端刷新课表。 */ async function publishTermChange(scope: string): Promise { if (scope) { emitToClass(scope, SOCKET_EVENTS.classUpdated, { classId: scope, action: 'updated' }); emitToClass(scope, SOCKET_EVENTS.scheduleUpdated, { classId: scope, action: 'imported' }); return; } // 全校默认:把每个在读班级都刷一遍 const classes = await prisma.class.findMany({ where: { archivedYearId: null }, select: { id: true }, }); for (const item of classes) { emitToClass(item.id, SOCKET_EVENTS.classUpdated, { classId: item.id, action: 'updated' }); emitToClass(item.id, SOCKET_EVENTS.scheduleUpdated, { classId: item.id, action: 'imported' }); } }