2026-10-31 09:40:23 +08:00
|
|
|
import { assertAdmin, assertHeadOnly } from '../../lib/authz/index.js';
|
2026-10-06 19:41:18 +08:00
|
|
|
import {
|
|
|
|
|
buildAutoTermWeeks,
|
|
|
|
|
validateTermWeeks,
|
|
|
|
|
type HolidaySuggestionDto,
|
|
|
|
|
type TermWeeksDto,
|
|
|
|
|
} from '@classhelper/shared';
|
2026-10-23 22:55:21 +08:00
|
|
|
import { assertClassAccess } from '../../lib/access.js';
|
|
|
|
|
import { assertPermission } from '../../lib/permissions.js';
|
2026-10-06 19:41:18 +08:00
|
|
|
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';
|
2026-10-17 09:34:23 +08:00
|
|
|
import { getMaxTermWeeks, loadTermContext, resolveCurrentWeek } from '../../lib/term.js';
|
2026-10-11 16:21:38 +08:00
|
|
|
import { SETTING_KEYS, setSetting } from '../../lib/settings.js';
|
2026-10-06 19:41:18 +08:00
|
|
|
import { emitToClass } from '../../realtime/bus.js';
|
|
|
|
|
import { SOCKET_EVENTS } from '@classhelper/shared';
|
|
|
|
|
import type { AutoTermWeeksInput, UpdateTermWeeksInput } from './term.schemas.js';
|
|
|
|
|
|
|
|
|
|
/**
|
2026-10-23 22:55:21 +08:00
|
|
|
* 学期周次(具有 term:write 的员工可写)。
|
2026-10-06 19:41:18 +08:00
|
|
|
*
|
|
|
|
|
* 为什么需要逐周配置:`TERM_START_DATE + 每周七天`的线性推算遇到法定节假日调休、
|
|
|
|
|
* 周末补课、错峰开学就会**整体错位**——而课表、作业、成绩都按周次组织,错一周就全错。
|
|
|
|
|
* 因此管理员可以:
|
|
|
|
|
* 1. 先按开学日期一键生成逐周区间(`autoTermWeeks`);
|
|
|
|
|
* 2. 再逐周微调(调休那一周拉长、开学前那几天并进第 1 周…);
|
|
|
|
|
* 3. 也可以直接改某个班的开学日期(`Class.termStartDate`)。
|
|
|
|
|
*
|
|
|
|
|
* `classId = ''` 表示**全校默认**;某个班有自己的配置时优先用它。
|
2026-10-17 03:06:46 +08:00
|
|
|
* 改完之后广播课表更新,客户端重新读取当前周次。
|
2026-10-06 19:41:18 +08:00
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
/** 把 null/undefined 归一成空串(空串 = 全校默认) */
|
|
|
|
|
function scopeOf(classId?: string | null): string {
|
|
|
|
|
return classId ?? '';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** 读取某个范围的周次配置 */
|
|
|
|
|
export async function getTermWeeks(user: TokenPayload, classId?: string | null): Promise<TermWeeksDto> {
|
2026-10-31 09:40:23 +08:00
|
|
|
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);
|
|
|
|
|
}
|
2026-10-06 19:41:18 +08:00
|
|
|
|
2026-10-31 09:40:23 +08:00
|
|
|
async function readTermWeeks(scope: string): Promise<TermWeeksDto> {
|
2026-10-17 09:34:23 +08:00
|
|
|
const [weeks] = await Promise.all([
|
2026-10-06 19:41:18 +08:00
|
|
|
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),
|
2026-10-17 09:34:23 +08:00
|
|
|
maxWeek: context.maxWeek,
|
|
|
|
|
maxTermWeeks: await getMaxTermWeeks(),
|
2026-10-06 19:41:18 +08:00
|
|
|
configured: weeks.length > 0,
|
|
|
|
|
weeks: weeks.map((item) => ({
|
|
|
|
|
weekNumber: item.weekNumber,
|
|
|
|
|
startDate: item.startDate,
|
|
|
|
|
endDate: item.endDate,
|
|
|
|
|
note: item.note,
|
|
|
|
|
})),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-10-23 22:55:21 +08:00
|
|
|
* 保存逐周配置(整表覆盖)。需要 term:write。
|
2026-10-06 19:41:18 +08:00
|
|
|
*
|
|
|
|
|
* 校验:日期格式、起止顺序、**区间不重叠**(重叠会让"当前第几周"出现歧义)。
|
|
|
|
|
* 保存后把新的周次口径下发给该班所有在线设备。
|
|
|
|
|
*/
|
2026-10-17 03:06:46 +08:00
|
|
|
export async function updateTermWeeks(
|
|
|
|
|
user: TokenPayload,
|
|
|
|
|
input: UpdateTermWeeksInput,
|
|
|
|
|
): Promise<TermWeeksDto> {
|
2026-10-06 19:41:18 +08:00
|
|
|
const scope = scopeOf(input.classId);
|
2026-10-31 09:40:23 +08:00
|
|
|
if (scope) await assertHeadOnly(user, 'class:settings', scope);
|
|
|
|
|
else assertAdmin(user);
|
2026-10-06 19:41:18 +08:00
|
|
|
|
2026-10-17 09:34:23 +08:00
|
|
|
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('请先缩短超过新上限的班级教学周数');
|
|
|
|
|
}
|
2026-10-06 19:41:18 +08:00
|
|
|
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 } })]
|
|
|
|
|
: []),
|
|
|
|
|
]);
|
|
|
|
|
|
2026-10-11 16:21:38 +08:00
|
|
|
// 全校默认没有"班级记录"可以挂开学日期,写进全局设置(`term.startDate`)。
|
|
|
|
|
// 需求:「服务端可设置学期开始日期,以保证当前周次正确,管理员也可以随时纠正」——
|
|
|
|
|
// 原先这一级只能来自 `.env`,改一次要动文件 + 重启。
|
|
|
|
|
if (input.termStartDate && !scope) {
|
|
|
|
|
await setSetting(SETTING_KEYS.termStartDate, input.termStartDate);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-17 03:06:46 +08:00
|
|
|
logger.info(`学期周次已更新:范围=${scope || '全校默认'} 周数=${input.weeks.length} 由 ${user.name} 执行`);
|
2026-10-17 09:34:23 +08:00
|
|
|
if (input.maxTermWeeks !== undefined) await setSetting(SETTING_KEYS.termMaxWeeks, String(maximum));
|
2026-10-06 19:41:18 +08:00
|
|
|
await publishTermChange(scope);
|
2026-10-31 09:40:23 +08:00
|
|
|
return readTermWeeks(scope);
|
2026-10-06 19:41:18 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-10-23 22:55:21 +08:00
|
|
|
* 按学期开始日期**一键生成**逐周区间(管理员再逐周微调)。需要 term:write。
|
2026-10-06 19:41:18 +08:00
|
|
|
*
|
|
|
|
|
* 这是配置的起点:先生成一份"标准学期",再把调休那几周改掉,
|
|
|
|
|
* 比从空白开始一周周填要快得多。
|
|
|
|
|
*/
|
|
|
|
|
export async function autoTermWeeks(user: TokenPayload, input: AutoTermWeeksInput): Promise<TermWeeksDto> {
|
|
|
|
|
const scope = scopeOf(input.classId);
|
2026-10-31 09:40:23 +08:00
|
|
|
if (scope) await assertHeadOnly(user, 'class:settings', scope);
|
|
|
|
|
else assertAdmin(user);
|
2026-10-06 19:41:18 +08:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ------------------------------------------------------------------ 内部工具 */
|
|
|
|
|
|
2026-10-17 03:06:46 +08:00
|
|
|
/** 周次变更后通知班级客户端刷新课表。 */
|
2026-10-06 19:41:18 +08:00
|
|
|
async function publishTermChange(scope: string): Promise<void> {
|
|
|
|
|
if (scope) {
|
|
|
|
|
emitToClass(scope, SOCKET_EVENTS.classUpdated, { classId: scope, action: 'updated' });
|
2026-10-17 03:06:46 +08:00
|
|
|
emitToClass(scope, SOCKET_EVENTS.scheduleUpdated, { classId: scope, action: 'imported' });
|
2026-10-06 19:41:18 +08:00
|
|
|
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' });
|
2026-10-17 03:06:46 +08:00
|
|
|
emitToClass(item.id, SOCKET_EVENTS.scheduleUpdated, { classId: item.id, action: 'imported' });
|
2026-10-06 19:41:18 +08:00
|
|
|
}
|
|
|
|
|
}
|