Files
ClassHelper/packages/server/src/modules/term/term.service.ts
T

258 lines
11 KiB
TypeScript
Raw Normal View History

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<TermWeeksDto> {
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<TermWeeksDto> {
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<TermWeeksDto> {
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<number>();
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<TermWeeksDto> {
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<number, { at: number; data: HolidaySuggestionDto }>();
const HOLIDAY_CACHE_TTL_MS = 60 * 60 * 1000;
/**
* 联网拉取法定节假日安排,给出「哪几天不上课」的建议。
*
* **`ok:false` 是设计内的结果**:机房与教室机器常常只有内网,离线 / 超时 / 限流
* 一律收敛成 `ok:false` + 一句人话,绝不抛 500(与更新检查同一种处理思路)。
* 而且它只**给建议**,不会自动改任何数据 —— 管理员确认后才应用到周次上。
*/
export async function suggestHolidays(year?: number): Promise<HolidaySuggestionDto> {
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<string, { holiday: boolean; name: string; date: string }>;
};
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<void> {
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' });
}
}