feat: 完善学生生命周期管理与权限范围并发布 1.4.2

This commit is contained in:
LoveChengke committed 2026-10-23 22:55:21 +08:00
1 parent 6347d1e5aa
commit 24e8dd902b
92 files changed
+6801 -2255

No files matched your search

+71 -17
View File
@@ -1,7 +1,5 @@
import type { PermissionKey as ContractPermissionKey } from '@classhelper/shared';
import {
canAssignTeachers,
canManageClasses,
canManageGrades,
canManageRoster,
canManageSchedule,
@@ -13,6 +11,8 @@ import {
import { teacherScopeWhere } from './permission-scope.js';
import { prisma } from './db.js';
import { ApiError } from './http.js';
import { assertPermission, currentRequestPermission } from './permissions.js';
import { hasAllScope } from './permission-scope.js';
import type { TokenPayload } from './jwt.js';
/**
@@ -55,6 +55,14 @@ export function isClassDevice(user: TokenPayload): boolean {
return user.role === 'CLASS_DEVICE';
}
/** 班级端代录内容归属班主任账号,班级主体不能写入 User 外键。 */
export async function resolveContentCreator(user: TokenPayload, classId: string) {
if (!isClassDevice(user)) return user.sub;
const record = await prisma.class.findUnique({ where: { id: classId }, select: { teacherId: true } });
if (!record?.teacherId) throw ApiError.conflict('请先为班级配置班主任,再在班级端发布内容');
return record.teacherId;
}
/** ClassHelper 班级端绑定的班级 id;不是班级端会话时返回 null */
export function classDeviceClassId(user: TokenPayload): string | null {
return isClassDevice(user) ? (user.classId ?? user.sub) : null;
@@ -88,11 +96,15 @@ export async function getAccessibleClassIds(
return classId ? [classId] : [];
}
export async function canAccessClass(user: TokenPayload, classId: string): Promise<boolean> {
export async function canAccessClass(
user: TokenPayload,
classId: string,
permission?: ContractPermissionKey,
): Promise<boolean> {
if (isAdmin(user)) return true;
if (isTeacher(user)) {
const count = await prisma.class.count({
where: { AND: [{ id: classId }, await teacherScopeWhere(user)] },
where: { AND: [{ id: classId }, await teacherScopeWhere(user, permission)] },
});
return count > 0;
}
@@ -100,10 +112,14 @@ export async function canAccessClass(user: TokenPayload, classId: string): Promi
}
/** 读权限断言 */
export async function assertClassAccess(user: TokenPayload, classId: string): Promise<void> {
export async function assertClassAccess(
user: TokenPayload,
classId: string,
permission?: ContractPermissionKey,
): Promise<void> {
const exists = await prisma.class.findUnique({ where: { id: classId }, select: { id: true } });
if (!exists) throw ApiError.notFound('班级不存在');
if (!(await canAccessClass(user, classId))) throw ApiError.forbidden('无权访问该班级的数据');
if (!(await canAccessClass(user, classId, permission))) throw ApiError.forbidden('无权访问该班级的数据');
}
/** 写权限断言:无账号的学生不在会话里,因此这里只放行 staff 与 ClassHelper 班级端 */
@@ -135,17 +151,38 @@ export async function resolveUserClassRole(user: TokenPayload, classId: string):
}
/** 班级本身(增删改)与人员分配:仅管理员 */
export function assertCanManageClasses(user: TokenPayload): void {
if (!canManageClasses(user.role)) {
throw ApiError.forbidden('只有管理员可以创建、修改或删除班级');
}
export async function assertCanManageClasses(user: TokenPayload, classId?: string): Promise<void> {
await assertPermission(user, 'class:manage');
if (classId) await assertClassInScope(user, classId, 'class:manage');
else if (!(await hasAllScope(user, 'class:manage'))) throw ApiError.forbidden('创建或删除班级需要全校范围');
if (
classId &&
(await prisma.class.findUnique({ where: { id: classId }, select: { archivedYearId: true } }))
?.archivedYearId
)
throw ApiError.conflict('该班级已归档,请先撤销归档');
}
export async function assertGlobalPermission(user: TokenPayload, key: ContractPermissionKey) {
await assertPermission(user, key);
if (!(await hasAllScope(user, key))) throw ApiError.forbidden('此操作影响全校,需要对应功能的全校范围');
}
/** 班主任 / 各科科任老师的分配:仅管理员 */
export function assertCanAssignTeachers(user: TokenPayload): void {
if (!canAssignTeachers(user.role)) {
throw ApiError.forbidden('只有管理员可以分配班主任与各科科任老师');
}
export async function assertCanAssignTeachers(user: TokenPayload, classId?: string): Promise<void> {
await assertCanManageClasses(user, classId);
}
export async function assertClassInScope(user: TokenPayload, classId: string, key: ContractPermissionKey) {
await assertPermission(user, key);
if (isAdmin(user)) return;
const count = await prisma.class.count({
where: { AND: [{ id: classId }, await teacherScopeWhere(user, key)] },
});
if (!count) throw ApiError.forbidden('目标班级不在此功能的授权范围内');
}
export async function assertClassesInScope(user: TokenPayload, ids: string[], key: ContractPermissionKey) {
for (const id of new Set(ids)) await assertClassInScope(user, id, key);
}
/**
@@ -154,11 +191,14 @@ export function assertCanAssignTeachers(user: TokenPayload): void {
* @param classId 目标班级;不传表示"全局名单操作"(例如未分班的学生),此时仅管理员
*/
export async function assertCanManageRoster(user: TokenPayload, classId?: string): Promise<void> {
await assertPermission(user, 'roster:manage');
if (!classId) {
if (!isAdmin(user)) throw ApiError.forbidden('该操作需要指定班级,只有管理员可以全局执行');
if (!(await hasAllScope(user, 'roster:manage')))
throw ApiError.forbidden('未分班学生操作需要全校名单范围');
return;
}
await assertClassAccess(user, classId);
await assertClassInScope(user, classId, 'roster:manage');
if (await hasAllScope(user, 'roster:manage')) return;
const classRole = await resolveUserClassRole(user, classId);
if (!canManageRoster(classRole)) {
throw ApiError.forbidden(
@@ -182,6 +222,17 @@ export async function assertCanManageSchedule(user: TokenPayload, classId: strin
}
}
/** 导入需同时覆盖导入能力和对应写能力,范围不能从另一项能力借用。 */
export async function assertCanImportSchedule(
user: TokenPayload,
classId: string,
writeKey: ContractPermissionKey = 'schedule:write',
): Promise<void> {
await assertClassInScope(user, classId, 'import:schedule');
await assertClassInScope(user, classId, writeKey);
await assertCanManageSchedule(user, classId);
}
/** 通知 / 叫人:管理员、班主任、科任老师、ClassHelper 班级端(面向全班,不分科目) */
export async function assertCanPublishNotification(user: TokenPayload, classId: string): Promise<void> {
await assertClassAccess(user, classId);
@@ -298,10 +349,13 @@ export async function resolveClassScope(
permission?: ContractPermissionKey,
): Promise<ClassScope> {
if (requestedClassId) {
await assertClassAccess(user, requestedClassId);
await assertClassAccess(user, requestedClassId, permission);
return { mode: 'in', classIds: [requestedClassId] };
}
if (isAdmin(user)) return { mode: 'all' };
const scopePermission = permission ?? currentRequestPermission(user);
if (isTeacher(user) && scopePermission && (await hasAllScope(user, scopePermission)))
return { mode: 'all' };
const classIds = await getAccessibleClassIds(user, permission);
// 没有任何班级时不抛错,返回空集合让列表接口给出空数组,避免新教师首次登录报错
+18 -2
View File
@@ -458,7 +458,13 @@ export interface StudentTransferLike {
toClassName: string;
operatorId: string | null;
operatorName: string;
mode: string;
kind: string;
batchId: string;
academicYear: number | null;
fromGrade: string;
toGrade: string;
fromSchool: string;
toSchool: string;
note?: string | null;
createdAt: Date;
}
@@ -475,7 +481,15 @@ export function toStudentTransferDto(item: StudentTransferLike): StudentTransfer
toClassName: item.toClassName,
operatorId: item.operatorId,
operatorName: item.operatorName,
mode: item.mode as StudentTransferDto['mode'],
kind: item.kind as StudentTransferDto['kind'],
batchId: item.batchId,
academicYear: item.academicYear,
fromGrade: item.fromGrade,
toGrade: item.toGrade,
fromSchool: item.fromSchool,
toSchool: item.toSchool,
mode:
item.kind === 'transfer-out' ? 'transfer-out' : item.kind === 'restore' ? 'restore-transfer' : 'single',
note: item.note ?? '',
createdAt: toIso(item.createdAt),
};
@@ -492,6 +506,7 @@ export interface ArchivedYearLike {
classCount: number;
studentCount: number;
transferredCount: number;
inactiveCount: number;
homeworkCount: number;
notificationCount: number;
archivedAt: Date;
@@ -509,6 +524,7 @@ export function toArchivedYearDto(item: ArchivedYearLike): ArchivedYearDto {
classCount: item.classCount,
studentCount: item.studentCount,
transferredCount: item.transferredCount,
inactiveCount: item.inactiveCount,
homeworkCount: item.homeworkCount,
notificationCount: item.notificationCount,
archivedAt: toIso(item.archivedAt),
+32 -12
View File
@@ -1,32 +1,52 @@
import { prisma } from './db.js';
import type { TokenPayload } from './jwt.js';
import type { Prisma } from '../generated/prisma/client.js';
import type { PermissionKey } from '@classhelper/shared';
import { parsePermissionScope, type PermissionScope, type PermissionKey } from '@classhelper/shared';
import { currentRequestPermission } from './permissions.js';
/** 范围与功能键正交;显式分组覆盖角色默认,不在范围内一律不放行。 */
export async function teacherScopeWhere(
user: TokenPayload,
permission?: PermissionKey,
): Promise<Prisma.ClassWhereInput> {
permission ??= currentRequestPermission(user);
const assignments = await prisma.userPermissionGroup.findMany({
where: { userId: user.sub },
include: { group: { include: { items: true } } },
});
const own = { OR: [{ teacherId: user.sub }, { courses: { some: { teacherId: user.sub } } }] };
if (!assignments.length) return own;
const groups = assignments.length
? assignments.map(({ group }) => group)
: await prisma.permissionGroup.findMany({ where: { defaultRole: user.role }, include: { items: true } });
const alternatives: Prisma.ClassWhereInput[] = [];
for (const { group } of assignments) {
for (const group of groups) {
if (permission && !group.items.some((item) => item.key === permission)) continue;
if (group.scope === 'ALL') return {};
if (group.scope === 'SELF_CLASS')
alternatives.push(group.scopeValue === 'HEAD' ? { teacherId: user.sub } : own);
if (group.scope === 'GRADE' || group.scope === 'DIVISION') {
const grades = group.scopeValue
.split(',')
.map((s) => s.trim())
.filter(Boolean);
if (grades.length) alternatives.push({ grade: { in: grades } });
try {
const parsed = parsePermissionScope(group.scope as PermissionScope, group.scopeValue);
if (parsed.scope === 'ALL') return {};
if (parsed.scope === 'SELF_CLASS')
alternatives.push(parsed.scopeValue === 'HEAD' ? { teacherId: user.sub } : own);
else alternatives.push({ grade: { in: parsed.grades } });
} catch {
/* 旧岗位组未配置或范围非法时不授予任何班级。 */
}
}
return { OR: alternatives };
}
export async function hasAllScope(user: TokenPayload, permission: PermissionKey) {
if (user.role === 'ADMIN') return true;
const links = await prisma.userPermissionGroup.findMany({
where: { userId: user.sub },
select: { groupId: true },
});
return (
(await prisma.permissionGroup.count({
where: {
...(links.length ? { id: { in: links.map((link) => link.groupId) } } : { defaultRole: user.role }),
scope: 'ALL',
items: { some: { key: permission } },
},
})) > 0
);
}
+31
View File
@@ -8,6 +8,37 @@ import {
} from '@classhelper/shared';
import { prisma } from './db.js';
import type { TokenPayload } from './jwt.js';
import { ApiError } from './http.js';
// 仅复用当前请求对象上的权限,绝不跨请求缓存。
const requestPermissions = new WeakMap<TokenPayload, ReadonlySet<PermissionKey>>();
const requestPermission = new WeakMap<TokenPayload, PermissionKey>();
export function bindRequestPermission(user: TokenPayload, key: PermissionKey) {
requestPermission.set(user, key);
}
export function currentRequestPermission(user: TokenPayload) {
return requestPermission.get(user);
}
export function bindRequestPermissions(user: TokenPayload, permissions: ReadonlySet<PermissionKey>) {
requestPermissions.set(user, permissions);
}
export async function hasEffectivePermission(user: TokenPayload, key: PermissionKey) {
return (requestPermissions.get(user) ?? (await loadEffectivePermissions(user))).has(key);
}
export async function assertPermission(user: TokenPayload, key: PermissionKey): Promise<void> {
if (!(await hasEffectivePermission(user, key)))
throw ApiError.forbidden(`缺少「${PERMISSION_LABELS[key]}」权限`);
bindRequestPermission(user, key);
}
export async function assertAnyPermission(user: TokenPayload, keys: PermissionKey[]): Promise<void> {
const permissions = requestPermissions.get(user) ?? (await loadEffectivePermissions(user));
if (!keys.some((key) => permissions.has(key))) throw ApiError.forbidden('缺少该操作所需权限');
const matched = keys.find((key) => permissions.has(key));
if (matched) bindRequestPermission(user, matched);
}
export async function assertStaffPermission(user: TokenPayload, key: PermissionKey): Promise<void> {
if (user.role !== 'CLASS_DEVICE') await assertPermission(user, key);
}
/**
* 生效权限集合的**唯一加载入口**(权限组功能的全部运行时逻辑都收在这里)。
+1
View File
@@ -30,6 +30,7 @@ export const SETTING_KEYS = {
termStartDate: 'term.startDate',
termMaxWeeks: 'term.maxWeeks',
promotionAt: 'term.promotionAt',
promotionPlan: 'term.promotionPlan',
} as const;
export type SettingKey = (typeof SETTING_KEYS)[keyof typeof SETTING_KEYS];
+15 -1
View File
@@ -34,6 +34,7 @@ export const SNAPSHOT_VERSION = 1;
*/
export const SNAPSHOT_TABLES = [
'User',
'PermissionAudit',
'PermissionGroup',
'PermissionGroupItem',
'UserPermissionGroup',
@@ -138,7 +139,7 @@ export function assertValidSnapshot(value: unknown): asserts value is DatabaseSn
for (const table of SNAPSHOT_TABLES) {
const rows = (snapshot.data as Record<string, unknown>)[table];
// 旧版本快照没有课件表;允许恢复,文件实体仍需单独备份。
if (rows === undefined && table.startsWith('Courseware')) continue;
if (rows === undefined && (table.startsWith('Courseware') || table === 'PermissionAudit')) continue;
if (!Array.isArray(rows)) {
throw new Error(`快照缺少表 ${table} 的数据`);
}
@@ -182,6 +183,19 @@ export async function restoreSnapshot(
}
for (const table of SNAPSHOT_TABLES) {
const rows = (snapshot.data[table] ?? []).map((row) => {
if (table === 'StudentClassTransfer') {
const { mode, ...rest } = row;
return {
...rest,
kind:
rest.kind ??
(mode === 'transfer-out'
? 'transfer-out'
: mode === 'restore-transfer'
? 'restore'
: 'class-change'),
};
}
if (table !== 'Class' || typeof row !== 'object' || row === null) return row;
const copy = { ...(row as Record<string, unknown>) };
delete copy.notificationChannel;
@@ -0,0 +1,166 @@
import { randomUUID } from 'node:crypto';
import { currentAcademicYearStart, SOCKET_EVENTS, type StudentLifecycleKind } from '@classhelper/shared';
import { prisma } from './db.js';
import { ApiError } from './http.js';
import { assertCanManageRoster } from './access.js';
import type { TokenPayload } from './jwt.js';
import { toStudentTransferDto } from './mappers.js';
import { emitToClass } from '../realtime/bus.js';
export function notifyStudentClasses(ids: (string | null)[]) {
for (const classId of new Set(ids.filter((id): id is string => Boolean(id))))
emitToClass(classId, SOCKET_EVENTS.classUpdated, { classId, action: 'updated' });
}
/** 生命周期写入只有此入口;数据、班级快照与历史在同一事务提交。 */
export async function changeStudentLifecycle(
user: TokenPayload,
input: {
studentIds: string[];
kind: StudentLifecycleKind;
toClassId?: string | null;
note?: string;
fromSchool?: string;
toSchool?: string;
allowGradeChange?: boolean;
newStudent?: {
studentNo: string;
name: string;
gender?: string;
guardianPhone?: string;
status?: 'active' | 'inactive';
};
},
) {
const batchId = randomUUID();
const ids = [...new Set(input.studentIds)];
const existing = ids.length ? await prisma.student.findMany({ where: { id: { in: ids } } }) : [];
if (existing.length !== ids.length) throw ApiError.notFound('部分学生不存在,请刷新后重试');
for (const cls of new Set(existing.map((student) => student.classId)))
await assertCanManageRoster(user, cls ?? undefined);
if (input.toClassId) await assertCanManageRoster(user, input.toClassId);
else if (!existing.length) await assertCanManageRoster(user);
const changedClasses: (string | null)[] = [];
const result = await prisma.$transaction(async (tx) => {
const target = input.toClassId ? await tx.class.findUnique({ where: { id: input.toClassId } }) : null;
if (input.toClassId && !target) throw ApiError.badRequest('目标班级不存在');
if (target?.archivedYearId) throw ApiError.conflict('目标班级已归档,不能调入');
if (['transfer-in', 'restore'].includes(input.kind) && !target)
throw ApiError.badRequest('请指定在读班级');
const students = ids.length
? await tx.student.findMany({ where: { id: { in: ids } }, include: { class: true } })
: [];
if (input.newStudent) {
if (await tx.student.findUnique({ where: { studentNo: input.newStudent.studentNo } }))
throw ApiError.conflict('学号已被占用,请使用转入或复学入口');
const created = await tx.student.create({
data: { ...input.newStudent, classId: target?.id ?? null },
include: { class: true },
});
students.push(created);
}
if (!students.length) throw ApiError.badRequest('请至少选择一名学生');
const records = [];
for (const student of students) {
const isNew = Boolean(input.newStudent);
const prior = existing.find((item) => item.id === student.id);
if (
prior &&
(prior.classId !== student.classId ||
prior.status !== student.status ||
prior.archivedYearId !== student.archivedYearId)
)
throw ApiError.conflict('学生状态已变化,请刷新后重试');
// 撤回转出是唯一允许将归档转出记录迁入其他在读班的路径。
if (
student.status === 'graduated' ||
(!isNew &&
(student.archivedYearId || student.class?.archivedYearId) &&
!(student.status === 'transferred' && input.kind === 'restore'))
)
throw ApiError.conflict('该生已毕业或已归档,请先在归档页撤销归档');
if (!isNew) {
const allowed: Record<StudentLifecycleKind, string[]> = {
'class-change': ['active'],
unassign: ['active'],
'transfer-out': ['active', 'inactive'],
'transfer-in': ['transferred', 'inactive'],
restore: ['transferred', 'inactive'],
deactivate: ['active'],
};
if (!allowed[input.kind].includes(student.status))
throw ApiError.conflict('当前学生状态不允许此操作,请刷新名单并使用对应生命周期入口');
}
const from = isNew ? null : student.class;
if (!isNew && input.kind === 'class-change' && student.classId === target?.id) continue;
if (
input.kind === 'class-change' &&
from &&
target &&
from.grade !== target.grade &&
!(user.role === 'ADMIN' && input.allowGradeChange && input.note?.trim())
)
throw ApiError.conflict('跨年级调班需要管理员勾选允许跨年级并填写备注');
const keepClass = ['transfer-out', 'deactivate'].includes(input.kind);
const nextClassId = keepClass ? student.classId : (target?.id ?? null);
const nextStatus =
input.kind === 'transfer-out'
? 'transferred'
: input.kind === 'deactivate'
? 'inactive'
: isNew
? student.status
: 'active';
await tx.student.update({
where: { id: student.id },
data: {
classId: nextClassId,
status: nextStatus,
...(input.kind === 'transfer-out'
? { transferredAt: new Date(), transferNote: input.note ?? '' }
: {}),
...(['transfer-in', 'restore'].includes(input.kind)
? { transferredAt: null, archivedYearId: null, archivedAt: null }
: {}),
},
});
records.push(
await tx.studentClassTransfer.create({
data: {
studentId: student.id,
studentNo: student.studentNo,
studentName: student.name,
kind: input.kind,
batchId,
academicYear: currentAcademicYearStart(),
fromClassId: from?.id ?? null,
fromClassName: from?.name ?? '',
fromGrade: from?.grade ?? '',
toClassId: keepClass ? null : (target?.id ?? null),
toClassName:
input.kind === 'transfer-out'
? '转出本校'
: input.kind === 'deactivate'
? '停用'
: (target?.name ?? '未分班'),
toGrade: keepClass ? '' : (target?.grade ?? ''),
fromSchool: input.fromSchool ?? '',
toSchool: input.toSchool ?? '',
operatorId: user.sub,
operatorName: user.name,
note: input.note ?? '',
},
}),
);
changedClasses.push(from?.id ?? null, nextClassId);
}
return {
batchId,
count: records.length,
transfers: records.map(toStudentTransferDto),
studentId: students[0].id,
};
});
notifyStudentClasses(changedClasses);
return result;
}
+3 -3
View File
@@ -1,7 +1,7 @@
/** 所有在读入口共用;归档/转出专页显式读取历史状态。 */
export function activeStudentWhere() {
return { status: { notIn: ['transferred', 'inactive'] } };
return { status: 'active', archivedYearId: null };
}
export function isActiveStudent(student: { status: string }) {
return !activeStudentWhere().status.notIn.includes(student.status);
export function isActiveStudent(student: { status: string; archivedYearId?: string | null }) {
return student.status === 'active' && !student.archivedYearId;
}
+9 -1
View File
@@ -3,7 +3,11 @@ import type { PermissionKey, UserRole } from '@classhelper/shared';
import { PERMISSION_LABELS } from '@classhelper/shared';
import { prisma } from '../lib/db.js';
import { ApiError } from '../lib/http.js';
import { loadEffectivePermissions } from '../lib/permissions.js';
import {
bindRequestPermission,
bindRequestPermissions,
loadEffectivePermissions,
} from '../lib/permissions.js';
import { extractBearerToken, verifyToken, type TokenPayload } from '../lib/jwt.js';
/**
@@ -58,6 +62,7 @@ export function authenticate(): RequestHandler {
classId: null,
};
req.auth = { token, user: authUser, permissions: await loadEffectivePermissions(authUser) };
bindRequestPermissions(authUser, req.auth.permissions!);
next();
} catch (error) {
next(error);
@@ -100,6 +105,7 @@ export function requirePermission(...keys: PermissionKey[]): RequestHandler {
next(ApiError.forbidden(`当前账号缺少${labels}权限,请联系管理员在「权限组」里分配`));
return;
}
if (keys[0]) bindRequestPermission(req.auth.user, keys[0]);
next();
};
}
@@ -133,6 +139,8 @@ export function requireAnyPermission(...keys: PermissionKey[]): RequestHandler {
);
return;
}
const matched = keys.find((key) => permissions.has(key));
if (matched) bindRequestPermission(req.auth.user, matched);
next();
};
}
@@ -1,3 +1,4 @@
import { restoreStudentSchema } from '../students/students.schemas.js';
import { listTransferred, restoreTransferred } from './transferred.service.js';
import { Router } from 'express';
import { sendOk } from '../../lib/http.js';
@@ -20,13 +21,14 @@ router.get('/transferred', requirePermission('archive:manage'), async (req, res)
);
router.post(
'/transferred/:id/restore',
requirePermission('archive:manage'),
validate({ params: idParamSchema }),
async (req, res) => sendOk(res, await restoreTransferred(getAuthUser(req), String(req.params.id))),
requirePermission('roster:manage'),
validate({ params: idParamSchema, body: restoreStudentSchema }),
async (req, res) =>
sendOk(res, await restoreTransferred(getAuthUser(req), String(req.params.id), req.body)),
);
/**
* 毕业归档(**全部接口仅管理员**)。
* 毕业归档(**归档操作按 archive:manage 与全校范围校验**)。
*
* 归档是**打标记 + 只读**:毕业班级的作业与通知原样留在库里,
* 通过 `GET /:id/classes/:classId/content` 只读查看。
@@ -49,7 +51,7 @@ router.get('/summary', requirePermission('archive:manage'), async (req, res) =>
sendOk(
res,
{
...(await archiveService.getArchiveSummary()),
...(await archiveService.getArchiveSummary(getAuthUser(req))),
suggestedEnrollmentYears: suggestedEnrollmentYears(),
},
'获取归档汇总成功',
@@ -2,14 +2,6 @@ import { z } from 'zod';
import { currentAcademicYearStart, graduationYearOf } from '@classhelper/shared';
import { enrollmentYearSchema } from '../../lib/schemas.js';
/** 执行毕业归档:按**届别**(入学年份)把整届班级与学生归档 */
export const createArchiveSchema = z.object({
enrollmentYear: enrollmentYearSchema,
/** 毕业年份;留空 = 入学年份 + 默认学制(3 年) */
graduationYear: z.coerce.number().int().min(2000).max(2200).optional(),
note: z.string().trim().max(200).optional(),
});
/** 届别列表的查询参数 */
export const listArchivesQuerySchema = z.object({
/** 只看某一届 */
@@ -1,12 +1,10 @@
import {
SOCKET_EVENTS,
formatYearLabel,
graduationYearOf,
type ArchivedClassContentDto,
type ArchivedYearDetailDto,
type ArchivedYearDto,
} from '@classhelper/shared';
import { assertCanManageClasses } from '../../lib/access.js';
import { assertGlobalPermission } from '../../lib/access.js';
import { prisma } from '../../lib/db.js';
import { ApiError } from '../../lib/http.js';
import type { TokenPayload } from '../../lib/jwt.js';
@@ -19,10 +17,6 @@ import {
} from '../../lib/mappers.js';
import { emitToClass } from '../../realtime/bus.js';
import { logger } from '../../lib/logger.js';
import type { createArchiveSchema } from './archives.schemas.js';
import type { z } from 'zod';
type CreateArchiveInput = z.infer<typeof createArchiveSchema>;
/**
* 毕业归档。
@@ -36,12 +30,12 @@ type CreateArchiveInput = z.infer<typeof createArchiveSchema>;
* **未毕业而升级的班级不归档**:升级只改 `Class.grade`(高一 → 高二),
* 班级记录、学生名单、学号、作业成绩全部沿用(见 `classes.service.promoteClasses`)。
*
* 全部接口**仅管理员**。
* 全部接口受 archive:manage 与全校范围保护。
*/
/** 届别列表(按入学年份倒序) */
export async function listArchivedYears(user: TokenPayload): Promise<ArchivedYearDto[]> {
assertCanManageClasses(user);
await assertGlobalPermission(user, 'archive:manage');
const records = await prisma.archivedYear.findMany({ orderBy: { enrollmentYear: 'desc' } });
return records.map(toArchivedYearDto);
}
@@ -51,7 +45,7 @@ export async function getArchivedYearDetail(
user: TokenPayload,
archiveId: string,
): Promise<ArchivedYearDetailDto> {
assertCanManageClasses(user);
await assertGlobalPermission(user, 'archive:manage');
const record = await prisma.archivedYear.findUnique({ where: { id: archiveId } });
if (!record) throw ApiError.notFound('该届别档案不存在');
@@ -72,11 +66,18 @@ export async function getArchivedYearDetail(
]);
const dtos = students.map(toStudentDto);
const transferredIn = await prisma.studentClassTransfer.findMany({
where: { kind: 'transfer-in', student: { archivedYearId: archiveId } },
distinct: ['studentId'],
select: { studentId: true },
});
return {
...toArchivedYearDto(record),
transferInCount: transferredIn.length,
classes: classes.map(toArchivedClassDto),
graduates: dtos.filter((item) => item.status === 'graduated'),
transferred: dtos.filter((item) => item.status === 'transferred'),
inactive: dtos.filter((item) => item.status === 'inactive'),
};
}
@@ -92,7 +93,7 @@ export async function getArchivedClassContent(
classId: string,
limit = 200,
): Promise<ArchivedClassContentDto> {
assertCanManageClasses(user);
await assertGlobalPermission(user, 'archive:manage');
const record = await prisma.class.findUnique({
where: { id: classId },
select: { id: true, name: true, archivedYearId: true },
@@ -132,99 +133,6 @@ export async function getArchivedClassContent(
};
}
/**
* 执行归档:把某一届的全部班级与学生标记为已归档。
*
* 步骤:
* 1. 找到该届**尚未归档**的班级(`enrollmentYear` 匹配且 `archivedYearId` 为空);
* 2. 把这些班的学生标记为 `graduated`;把其中已经 `transferred` 的保持原状态;
* 3. 统计班级数 / 学生数 / 转出数 / 作业数 / 通知数写进档案快照;
* 4. 全部在**一个事务**里完成,中途失败不会留下"班归档了、学生没归档"的半截状态。
*/
export async function createArchive(
user: TokenPayload,
input: CreateArchiveInput,
): Promise<ArchivedYearDetailDto> {
assertCanManageClasses(user);
const existing = await prisma.archivedYear.findUnique({
where: { enrollmentYear: input.enrollmentYear },
select: { id: true },
});
if (existing) throw ApiError.conflict(`${formatYearLabel(input.enrollmentYear)} 已经归档过了`);
const classes = await prisma.class.findMany({
where: { enrollmentYear: input.enrollmentYear, archivedYearId: null },
select: { id: true },
});
if (classes.length === 0) {
throw ApiError.badRequest(
`没有找到 ${formatYearLabel(input.enrollmentYear)} 的在读班级(可能已归档,或入学年份没填对)`,
);
}
const classIds = classes.map((item) => item.id);
const [students, homeworkCount, notificationCount] = await Promise.all([
prisma.student.findMany({
where: { classId: { in: classIds }, archivedYearId: null },
select: { id: true, status: true },
}),
prisma.homework.count({ where: { classId: { in: classIds } } }),
prisma.notification.count({ where: { classId: { in: classIds } } }),
]);
const transferredCount = students.filter((item) => item.status === 'transferred').length;
const graduateCount = students.length - transferredCount;
const created = await prisma.$transaction(async (tx) => {
const archive = await tx.archivedYear.create({
data: {
enrollmentYear: input.enrollmentYear,
graduationYear: input.graduationYear ?? graduationYearOf(input.enrollmentYear),
name: formatYearLabel(input.enrollmentYear),
note: input.note ?? '',
operatorId: user.sub,
operatorName: user.name,
classCount: classes.length,
studentCount: graduateCount,
transferredCount,
homeworkCount,
notificationCount,
},
select: { id: true },
});
const now = new Date();
await tx.class.updateMany({
where: { id: { in: classIds } },
data: { archivedYearId: archive.id, archivedAt: now },
});
// 在读的 → 已毕业;已经转出的保持 transferred(他们的归档记录也一并挂到这一届)
if (students.length > 0) {
await tx.student.updateMany({
where: { id: { in: students.filter((s) => s.status !== 'transferred').map((s) => s.id) } },
data: { status: 'graduated', archivedYearId: archive.id, archivedAt: now },
});
await tx.student.updateMany({
where: { id: { in: students.filter((s) => s.status === 'transferred').map((s) => s.id) } },
data: { archivedYearId: archive.id, archivedAt: now },
});
}
return archive;
});
for (const classId of classIds) {
emitToClass(classId, SOCKET_EVENTS.classUpdated, { classId, action: 'deleted' });
}
logger.info(
`毕业归档:${formatYearLabel(input.enrollmentYear)} 班级=${classes.length} 毕业生=${graduateCount} 转出=${transferredCount} 由 ${user.name} 执行`,
);
return getArchivedYearDetail(user, created.id);
}
/**
* 撤销归档(管理员误操作的退路)。
*
@@ -232,7 +140,7 @@ export async function createArchive(
* **不会删除任何作业或通知** —— 它们本来就没被删过。
*/
export async function deleteArchive(user: TokenPayload, archiveId: string): Promise<void> {
assertCanManageClasses(user);
await assertGlobalPermission(user, 'archive:manage');
const record = await prisma.archivedYear.findUnique({ where: { id: archiveId } });
if (!record) throw ApiError.notFound('该届别档案不存在');
@@ -265,12 +173,13 @@ export async function deleteArchive(user: TokenPayload, archiveId: string): Prom
}
/** 归档页顶部的汇总(一届一行之外的全局数字) */
export async function getArchiveSummary(): Promise<{
export async function getArchiveSummary(user: TokenPayload): Promise<{
archiveCount: number;
classCount: number;
graduateCount: number;
transferredCount: number;
}> {
await assertGlobalPermission(user, 'archive:manage');
const [archiveCount, classCount, graduateCount, transferredCount] = await Promise.all([
prisma.archivedYear.count(),
prisma.class.count({ where: { NOT: { archivedYearId: null } } }),
@@ -1,10 +1,9 @@
import { prisma } from '../../lib/db.js';
import { assertCanManageClasses } from '../../lib/access.js';
import { assertGlobalPermission } from '../../lib/access.js';
import { restoreStudent } from '../students/students.service.js';
import type { TokenPayload } from '../../lib/jwt.js';
import { ApiError } from '../../lib/http.js';
import { notifyScheduleChanged } from '../class-plans/class-plans.service.js';
export async function listTransferred(user: TokenPayload) {
assertCanManageClasses(user);
await assertGlobalPermission(user, 'archive:manage');
return prisma.student.findMany({
where: { status: 'transferred' },
select: {
@@ -19,31 +18,11 @@ export async function listTransferred(user: TokenPayload) {
orderBy: { transferredAt: 'desc' },
});
}
export async function restoreTransferred(user: TokenPayload, id: string) {
assertCanManageClasses(user);
const student = await prisma.student.findUnique({ where: { id }, include: { class: true } });
if (student?.status !== 'transferred' || !student.classId || student.class?.archivedYearId)
throw ApiError.badRequest('只能撤回原班仍在读的转出记录');
await prisma.$transaction([
prisma.student.update({
where: { id },
data: { status: 'active', transferredAt: null, archivedAt: null, archivedYearId: null },
}),
prisma.studentClassTransfer.create({
data: {
studentId: id,
studentNo: student.studentNo,
studentName: student.name,
fromClassId: null,
toClassId: student.classId,
toClassName: student.class?.name ?? '',
operatorId: user.sub,
operatorName: user.name,
mode: 'restore-transfer',
note: '撤回转出,保留原转出记录',
},
}),
]);
await notifyScheduleChanged(student.classId);
export async function restoreTransferred(
user: TokenPayload,
id: string,
input: { toClassId?: string; note?: string } = {},
) {
await restoreStudent(user, id, input);
return { id, restored: true };
}
@@ -1,6 +1,6 @@
import { Router } from 'express';
import { sendCreated } from '../../lib/http.js';
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
import { authenticate, getAuthUser, requireStaffPermission } from '../../middleware/auth.js';
import { validate } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import { createCallSchema, type CreateCallInput } from './calls.schemas.js';
@@ -13,10 +13,15 @@ router.use(authenticate());
* POST /api/calls - 叫人(老师点名让学生过来)
* ClassHelper 班级端会在灵动岛上立即弹出"请 XXX 同学找 XXX 老师",无论是否在上课。
*/
router.post('/', requirePermission('call:create'), validate({ body: createCallSchema }), async (req, res) => {
const user = getAuthUser(req);
sendCreated(res, await callService.createCall(user, req.body as CreateCallInput), '已通知该学生');
});
router.post(
'/',
requireStaffPermission('call:create'),
validate({ body: createCallSchema }),
async (req, res) => {
const user = getAuthUser(req);
sendCreated(res, await callService.createCall(user, req.body as CreateCallInput), '已通知该学生');
},
);
export const callsModule = defineModule({
name: 'calls',
@@ -1,3 +1,5 @@
import { assertStaffPermission } from '../../lib/permissions.js';
import { resolveContentCreator } from '../../lib/access.js';
import { SOCKET_EVENTS, buildCallTitle, dayKeyLocal, type NotificationDto } from '@classhelper/shared';
import { assertCanPublishNotification } from '../../lib/access.js';
import { isActiveStudent } from '../../lib/students.js';
@@ -27,6 +29,7 @@ const creatorSelect = { select: { id: true, name: true, username: true } } as co
* 全班都看得到在叫谁,这与需求「教室机器代全班」一致。
*/
export async function createCall(user: TokenPayload, input: CreateCallInput): Promise<NotificationDto> {
await assertStaffPermission(user, 'call:create');
await assertCanPublishNotification(user, input.classId);
const student = await prisma.student.findUnique({
@@ -54,7 +57,7 @@ export async function createCall(user: TokenPayload, input: CreateCallInput): Pr
// 紧急叫人 → URGENT(客户端无视上课时段立即展开);普通叫人 → HIGH
// (不会触发上课时段 409 拦截,但客户端按普通通知排队处理)
priority: input.urgent ? 'URGENT' : 'HIGH',
createdBy: user.sub,
createdBy: await resolveContentCreator(user, input.classId),
assignDate: dayKeyLocal(new Date()),
},
include: { creator: creatorSelect, reads: true },
@@ -6,7 +6,12 @@ import { prisma } from '../../lib/db.js';
import { assertCanManageSchedule, assertClassAccess } from '../../lib/access.js';
import { sendOk, sendCreated, ApiError } from '../../lib/http.js';
import { idParamSchema } from '../../lib/schemas.js';
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
import {
authenticate,
getAuthUser,
requireStaffPermission,
requirePermission,
} from '../../middleware/auth.js';
import { validate, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import * as schemas from './class-plans.schemas.js';
@@ -14,8 +19,15 @@ import * as service from './class-plans.service.js';
const layouts = Router();
layouts.use(authenticate());
layouts.get('/', validate({ query: schemas.scopeQuery }), async (req, res) =>
sendOk(res, await service.listLayouts(getAuthUser(req), validatedQuery<{ classId?: string }>(req).classId)),
layouts.get(
'/',
requireStaffPermission('schedule:read'),
validate({ query: schemas.scopeQuery }),
async (req, res) =>
sendOk(
res,
await service.listLayouts(getAuthUser(req), validatedQuery<{ classId?: string }>(req).classId),
),
);
layouts.post(
'/',
@@ -83,12 +95,16 @@ layouts.delete(
const plans = Router();
plans.use(authenticate());
plans.get('/profile', validate({ query: z.object({ classId: z.string().min(1) }) }), async (req, res) =>
sendOk(res, await exportProfile(getAuthUser(req), validatedQuery<{ classId: string }>(req).classId)),
plans.get(
'/profile',
requireStaffPermission('schedule:read'),
validate({ query: z.object({ classId: z.string().min(1) }) }),
async (req, res) =>
sendOk(res, await exportProfile(getAuthUser(req), validatedQuery<{ classId: string }>(req).classId)),
);
plans.post(
'/import/profile',
requirePermission('schedule:write'),
requirePermission('schedule:write', 'import:schedule'),
validate({
body: z.object({
classId: z.string().min(1),
@@ -102,10 +118,15 @@ plans.post(
await importProfile(getAuthUser(req), req.body.classId, req.body.payload, req.body.mode),
),
);
plans.get('/', validate({ query: schemas.scopeQuery }), async (req, res) => {
const q = validatedQuery<{ classId?: string; week?: number }>(req);
sendOk(res, await service.listPlans(getAuthUser(req), q.classId, q.week));
});
plans.get(
'/',
requireStaffPermission('schedule:read'),
validate({ query: schemas.scopeQuery }),
async (req, res) => {
const q = validatedQuery<{ classId?: string; week?: number }>(req);
sendOk(res, await service.listPlans(getAuthUser(req), q.classId, q.week));
},
);
plans.post(
'/',
requirePermission('schedule:write'),
@@ -145,13 +166,18 @@ plans.post(
async (req, res) =>
sendCreated(res, await service.clonePlan(getAuthUser(req), String(req.params.id), req.body.name)),
);
plans.get('/:id/export', validate({ params: idParamSchema }), async (req, res) => {
const p = await service.requirePlan(getAuthUser(req), String(req.params.id), false);
sendOk(res, { format: 'classhelper-class-plan', plan: p, timeLayout: p.layout });
});
plans.get(
'/:id/export',
requireStaffPermission('schedule:read'),
validate({ params: idParamSchema }),
async (req, res) => {
const p = await service.requirePlan(getAuthUser(req), String(req.params.id), false);
sendOk(res, { format: 'classhelper-class-plan', plan: p, timeLayout: p.layout });
},
);
plans.post(
'/import',
requirePermission('schedule:write'),
requirePermission('schedule:write', 'import:schedule'),
validate({ body: z.object({ plan: schemas.planSchema, entries: schemas.entriesSchema.shape.entries }) }),
async (req, res) => {
const p = await service.savePlan(getAuthUser(req), req.body.plan);
@@ -172,17 +198,27 @@ plans.delete(
const groups = Router();
groups.use(authenticate());
groups.get('/selection', validate({ query: z.object({ classId: z.string().min(1) }) }), async (req, res) => {
const { classId } = validatedQuery<{ classId: string }>(req);
await assertClassAccess(getAuthUser(req), classId);
const value = await prisma.appSetting.findUnique({ where: { key: `schedule.group.${classId}` } });
sendOk(res, value ? JSON.parse(value.value) : {});
});
groups.get('/', validate({ query: z.object({ classId: z.string().min(1) }) }), async (req, res) => {
const { classId } = validatedQuery<{ classId: string }>(req);
await assertClassAccess(getAuthUser(req), classId);
sendOk(res, await prisma.classPlanGroup.findMany({ where: { classId } }));
});
groups.get(
'/selection',
requireStaffPermission('schedule:read'),
validate({ query: z.object({ classId: z.string().min(1) }) }),
async (req, res) => {
const { classId } = validatedQuery<{ classId: string }>(req);
await assertClassAccess(getAuthUser(req), classId);
const value = await prisma.appSetting.findUnique({ where: { key: `schedule.group.${classId}` } });
sendOk(res, value ? JSON.parse(value.value) : {});
},
);
groups.get(
'/',
requireStaffPermission('schedule:read'),
validate({ query: z.object({ classId: z.string().min(1) }) }),
async (req, res) => {
const { classId } = validatedQuery<{ classId: string }>(req);
await assertClassAccess(getAuthUser(req), classId);
sendOk(res, await prisma.classPlanGroup.findMany({ where: { classId } }));
},
);
groups.post(
'/',
requirePermission('schedule:write'),
@@ -2,7 +2,7 @@ import { createHash } from 'node:crypto';
import { z } from 'zod';
import { isDayKey, dayKeyLocal } from '@classhelper/shared';
import { prisma } from '../../lib/db.js';
import { assertCanManageSchedule } from '../../lib/access.js';
import { assertCanImportSchedule } from '../../lib/access.js';
import { ApiError } from '../../lib/http.js';
import type { TokenPayload } from '../../lib/jwt.js';
import { notifyScheduleChanged } from './class-plans.service.js';
@@ -21,7 +21,7 @@ function time(value: unknown) {
}
/** 直接保留上游三元组/四种时间点/显式课程位置,绝不经过单双周的有损中间格式。 */
export async function importProfile(user: TokenPayload, classId: string, payload: unknown, mode = 'merge') {
await assertCanManageSchedule(user, classId);
await assertCanImportSchedule(user, classId);
let parsed = payload;
if (typeof payload === 'string') {
try {
@@ -1,7 +1,12 @@
import { Router } from 'express';
import { sendCreated, sendOk } from '../../lib/http.js';
import { ApiError, sendCreated, sendOk } from '../../lib/http.js';
import { idParamSchema } from '../../lib/schemas.js';
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
import {
authenticate,
getAuthUser,
requireStaffPermission,
requirePermission,
} from '../../middleware/auth.js';
import { validate, validatedBody, validatedParams, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import {
@@ -35,35 +40,45 @@ router.use(authenticate());
* 只列**在读**班级(毕业归档的班级属于「归档管理」页);
* 教师/管理员看自己可访问的班级(班主任 ∪ 任课),ClassHelper 班级端看自己绑定的那个。
*/
router.get('/', validate({ query: listClassesQuerySchema }), async (req, res) => {
const user = getAuthUser(req);
const { keyword } = validatedQuery<{ keyword?: string }>(req);
sendOk(res, await classService.listClasses(user, keyword), '获取班级列表成功');
});
router.get(
'/',
requireStaffPermission('class:read'),
validate({ query: listClassesQuerySchema }),
async (req, res) => {
const user = getAuthUser(req);
const { keyword } = validatedQuery<{ keyword?: string }>(req);
sendOk(res, await classService.listClasses(user, keyword), '获取班级列表成功');
},
);
/**
* POST /api/classes/promote - 学年升级(批量改年级,仅管理员)
* POST /api/classes/promote - 学年升级(批量改年级,按功能权限及范围校验)
*
* **升级不归档、数据沿用**:只改 `grade`(高一 → 高二),班级记录、学生名单、学号、
* 作业与成绩全部原样。归档只在毕业时由「归档管理」页显式执行。
* 作业与成绩全部原样。毕业归档只由年度升级执行。
*/
router.post(
'/promote',
requirePermission('class:manage'),
requirePermission('promotion:manage'),
validate({ body: promoteClassesSchema }),
async (req, res) => {
const user = getAuthUser(req);
const body = validatedBody<{ classIds: string[]; grade: string }>(req);
const body = validatedBody<{ classIds: string[] }>(req);
sendOk(res, await classService.promoteClasses(user, body), '班级已升级,数据继续沿用');
},
);
/** GET /api/classes/:id - 班级详情(学生名单 + 课程 + 班主任 + 各科任课老师) */
router.get('/:id', validate({ params: idParamSchema }), async (req, res) => {
const user = getAuthUser(req);
const { id } = validatedParams<{ id: string }>(req);
sendOk(res, await classService.getClassDetail(user, id), '获取班级详情成功');
});
router.get(
'/:id',
requireStaffPermission('class:read'),
validate({ params: idParamSchema }),
async (req, res) => {
const user = getAuthUser(req);
const { id } = validatedParams<{ id: string }>(req);
sendOk(res, await classService.getClassDetail(user, id), '获取班级详情成功');
},
);
/** POST /api/classes - 创建班级(称呼由入学年份 + 班号生成,如「2026级1班」) */
router.post(
@@ -76,10 +91,14 @@ router.post(
},
);
/** PATCH /api/classes/:id - 编辑班级(年级 / 届别 / 教学周数),仅管理员 */
/** PATCH /api/classes/:id - 编辑班级(年级 / 届别 / 教学周数),按功能权限及范围校验 */
router.patch(
'/:id',
requirePermission('class:manage'),
(req, _res, next) => {
if (req.body && 'grade' in req.body) return next(ApiError.badRequest('年级请通过学年升级入口修改'));
next();
},
validate({ params: idParamSchema, body: updateClassSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -103,7 +122,7 @@ router.delete(
/**
* PATCH /api/classes/:id/class-account - 设置 / 重置班级账号(班级码 + 班级密码)
* 仅管理员:教室机器「ClassHelper 班级端」登录用的就是这个班级码
* 按功能权限及范围校验:教室机器「ClassHelper 班级端」登录用的就是这个班级码
*/
router.patch(
'/:id/class-account',
@@ -122,7 +141,7 @@ router.patch(
/**
* PATCH /api/classes/:id/head-teacher - 设置 / 更改班主任(每班 1 人)
* 仅管理员:班主任决定谁能管这个班的学生名单与课表,属于人员分配权限
* 按功能权限及范围校验:班主任决定谁能管这个班的学生名单与课表,属于人员分配权限
*/
router.patch(
'/:id/head-teacher',
@@ -139,7 +158,7 @@ router.patch(
/**
* PUT /api/classes/:id/subject-teachers - 设置各科任课老师(班级 + 科目 + 教师)
*
* 仅管理员。这是「科任老师」这条关系的唯一写入口 —— 作业与成绩能不能被某位老师改动,
* 按功能权限及范围校验。这是「科任老师」这条关系的唯一写入口 —— 作业与成绩能不能被某位老师改动,
* 查的就是这里写下的 `Course.teacherId`。
* 班主任同时是某科科任老师时,两条记录指向同一个账号:不重复建号、权限自动合并。
*/
@@ -162,14 +181,20 @@ router.put(
* GET /api/classes/:id/student-grade-query - 本班是否允许班级端按学号查成绩明细
* PATCH 同路径 - 教师/管理员按班级管理权限设置,班级端只读
*/
router.get('/:id/student-grade-query', validate({ params: idParamSchema }), async (req, res) => {
const user = getAuthUser(req);
const { id } = validatedParams<{ id: string }>(req);
sendOk(res, await classService.getStudentGradeQuery(user, id), '获取成绩查询开关成功');
});
router.get(
'/:id/student-grade-query',
requireStaffPermission('class:read'),
validate({ params: idParamSchema }),
async (req, res) => {
const user = getAuthUser(req);
const { id } = validatedParams<{ id: string }>(req);
sendOk(res, await classService.getStudentGradeQuery(user, id), '获取成绩查询开关成功');
},
);
router.patch(
'/:id/student-grade-query',
requirePermission('grade:write'),
validate({ params: idParamSchema, body: updateStudentGradeQuerySchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -183,11 +208,16 @@ router.patch(
);
/** GET /api/classes/:id/students - 班级学生名单 */
router.get('/:id/students', validate({ params: idParamSchema }), async (req, res) => {
const user = getAuthUser(req);
const { id } = validatedParams<{ id: string }>(req);
sendOk(res, await classService.listClassStudents(user, id), '获取学生名单成功');
});
router.get(
'/:id/students',
requireStaffPermission('roster:read'),
validate({ params: idParamSchema }),
async (req, res) => {
const user = getAuthUser(req);
const { id } = validatedParams<{ id: string }>(req);
sendOk(res, await classService.listClassStudents(user, id), '获取学生名单成功');
},
);
/** POST /api/classes/:id/students - 把学生加入班级(按学号,已存在则转入并留调班痕迹) */
router.post(
@@ -18,7 +18,7 @@ export const createClassSchema = z.object({
enrollmentYear: enrollmentYearSchema,
/** 班号(同一届内的序号) */
classIndex: classIndexSchema,
/** 仅管理员可指定班主任,教师创建时固定为自己 */
/** 班主任可空;具备班级管理权限的员工显式指定 */
teacherId: z.string().min(1).optional(),
/** 可选:自定义班级码(ClassHelper 班级端登录用),留空自动生成 */
code: z
@@ -37,11 +37,10 @@ export const createClassSchema = z.object({
/**
* 编辑班级。
*
* `grade` 是**学年升级**用的:高一 → 高二 只改它,班级记录与学生数据全部沿用,**不归档**。
* 年级只能通过升级计划修改,本接口维护届别、班号和作息覆盖。
* `enrollmentYear` / `classIndex` 是给"有名无实的旧数据"补届别用的,改了会重算班级称呼。
*/
export const updateClassSchema = z.object({
grade: z.string().trim().min(1).max(32).optional(),
export const updateClassSchema = z.strictObject({
/** 本班学期开始日期(第 1 教学周的周一);空串 = 回落到全局配置 */
termStartDate: z
.union([z.string().trim().refine(isDayKey, { message: '日期格式应为 YYYY-MM-DD' }), z.literal('')])
@@ -52,9 +51,8 @@ export const updateClassSchema = z.object({
});
/** 学年升级(批量改年级,不归档) */
export const promoteClassesSchema = z.object({
export const promoteClassesSchema = z.strictObject({
classIds: z.array(z.string().min(1)).min(1, '请至少选择一个班级').max(200),
grade: z.string().trim().min(1, '请输入升级后的年级').max(32),
});
/** 设置 / 重置班级账号(班级码 + 班级密码):仅管理员 */
@@ -4,7 +4,6 @@ import { env } from '../../config/env.js';
import {
assertCanAssignTeachers,
assertCanManageClasses,
assertCanManageRoster,
assertClassAccess,
classScopeIdFilter,
isStaff,
@@ -26,7 +25,11 @@ import type {
UpdateClassInput,
UpdateStudentGradeQueryInput,
} from './classes.schemas.js';
import { activeStudentWhere, isActiveStudent } from '../../lib/students.js';
import { loadPromotionPlan } from '../term/promotion.service.js';
import { assertClassInScope, canAccessClass } from '../../lib/access.js';
import { changeStudentLifecycle } from '../../lib/student-lifecycle.js';
import { assertStaffPermission, assertPermission, hasEffectivePermission } from '../../lib/permissions.js';
import { activeStudentWhere } from '../../lib/students.js';
import { getMaxTermWeeks } from '../../lib/term.js';
import { logger } from '../../lib/logger.js';
@@ -76,7 +79,8 @@ function toClassDtoWithAccount(
* 混在常规列表里会让老师误以为那届还在上课。需要看归档班级请用 `/api/archives`。
*/
export async function listClasses(user: TokenPayload, keyword?: string): Promise<ClassDto[]> {
const scope = await resolveClassScope(user);
await assertStaffPermission(user, 'class:read');
const scope = await resolveClassScope(user, undefined, 'class:read');
// Class 表按主键 id 过滤(classScopeWhere 针对的是带 classId 字段的子表)
const where = {
...classScopeIdFilter(scope),
@@ -98,6 +102,7 @@ export async function listClasses(user: TokenPayload, keyword?: string): Promise
/** 班级详情:学生名单 + 课程 + 班主任 + 各科任课老师 */
export async function getClassDetail(user: TokenPayload, classId: string): Promise<ClassDetailDto> {
await assertStaffPermission(user, 'class:read');
await assertClassAccess(user, classId);
const item = await prisma.class.findUnique({
@@ -117,7 +122,12 @@ export async function getClassDetail(user: TokenPayload, classId: string): Promi
const base = toClassDtoWithAccount(user, item);
return {
...base,
students: item.students.map(toStudentDto),
students:
user.role === 'CLASS_DEVICE' ||
((await hasEffectivePermission(user, 'roster:read')) &&
(await canAccessClass(user, classId, 'roster:read')))
? item.students.map(toStudentDto)
: [],
courses: item.courses.map(toCourseDto),
headTeacher: item.teacher ? toTeacherBrief(item.teacher) : null,
subjectTeachers: item.courses.map((course) => ({
@@ -159,7 +169,7 @@ export async function listSubjectTeachers(
/** 创建班级 */
export async function createClass(user: TokenPayload, input: CreateClassInput): Promise<ClassDto> {
assertCanManageClasses(user);
await assertCanManageClasses(user);
if (
'termWeeks' in input &&
typeof input.termWeeks === 'number' &&
@@ -167,8 +177,8 @@ export async function createClass(user: TokenPayload, input: CreateClassInput):
)
throw ApiError.badRequest('班级周数不能超过全校上限');
let teacherId = user.sub;
if (input.teacherId && user.role === 'ADMIN') {
let teacherId: string | null = null;
if (input.teacherId) {
teacherId = await requireTeacher(input.teacherId, '指定的班主任不存在或角色不是教师');
}
@@ -200,7 +210,7 @@ export async function createClass(user: TokenPayload, input: CreateClassInput):
}
/**
* 设置 / 更改班主任(仅管理员)。
* 设置 / 更改班主任(按班级管理权限与范围)。
*
* 班主任决定了"谁能管这个班的学生名单与课表",所以更换时要保证目标账号确实是教师/管理员。
* 科任关系**不受影响** —— 它在 `Course` 上,是独立的一条条记录;
@@ -211,7 +221,7 @@ export async function updateHeadTeacher(
classId: string,
teacherId: string,
): Promise<ClassDto> {
assertCanAssignTeachers(user);
await assertCanAssignTeachers(user, classId);
const targetId = await requireTeacher(teacherId, '指定的班主任不存在或角色不是教师');
const updated = await prisma.class.update({
@@ -228,7 +238,7 @@ export async function updateHeadTeacher(
}
/**
* 批量设置各科科任老师(仅管理员)。
* 批量设置各科科任老师(按班级管理权限与范围)。
*
* 入参里每条是「某班某科的任课老师」:
* - 给了 `courseId` 就改那门课;
@@ -242,7 +252,7 @@ export async function assignSubjectTeachers(
classId: string,
input: AssignSubjectTeachersInput,
): Promise<SubjectTeacherDto[]> {
assertCanAssignTeachers(user);
await assertCanAssignTeachers(user, classId);
const targetClass = await prisma.class.findUnique({ where: { id: classId }, select: { id: true } });
if (!targetClass) throw ApiError.notFound('班级不存在');
@@ -288,13 +298,13 @@ export async function assignSubjectTeachers(
}));
}
/** 设置 / 重置班级账号(班级码 + 班级密码):仅管理员 */
/** 设置 / 重置班级账号(班级码 + 班级密码):按班级管理权限与范围 */
export async function updateClassAccount(
user: TokenPayload,
classId: string,
input: { code?: string; password?: string },
): Promise<ClassDto> {
assertCanManageClasses(user);
await assertCanManageClasses(user, classId);
if (
'termWeeks' in input &&
typeof input.termWeeks === 'number' &&
@@ -315,13 +325,13 @@ export async function updateClassAccount(
return toClassDtoWithAccount(user, updated);
}
/** 编辑班级(年级 / 教学周数 / 届别) */
/** 编辑班级资料;年级只能通过升级计划修改。 */
export async function updateClass(
user: TokenPayload,
classId: string,
input: UpdateClassInput,
): Promise<ClassDto> {
assertCanManageClasses(user);
await assertCanManageClasses(user, classId);
const current = await prisma.class.findUnique({
where: { id: classId },
@@ -329,13 +339,15 @@ export async function updateClass(
});
if (!current) throw ApiError.notFound('班级不存在');
if ('grade' in input) throw ApiError.badRequest('修改年级请使用学年升级入口');
if (input.termWeeks !== undefined && input.termWeeks > (await getMaxTermWeeks()))
throw ApiError.badRequest('班级周数不能超过全校上限');
const enrollmentYear = input.enrollmentYear ?? current.enrollmentYear;
const classIndex = input.classIndex ?? current.classIndex;
const updated = await prisma.class.update({
where: { id: classId },
data: {
...(input.grade ? { grade: input.grade } : {}),
...(input.termWeeks !== undefined ? { termWeeks: input.termWeeks } : {}),
// 学期开始日期:空串/null 都表示"回落到全局配置"(如 .env 的 TERM_START_DATE)
...(input.termStartDate !== undefined
@@ -357,38 +369,60 @@ export async function updateClass(
}
/**
* 学年升级(仅管理员):把若干班级的年级改掉,**不归档、数据沿用**。
* 学年升级(按升级权限与班级范围):把若干班级的年级改掉,**不归档、数据沿用**。
*
* 这是「未毕业而升级的班级不归档」这条例则的操作入口 —— 高一 → 高二 只动 `grade`,
* 班级记录、学生名单、学号、作业与成绩全部原样。归档只在毕业时由归档页显式执行。
* 从已保存计划推导下一学段;毕业学段必须走年度升级归档。保留班级业务数据。
*/
export async function promoteClasses(
user: TokenPayload,
input: { classIds: string[]; grade: string },
input: { classIds: string[] },
): Promise<{ promoted: number }> {
assertCanManageClasses(user);
const result = await prisma.class.updateMany({
where: { id: { in: input.classIds }, archivedYearId: null },
data: { grade: input.grade },
await assertPermission(user, 'promotion:manage');
const plan = await loadPromotionPlan();
const ids = [...new Set(input.classIds)];
for (const id of ids) await assertClassInScope(user, id, 'promotion:manage');
await prisma.$transaction(async (tx) => {
const classes = await tx.class.findMany({ where: { id: { in: ids }, archivedYearId: null } });
if (classes.length !== ids.length) throw ApiError.conflict('部分班级不存在或已归档');
for (const cls of classes) {
const track = plan.tracks.find((track) => track.stages.some((stage) => stage.grade === cls.grade));
const index = track?.stages.findIndex((stage) => stage.grade === cls.grade) ?? -1;
if (!track || index < 0) throw ApiError.conflict('班级年级不在升级计划内');
if (track.stages[index].terminal) throw ApiError.conflict('毕业学段请通过年度升级进行归档');
const nextGrade = track.stages[index + 1].grade;
await tx.class.update({ where: { id: cls.id }, data: { grade: nextGrade, termStartDate: null } });
await tx.permissionAudit.create({
data: {
actorId: user.sub,
actorName: user.name,
action: 'promote-class',
targetType: 'class',
targetId: cls.id,
beforeJson: JSON.stringify({ grade: cls.grade }),
afterJson: JSON.stringify({ grade: nextGrade }),
},
});
}
});
for (const classId of input.classIds) {
emitToClass(classId, SOCKET_EVENTS.classUpdated, { classId, action: 'updated' });
}
return { promoted: result.count };
for (const classId of ids) emitToClass(classId, SOCKET_EVENTS.classUpdated, { classId, action: 'updated' });
return { promoted: ids.length };
}
/** 删除班级(级联删除课表/作业/通知/成绩;学生记录保留但解除班级归属) */
export async function deleteClass(user: TokenPayload, classId: string): Promise<void> {
assertCanManageClasses(user);
await assertCanManageClasses(user);
if (await prisma.student.count({ where: { classId } }))
throw ApiError.conflict('班级仍有学生,请先通过名单生命周期入口移出或归档,不能级联清除归属');
emitToClass(classId, SOCKET_EVENTS.classUpdated, { classId, action: 'deleted' });
await prisma.class.delete({ where: { id: classId } });
}
/** 班级学生名单 */
export async function listClassStudents(user: TokenPayload, classId: string): Promise<StudentDto[]> {
await assertStaffPermission(user, 'roster:read');
await assertClassAccess(user, classId);
const students = await prisma.student.findMany({
where: { classId, archivedYearId: null, ...activeStudentWhere() },
where: { classId, ...activeStudentWhere() },
include: { class: { select: { name: true, grade: true } } },
orderBy: { studentNo: 'asc' },
});
@@ -406,85 +440,28 @@ export async function addStudent(
classId: string,
input: AddStudentInput,
): Promise<StudentDto> {
await assertCanManageRoster(user, classId);
await assertClassAccess(user, classId);
const existing = await prisma.student.findUnique({ where: { studentNo: input.studentNo } });
let studentId: string;
if (existing && !isActiveStudent(existing))
throw ApiError.conflict('学号已被历史学生占用;请使用新学号或在归档页撤回转出');
if (existing) {
studentId = existing.id;
if (existing.classId !== classId) {
await prisma.studentClassTransfer.create({
data: {
studentId: existing.id,
studentNo: existing.studentNo,
studentName: input.name || existing.name,
fromClassId: existing.classId,
fromClassName: await classLabel(existing.classId),
toClassId: classId,
toClassName: await classLabel(classId),
operatorId: user.sub,
operatorName: user.name,
mode: 'single',
note: '在班级名单里直接加入',
},
});
}
await prisma.student.update({
where: { id: existing.id },
data: { classId, name: input.name, status: 'active' },
});
} else {
const created = await prisma.student.create({
data: {
studentNo: input.studentNo,
name: input.name,
classId,
gender: input.gender ?? '',
guardianPhone: input.guardianPhone ?? '',
},
});
studentId = created.id;
}
const student = await prisma.student.findUniqueOrThrow({
where: { id: studentId },
include: { class: { select: { name: true, grade: true } } },
if (existing && (existing.archivedYearId || existing.status !== 'active'))
throw ApiError.conflict('学号已被历史学生占用,请使用转入、复学或撤销归档入口');
const result = await changeStudentLifecycle(user, {
studentIds: existing ? [existing.id] : [],
kind: 'class-change',
toClassId: classId,
note: '班级名单添加',
...(!existing ? { newStudent: input } : {}),
});
emitToClass(classId, SOCKET_EVENTS.classUpdated, { classId, action: 'updated' });
return toStudentDto(student);
return toStudentDto(
await prisma.student.findUniqueOrThrow({
where: { id: result.studentId },
include: { class: { select: { name: true, grade: true } } },
}),
);
}
/** 将学生移出班级(保留名单记录,回到「未分班」) */
export async function removeStudent(user: TokenPayload, classId: string, studentId: string): Promise<void> {
await assertCanManageRoster(user, classId);
const student = await prisma.student.findUnique({ where: { id: studentId } });
if (!student) throw ApiError.notFound('学生不存在');
if (student.classId !== classId) throw ApiError.badRequest('该学生不属于此班级');
await prisma.student.update({ where: { id: studentId }, data: { classId: null } });
await prisma.studentClassTransfer.create({
data: {
studentId: student.id,
studentNo: student.studentNo,
studentName: student.name,
fromClassId: classId,
fromClassName: await classLabel(classId),
toClassId: null,
toClassName: '未分班',
operatorId: user.sub,
operatorName: user.name,
mode: 'single',
note: '从班级名单里移出',
},
});
emitToClass(classId, SOCKET_EVENTS.classUpdated, { classId, action: 'updated' });
if (!student || student.classId !== classId) throw ApiError.badRequest('学生不属于此班级');
await changeStudentLifecycle(user, { studentIds: [studentId], kind: 'unassign', note: '移出班级' });
}
/**
@@ -543,10 +520,3 @@ async function requireTeacher(teacherId: string, message: string): Promise<strin
}
return teacher.id;
}
/** 班级名(写调班历史快照用);班级不存在时返回空串 */
async function classLabel(classId: string | null): Promise<string> {
if (!classId) return '';
const record = await prisma.class.findUnique({ where: { id: classId }, select: { name: true } });
return record?.name ?? '';
}
@@ -1,7 +1,12 @@
import { Router } from 'express';
import { sendCreated, sendOk } from '../../lib/http.js';
import { idParamSchema } from '../../lib/schemas.js';
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
import {
authenticate,
getAuthUser,
requireStaffPermission,
requirePermission,
} from '../../middleware/auth.js';
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import {
@@ -17,11 +22,16 @@ const router = Router();
router.use(authenticate());
/** GET /api/courses?classId= - 课程列表 */
router.get('/', validate({ query: listCoursesQuerySchema }), async (req, res) => {
const user = getAuthUser(req);
const { classId } = validatedQuery<{ classId?: string }>(req);
sendOk(res, await courseService.listCourses(user, classId), '获取课程列表成功');
});
router.get(
'/',
requireStaffPermission('schedule:read'),
validate({ query: listCoursesQuerySchema }),
async (req, res) => {
const user = getAuthUser(req);
const { classId } = validatedQuery<{ classId?: string }>(req);
sendOk(res, await courseService.listCourses(user, classId), '获取课程列表成功');
},
);
/** POST /api/courses - 新增课程 */
router.post(
@@ -1,7 +1,12 @@
import { Router } from 'express';
import { sendCreated, sendOk } from '../../lib/http.js';
import { idParamSchema } from '../../lib/schemas.js';
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
import {
authenticate,
getAuthUser,
requireStaffPermission,
requirePermission,
} from '../../middleware/auth.js';
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import {
@@ -25,16 +30,21 @@ const router = Router();
router.use(authenticate());
/** GET /api/homeworks?classId=&courseId=&pendingOnly=&keyword= - 作业列表 */
router.get('/', validate({ query: listHomeworksQuerySchema }), async (req, res) => {
const user = getAuthUser(req);
const query = validatedQuery<{
classId?: string;
courseId?: string;
pendingOnly: boolean;
keyword?: string;
}>(req);
sendOk(res, await homeworkService.listHomeworks(user, query), '获取作业列表成功');
});
router.get(
'/',
requireStaffPermission('homework:read'),
validate({ query: listHomeworksQuerySchema }),
async (req, res) => {
const user = getAuthUser(req);
const query = validatedQuery<{
classId?: string;
courseId?: string;
pendingOnly: boolean;
keyword?: string;
}>(req);
sendOk(res, await homeworkService.listHomeworks(user, query), '获取作业列表成功');
},
);
/** GET /api/homeworks/:id - 作业详情 */
/**
@@ -42,17 +52,27 @@ router.get('/', validate({ query: listHomeworksQuerySchema }), async (req, res)
*
* 必须注册在 /:id 之前,否则 "days" 会被当成作业 id 命中 /:id 路由。
*/
router.get('/days', validate({ query: listHomeworkDaysQuerySchema }), async (req, res) => {
const user = getAuthUser(req);
const query = validatedQuery<ListHomeworkDaysInput>(req);
sendOk(res, await homeworkService.listHomeworkDays(user, query), '获取作业日历成功');
});
router.get(
'/days',
requireStaffPermission('homework:read'),
validate({ query: listHomeworkDaysQuerySchema }),
async (req, res) => {
const user = getAuthUser(req);
const query = validatedQuery<ListHomeworkDaysInput>(req);
sendOk(res, await homeworkService.listHomeworkDays(user, query), '获取作业日历成功');
},
);
router.get('/:id', validate({ params: idParamSchema }), async (req, res) => {
const user = getAuthUser(req);
const { id } = validatedParams<{ id: string }>(req);
sendOk(res, await homeworkService.getHomework(user, id), '获取作业详情成功');
});
router.get(
'/:id',
requireStaffPermission('homework:read'),
validate({ params: idParamSchema }),
async (req, res) => {
const user = getAuthUser(req);
const { id } = validatedParams<{ id: string }>(req);
sendOk(res, await homeworkService.getHomework(user, id), '获取作业详情成功');
},
);
/**
* POST /api/homeworks - 发布作业(发布后广播 homework:new)
@@ -62,6 +82,7 @@ router.get('/:id', validate({ params: idParamSchema }), async (req, res) => {
*/
router.post(
'/',
requireStaffPermission('homework:write'),
validate({ body: createHomeworkSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -87,10 +108,7 @@ router.post(
validate({ body: bulkCreateHomeworkSchema }),
async (req, res) => {
const user = getAuthUser(req);
const result = await homeworkService.bulkCreateHomeworks(
user,
req.body as BulkCreateHomeworkInput,
);
const result = await homeworkService.bulkCreateHomeworks(user, req.body as BulkCreateHomeworkInput);
sendCreated(res, result, `批量发布完成:成功 ${result.succeeded} 个班,失败 ${result.failed} 个班`);
},
);
@@ -127,6 +145,7 @@ router.delete(
/** PATCH /api/homeworks/:id/status - 标记完成 / 取消完成 */
router.patch(
'/:id/status',
requireStaffPermission('homework:write'),
validate({ params: idParamSchema, body: updateHomeworkStatusSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -141,16 +160,22 @@ router.patch(
);
/** GET /api/homeworks/:id/submissions - 提交名单(未交名单功能) */
router.get('/:id/submissions', validate({ params: idParamSchema }), async (req, res) => {
const user = getAuthUser(req);
const { id } = validatedParams<{ id: string }>(req);
const result = await homeworkService.listHomeworkSubmissions(user, id);
sendOk(res, result, `未交 ${result.notSubmitted.length} / ${result.total} 人`);
});
router.get(
'/:id/submissions',
requireStaffPermission('homework:read'),
validate({ params: idParamSchema }),
async (req, res) => {
const user = getAuthUser(req);
const { id } = validatedParams<{ id: string }>(req);
const result = await homeworkService.listHomeworkSubmissions(user, id);
sendOk(res, result, `未交 ${result.notSubmitted.length} / ${result.total} 人`);
},
);
/** PATCH /api/homeworks/:id/submissions - 勾选未交名单(其余学生一律视为已交) */
router.patch(
'/:id/submissions',
requireStaffPermission('homework:write'),
validate({ params: idParamSchema, body: updateHomeworkSubmissionsSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -1,3 +1,4 @@
import { assertStaffPermission } from '../../lib/permissions.js';
import {
SOCKET_EVENTS,
dayKeyLocal,
@@ -55,6 +56,7 @@ export async function listHomeworks(
user: TokenPayload,
options: ListHomeworkOptions,
): Promise<HomeworkDto[]> {
await assertStaffPermission(user, 'homework:read');
const scope = await resolveClassScope(user, options.classId);
const device = isClassDevice(user);
// ClassHelper 班级端以全班学生为范围;教师/管理员没有"自己的完成状态"
@@ -95,6 +97,7 @@ export async function listHomeworkDays(
user: TokenPayload,
options: { classId?: string; from?: string; to?: string; days?: number },
): Promise<HomeworkDaysDto> {
await assertStaffPermission(user, 'homework:read');
const scope = await resolveClassScope(user, options.classId);
const today = dayKeyLocal(new Date());
const to = options.to ?? today;
@@ -119,6 +122,7 @@ export async function listHomeworkDays(
}
export async function getHomework(user: TokenPayload, homeworkId: string): Promise<HomeworkDto> {
await assertStaffPermission(user, 'homework:read');
const device = isClassDevice(user);
const personalIds = device ? await resolvePersonalIds(user) : [];
const homework = await prisma.homework.findUnique({
@@ -152,6 +156,7 @@ export async function getHomework(user: TokenPayload, homeworkId: string): Promi
* 见 AGENTS.md 的现行工程约定),班级端录入的作业归属该班班主任。
*/
export async function createHomework(user: TokenPayload, input: CreateHomeworkInput): Promise<HomeworkDto> {
await assertStaffPermission(user, 'homework:write');
await assertCanManageSubjectContent(user, input.classId, input.courseId ?? null, '布置作业', {
allowClassDevice: true,
});
@@ -207,6 +212,7 @@ export async function bulkCreateHomeworks(
user: TokenPayload,
input: BulkCreateHomeworkRequest,
): Promise<BulkPublishResult> {
await assertStaffPermission(user, 'homework:write');
const classIds = normalizeBulkClassIds(input.classIds);
const results: BulkPublishClassResult[] = [];
@@ -255,6 +261,7 @@ export async function updateHomework(
homeworkId: string,
input: UpdateHomeworkInput,
): Promise<HomeworkDto> {
await assertStaffPermission(user, 'homework:write');
const current = await prisma.homework.findUnique({ where: { id: homeworkId } });
if (!current) throw ApiError.notFound('作业不存在');
@@ -287,6 +294,7 @@ export async function updateHomework(
}
export async function deleteHomework(user: TokenPayload, homeworkId: string): Promise<void> {
await assertStaffPermission(user, 'homework:write');
const current = await prisma.homework.findUnique({
where: { id: homeworkId },
include: { course: courseSelect, creator: creatorSelect },
@@ -315,6 +323,7 @@ export async function updateHomeworkStatus(
homeworkId: string,
input: UpdateHomeworkStatusInput,
): Promise<HomeworkStatusDto> {
await assertStaffPermission(user, 'homework:write');
const homework = await prisma.homework.findUnique({ where: { id: homeworkId } });
if (!homework) throw ApiError.notFound('作业不存在');
@@ -373,7 +382,7 @@ export async function updateHomeworkStatus(
async function buildSubmissions(classId: string, homeworkId: string): Promise<HomeworkSubmissionsDto> {
const [students, statuses] = await Promise.all([
prisma.student.findMany({
where: { classId, archivedYearId: null, ...activeStudentWhere() },
where: { classId, ...activeStudentWhere() },
select: { id: true, name: true, studentNo: true },
orderBy: { studentNo: 'asc' },
}),
@@ -408,6 +417,7 @@ export async function listHomeworkSubmissions(
user: TokenPayload,
homeworkId: string,
): Promise<HomeworkSubmissionsDto> {
await assertStaffPermission(user, 'homework:read');
const homework = await prisma.homework.findUnique({
where: { id: homeworkId },
select: { id: true, classId: true },
@@ -427,6 +437,7 @@ export async function updateHomeworkSubmissions(
homeworkId: string,
input: UpdateHomeworkSubmissionsInput,
): Promise<HomeworkSubmissionsDto> {
await assertStaffPermission(user, 'homework:write');
const homework = await prisma.homework.findUnique({
where: { id: homeworkId },
select: { id: true, classId: true },
@@ -436,7 +447,7 @@ export async function updateHomeworkSubmissions(
assertCanManageSubmissions(user);
const students = await prisma.student.findMany({
where: { classId: homework.classId, archivedYearId: null, ...activeStudentWhere() },
where: { classId: homework.classId, ...activeStudentWhere() },
select: { id: true },
});
if (students.length === 0) throw ApiError.badRequest('当前班级还没有学生,请先在名单里添加学生');
@@ -1,7 +1,7 @@
import { importProfile } from '../class-plans/profile-import.service.js';
import { prisma } from '../../lib/db.js';
import type { TokenPayload } from '../../lib/jwt.js';
import { assertCanManageSchedule } from '../../lib/access.js';
import { assertCanImportSchedule } from '../../lib/access.js';
import { weekParityFromDiv } from '@classhelper/shared';
import type { ClassPlanImportInput } from './imports.schemas.js';
@@ -243,7 +243,7 @@ export async function previewClassPlan(
user: TokenPayload,
input: ClassPlanImportInput,
): Promise<ClassPlanParseResult & { entries: (ParsedClassPlanEntry & { weekdayLabel: string })[] }> {
await assertCanManageSchedule(user, input.classId);
await assertCanImportSchedule(user, input.classId);
const parsed = parseClassIslandClassPlan(input.payload);
const courses = await prisma.course.findMany({
where: { classId: input.classId },
@@ -1,7 +1,15 @@
import { assertPermission } from '../../lib/permissions.js';
import type { TokenPayload } from '../../lib/jwt.js';
import { Router } from 'express';
import { ApiError, sendOk } from '../../lib/http.js';
import { idParamSchema } from '../../lib/schemas.js';
import { authenticate, getAuthUser, requirePermission, requireAnyPermission } from '../../middleware/auth.js';
import {
authenticate,
getAuthUser,
requireStaffPermission,
requirePermission,
requireAnyPermission,
} from '../../middleware/auth.js';
import { validate, validatedBody, validatedParams, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import {
@@ -28,13 +36,21 @@ import {
const router = Router();
router.use(authenticate());
/** 名单类导入(学生/教师账号)只有管理员能做;成绩类按班级权限判定 */
function assertImportKindAllowed(kind: (typeof IMPORT_KINDS)[number], role: string): void {
if ((kind === 'students' || kind === 'teachers') && role !== 'ADMIN') {
throw ApiError.forbidden(
kind === 'teachers' ? '只有管理员可以录入或导入教师账号' : '只有管理员可以导入学生名单',
);
}
/** 导入类型分别校验对应导入键,名单还校验管理键。 */
async function assertImportKindAllowed(
kind: (typeof IMPORT_KINDS)[number],
user: TokenPayload,
): Promise<void> {
const key = {
students: 'import:students',
teachers: 'import:teachers',
grades: 'grade:import',
classTeachers: 'import:class-teachers',
scheduleTimes: 'import:schedule',
} as const;
await assertPermission(user, key[kind]);
if (kind === 'students') await assertPermission(user, 'roster:manage');
if (kind === 'teachers') await assertPermission(user, 'teacher:manage');
}
/** 查询参数里的 kind 收敛为合法值(默认 grades,避免非法值被当成成绩悄悄写入) */
@@ -46,20 +62,14 @@ function resolveImportKind(value: unknown): (typeof IMPORT_KINDS)[number] {
/**
* GET /api/imports/template?kind=grades|students|teachers&format=csv|xlsx - 下载导入模板
* 成绩模板:管理员或班主任(空白模板,不含任何业务数据);学生/教师名单模板:仅管理员
* 成绩模板:管理员或班主任(空白模板,不含任何业务数据);学生/教师名单模板:对应导入与管理键
*/
router.get(
'/template',
requireAnyPermission(
'import:students',
'grade:import',
'import:teachers',
'import:schedule',
'import:class-teachers',
),
(req, res) => {
requireAnyPermission('import:students', 'grade:import', 'import:teachers', 'import:class-teachers'),
async (req, res) => {
const kind = resolveImportKind(req.query.kind);
assertImportKindAllowed(kind, getAuthUser(req).role);
await assertImportKindAllowed(kind, getAuthUser(req));
const format = req.query.format === 'xlsx' ? 'xlsx' : 'csv';
if (format === 'xlsx') {
const base64 = buildTemplateXlsx(kind);
@@ -78,21 +88,15 @@ router.get(
/**
* POST /api/imports/table/preview - 上传表格并预览(解析 + 必填列校验 + 建议字段映射)
* 成绩:管理员或班主任;学生/教师名单:仅管理员(与写入权限保持一致)
* 成绩:管理员或班主任;学生/教师名单按对应导入与管理键校验
*/
router.post(
'/table/preview',
requireAnyPermission(
'import:students',
'grade:import',
'import:teachers',
'import:schedule',
'import:class-teachers',
),
requireAnyPermission('import:students', 'grade:import', 'import:teachers', 'import:class-teachers'),
validate({ body: tableFileSchema }),
(req, res) => {
async (req, res) => {
const input = validatedBody<TableFileInput>(req);
assertImportKindAllowed(input.kind, getAuthUser(req).role);
await assertImportKindAllowed(input.kind, getAuthUser(req));
sendOk(res, previewTable(input), '表格解析成功');
},
);
@@ -101,21 +105,15 @@ router.post(
* POST /api/imports/table/commit - 执行导入(字段映射 + 写入模式 + 结果统计)
*
* 路由放行 ADMIN|TEACHER,真正的权限在 service 内按 kind + 班级二次校验:
* 成绩 → 管理员或本班班主任;学生名单 → 仅管理员;科任老师一律 403(需求 7)。
* 成绩按班级与科目收口;学生名单按管理键、导入键与班主任/全校范围校验。
*/
router.post(
'/table/commit',
requireAnyPermission(
'import:students',
'grade:import',
'import:teachers',
'import:schedule',
'import:class-teachers',
),
requireAnyPermission('import:students', 'grade:import', 'import:teachers', 'import:class-teachers'),
validate({ body: tableCommitSchema }),
async (req, res) => {
const input = validatedBody<TableCommitInput>(req);
assertImportKindAllowed(input.kind, getAuthUser(req).role);
await assertImportKindAllowed(input.kind, getAuthUser(req));
// 教师名单与班级无关;成绩/学生名单必须带班级,否则拒绝(避免写到"全局")
if (input.kind !== 'teachers' && input.kind !== 'classTeachers' && !input.classId) {
throw ApiError.badRequest('请先选择要导入的班级');
@@ -133,19 +131,29 @@ router.post(
* POST /api/imports/time-layout/preview - 预览 ClassIsland 课表时间 JSON(只解析不写库)
* 权限与"课表管理"一致:管理员或本班班主任
*/
router.post('/time-layout/preview', validate({ body: timeLayoutImportSchema }), async (req, res) => {
const input = validatedBody<TimeLayoutImportInput>(req);
// 预览也要校验班级权限(与导入一致),避免越权探测
const { assertCanManageSchedule } = await import('../../lib/access.js');
await assertCanManageSchedule(getAuthUser(req), input.classId);
sendOk(res, previewClassIslandTimeLayout(input.payload), '解析完成');
});
router.post(
'/time-layout/preview',
requirePermission('import:schedule', 'schedule:write'),
validate({ body: timeLayoutImportSchema }),
async (req, res) => {
const input = validatedBody<TimeLayoutImportInput>(req);
// 预览也要校验班级权限(与导入一致),避免越权探测
const { assertCanImportSchedule } = await import('../../lib/access.js');
await assertCanImportSchedule(getAuthUser(req), input.classId);
sendOk(res, previewClassIslandTimeLayout(input.payload), '解析完成');
},
);
/** GET /api/imports/time-layout?classId= - 查看该班已导入的时间配置 */
router.get('/time-layout', validate({ query: timeLayoutQuerySchema }), async (req, res) => {
const { classId } = validatedQuery<{ classId: string }>(req);
sendOk(res, await listTimeLayouts(getAuthUser(req), classId), '获取时间配置成功');
});
router.get(
'/time-layout',
requireStaffPermission('schedule:read'),
validate({ query: timeLayoutQuerySchema }),
async (req, res) => {
const { classId } = validatedQuery<{ classId: string }>(req);
sendOk(res, await listTimeLayouts(getAuthUser(req), classId), '获取时间配置成功');
},
);
/**
* POST /api/imports/time-layout - 导入 ClassIsland 时间配置
@@ -153,7 +161,7 @@ router.get('/time-layout', validate({ query: timeLayoutQuerySchema }), async (re
*/
router.post(
'/time-layout',
requirePermission('timetable:manage'),
requirePermission('timetable:manage', 'import:schedule'),
validate({ body: timeLayoutImportSchema }),
async (req, res) => {
const result = await importTimeLayout(getAuthUser(req), validatedBody<TimeLayoutImportInput>(req));
@@ -183,28 +191,38 @@ router.delete(
* POST /api/imports/class-plan/preview - 预览 ClassIsland 课程表 JSON(只解析不写库)
* 真实档案里单双周由 ClassPlan.TimeRule.WeekCountDiv / WeekCountDivTotal 表达。
*/
router.post('/class-plan/preview', validate({ body: classPlanImportSchema }), async (req, res) => {
const result = await previewClassPlan(getAuthUser(req), validatedBody<ClassPlanImportInput>(req));
sendOk(
res,
result,
`解析出 ${result.entries.length} 节课(${result.subjects.length} 个科目,缺 ${result.missingSubjects.length} 个待建课程)`,
);
});
router.post(
'/class-plan/preview',
requirePermission('import:schedule', 'schedule:write'),
validate({ body: classPlanImportSchema }),
async (req, res) => {
const result = await previewClassPlan(getAuthUser(req), validatedBody<ClassPlanImportInput>(req));
sendOk(
res,
result,
`解析出 ${result.entries.length} 节课(${result.subjects.length} 个科目,缺 ${result.missingSubjects.length} 个待建课程)`,
);
},
);
/**
* POST /api/imports/class-plan - 导入 ClassIsland 课程表(支持单双周)
* replace 清空后写入 / merge 按 星期+开始时间+单双周 去重;解析失败返回 400 且不改动现有课表。
*/
router.post('/class-plan', validate({ body: classPlanImportSchema }), async (req, res) => {
const result = await importClassPlan(getAuthUser(req), validatedBody<ClassPlanImportInput>(req));
sendOk(
res,
result,
`导入成功:新增 ${result.created} 节 / 更新 ${result.updated} 节` +
`${result.createdCourses.length > 0 ? `(自动补建课程:${result.createdCourses.join('、')})` : ''}`,
);
});
router.post(
'/class-plan',
requirePermission('import:schedule', 'schedule:write'),
validate({ body: classPlanImportSchema }),
async (req, res) => {
const result = await importClassPlan(getAuthUser(req), validatedBody<ClassPlanImportInput>(req));
sendOk(
res,
result,
`导入成功:新增 ${result.created} 节 / 更新 ${result.updated} 节` +
`${result.createdCourses.length > 0 ? `(自动补建课程:${result.createdCourses.join('、')})` : ''}`,
);
},
);
export const importsModule = defineModule({
name: 'imports',
@@ -1,3 +1,7 @@
import { assertPermission } from '../../lib/permissions.js';
import { assertCanManageRoster, assertCanAssignTeachers, assertClassAccess } from '../../lib/access.js';
import { addStudent } from '../classes/classes.service.js';
import { updateStudent } from '../students/students.service.js';
import * as XLSX from 'xlsx';
import { formatClassName, normalizeStudentNo, type TableImportKind } from '@classhelper/shared';
import { activeStudentWhere, isActiveStudent } from '../../lib/students.js';
@@ -566,7 +570,7 @@ export async function commitStudents(user: TokenPayload, input: TableCommitInput
const existing = await prisma.student.findUnique({
where: { studentNo },
select: { id: true, classId: true, status: true },
select: { id: true, classId: true, status: true, archivedYearId: true },
});
if (existing && !isActiveStudent(existing))
@@ -576,40 +580,16 @@ export async function commitStudents(user: TokenPayload, input: TableCommitInput
skipped += 1;
continue;
}
if (existing.classId !== classId) {
// 换班要留痕,与手动调班同一种历史
const [from, to] = await Promise.all([classNameOf(existing.classId), classNameOf(classId)]);
await prisma.studentClassTransfer.create({
data: {
studentId: existing.id,
studentNo,
studentName: name,
fromClassId: existing.classId,
fromClassName: from,
toClassId: classId,
toClassName: to,
operatorId: user.sub,
operatorName: user.name,
mode: 'batch',
note: '学生名单导入',
},
});
}
await prisma.student.update({
where: { id: existing.id },
data: {
name,
classId,
status: 'active',
...(gender ? { gender } : {}),
...(guardianPhone ? { guardianPhone } : {}),
},
if (existing.classId !== classId)
await addStudent(user, classId, { studentNo, name, gender, guardianPhone });
await updateStudent(user, existing.id, {
name,
...(gender ? { gender } : {}),
...(guardianPhone ? { guardianPhone } : {}),
});
updated += 1;
} else {
await prisma.student.create({
data: { studentNo, name, classId, gender, guardianPhone },
});
await addStudent(user, classId, { studentNo, name, gender, guardianPhone });
inserted += 1;
}
}
@@ -769,6 +749,7 @@ export async function commitClassTeachers(
continue;
}
await assertImportAllowed(user, targetClass.id, 'classTeachers');
const role = parseClassTeacherRole(roleCell);
if (!role) {
errors.push({ row: rowNumber, message: `角色「${roleCell}」无法识别,请填「班主任」或「科任」` });
@@ -816,6 +797,8 @@ export async function commitClassTeachers(
});
continue;
}
await assertPermission(user, 'teacher:manage');
await assertPermission(user, 'import:teachers');
const created = await prisma.user.create({
data: {
username: teacherNo,
@@ -898,9 +881,21 @@ async function assertImportAllowed(
classId: string | undefined,
kind: 'grades' | 'students' | 'teachers' | 'classTeachers' | 'scheduleTimes',
): Promise<void> {
const key = {
students: 'import:students',
teachers: 'import:teachers',
grades: 'grade:import',
classTeachers: 'import:class-teachers',
scheduleTimes: 'import:schedule',
} as const;
await assertPermission(user, key[kind]);
if (classId) await assertClassAccess(user, classId, key[kind]);
if (kind === 'students') return assertCanManageRoster(user, classId);
if (kind === 'teachers') return assertPermission(user, 'teacher:manage');
if (kind === 'classTeachers') return assertCanAssignTeachers(user, classId);
const classRole = classId ? await resolveUserClassRole(user, classId) : 'NONE';
if (canImport(user.role, classRole, kind)) return;
if (kind === 'students' || kind === 'grades') {
if (kind === 'grades') {
throw ApiError.forbidden(
classRole === 'SUBJECT' || classRole === 'CLASS_DEVICE'
? '科任老师与 ClassHelper 班级端不能导入名单或成绩,请联系班主任或管理员'
@@ -910,12 +905,6 @@ async function assertImportAllowed(
throw ApiError.forbidden('该导入仅限管理员使用');
}
async function classNameOf(classId: string | null): Promise<string> {
if (!classId) return '';
const record = await prisma.class.findUnique({ where: { id: classId }, select: { name: true } });
return record?.name ?? '';
}
/** 只接受明确的角色值;空值或未识别值保留原角色,避免意外降级。 */
function parseTeacherRole(cell: string): 'TEACHER' | 'ADMIN' | null {
const value = cell.trim().toUpperCase();
@@ -3,7 +3,7 @@ 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 { assertCanManageSchedule, assertCanImportSchedule } from '../../lib/access.js';
import type { TimeLayoutImportInput } from './imports.schemas.js';
/**
@@ -324,7 +324,7 @@ export async function importTimeLayout(
user: TokenPayload,
input: TimeLayoutImportInput,
): Promise<{ layout: TimeLayoutDto; mode: string; warnings: string[]; replaced: number; merged: number }> {
await assertCanManageSchedule(user, input.classId);
await assertCanImportSchedule(user, input.classId, 'timetable:manage');
const parsed = parseClassIslandTimeLayout(input.payload);
if (parsed.errors.length > 0) {
@@ -1,7 +1,12 @@
import { Router } from 'express';
import { sendCreated, sendOk } from '../../lib/http.js';
import { idParamSchema } from '../../lib/schemas.js';
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
import {
authenticate,
getAuthUser,
requireStaffPermission,
requirePermission,
} from '../../middleware/auth.js';
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import {
@@ -21,44 +26,69 @@ const router = Router();
router.use(authenticate());
/** GET /api/notifications?classId=&priority=&unreadOnly=&date=&from=&to=&createdBy= - 通知列表 */
router.get('/', validate({ query: listNotificationsQuerySchema }), async (req, res) => {
const user = getAuthUser(req);
const query = validatedQuery<ListNotificationQuery>(req);
sendOk(res, await notificationService.listNotifications(user, query), '获取通知列表成功');
});
router.get(
'/',
requireStaffPermission('notification:read'),
validate({ query: listNotificationsQuerySchema }),
async (req, res) => {
const user = getAuthUser(req);
const query = validatedQuery<ListNotificationQuery>(req);
sendOk(res, await notificationService.listNotifications(user, query), '获取通知列表成功');
},
);
/** GET /api/notifications/days?classId=&from=&to=&createdBy= - 按天归档(日期 + 条数) */
router.get('/days', validate({ query: notificationArchiveQuerySchema }), async (req, res) => {
const user = getAuthUser(req);
const query = validatedQuery<NotificationArchiveQuery>(req);
sendOk(res, await notificationService.listNotificationDays(user, query), '获取通知日期归档成功');
});
router.get(
'/days',
requireStaffPermission('notification:read'),
validate({ query: notificationArchiveQuerySchema }),
async (req, res) => {
const user = getAuthUser(req);
const query = validatedQuery<NotificationArchiveQuery>(req);
sendOk(res, await notificationService.listNotificationDays(user, query), '获取通知日期归档成功');
},
);
/** GET /api/notifications/creators?classId=&from=&to=&date= - 按发布人归档 */
router.get('/creators', validate({ query: notificationArchiveQuerySchema }), async (req, res) => {
const user = getAuthUser(req);
const query = validatedQuery<NotificationArchiveQuery>(req);
sendOk(res, await notificationService.listNotificationCreators(user, query), '获取通知发布人归档成功');
});
router.get(
'/creators',
requireStaffPermission('notification:read'),
validate({ query: notificationArchiveQuerySchema }),
async (req, res) => {
const user = getAuthUser(req);
const query = validatedQuery<NotificationArchiveQuery>(req);
sendOk(res, await notificationService.listNotificationCreators(user, query), '获取通知发布人归档成功');
},
);
/** GET /api/notifications/unread-count?classId= - 未读数(客户端红点) */
router.get('/unread-count', validate({ query: notificationScopeQuerySchema }), async (req, res) => {
const user = getAuthUser(req);
const { classId } = validatedQuery<{ classId?: string }>(req);
sendOk(res, { count: await notificationService.unreadCount(user, classId) }, '获取未读数成功');
});
router.get(
'/unread-count',
requireStaffPermission('notification:read'),
validate({ query: notificationScopeQuerySchema }),
async (req, res) => {
const user = getAuthUser(req);
const { classId } = validatedQuery<{ classId?: string }>(req);
sendOk(res, { count: await notificationService.unreadCount(user, classId) }, '获取未读数成功');
},
);
/** POST /api/notifications/read-all - 全部标为已读 */
router.post('/read-all', validate({ body: notificationScopeQuerySchema }), async (req, res) => {
const user = getAuthUser(req);
const { classId } = (req.body ?? {}) as { classId?: string };
sendOk(res, await notificationService.markAllAsRead(user, classId), '已全部标为已读');
});
router.post(
'/read-all',
requireStaffPermission('notification:read'),
validate({ body: notificationScopeQuerySchema }),
async (req, res) => {
const user = getAuthUser(req);
const { classId } = (req.body ?? {}) as { classId?: string };
sendOk(res, await notificationService.markAllAsRead(user, classId), '已全部标为已读');
},
);
/** POST /api/notifications - 发布通知(发布后广播 notification:new) */
router.post(
'/',
requirePermission('notification:publish'),
requireStaffPermission('notification:publish'),
validate({ body: createNotificationSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -70,7 +100,7 @@ router.post(
/** POST /api/notifications/bulk - 一次给多个班级发同一条通知(逐班结果,不整批回滚) */
router.post(
'/bulk',
requirePermission('notification:publish'),
requireStaffPermission('notification:publish'),
validate({ body: bulkCreateNotificationSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -83,11 +113,16 @@ router.post(
);
/** POST /api/notifications/:id/read - 标记已读 */
router.post('/:id/read', validate({ params: idParamSchema }), async (req, res) => {
const user = getAuthUser(req);
const { id } = validatedParams<{ id: string }>(req);
sendOk(res, await notificationService.markAsRead(user, id), '已标记为已读');
});
router.post(
'/:id/read',
requireStaffPermission('notification:read'),
validate({ params: idParamSchema }),
async (req, res) => {
const user = getAuthUser(req);
const { id } = validatedParams<{ id: string }>(req);
sendOk(res, await notificationService.markAsRead(user, id), '已标记为已读');
},
);
/** DELETE /api/notifications/:id - 删除通知 */
router.delete(
@@ -1,3 +1,5 @@
import { assertStaffPermission, assertPermission } from '../../lib/permissions.js';
import { resolveContentCreator } from '../../lib/access.js';
import {
SOCKET_EVENTS,
dayKeyLocal,
@@ -71,6 +73,7 @@ export async function listNotifications(
user: TokenPayload,
options: ListNotificationOptions,
): Promise<NotificationDto[]> {
await assertStaffPermission(user, 'notification:read');
const scope = await resolveClassScope(user, options.classId);
const isStaff = user.role === 'ADMIN' || user.role === 'TEACHER';
// 班级账号(班级设备)以全班学生为"个人范围",普通学生就是自己
@@ -109,6 +112,7 @@ export async function listNotificationDays(
user: TokenPayload,
options: { classId?: string; priority?: string; createdBy?: string; from?: string; to?: string },
): Promise<NotificationDaysDto> {
await assertStaffPermission(user, 'notification:read');
const scope = await resolveClassScope(user, options.classId);
const grouped = await prisma.notification.groupBy({
by: ['assignDate'],
@@ -129,6 +133,7 @@ export async function listNotificationCreators(
user: TokenPayload,
options: { classId?: string; priority?: string; date?: string; from?: string; to?: string },
): Promise<NotificationCreatorsDto> {
await assertStaffPermission(user, 'notification:read');
const scope = await resolveClassScope(user, options.classId);
const grouped = await prisma.notification.groupBy({
by: ['createdBy'],
@@ -166,6 +171,7 @@ export async function bulkCreateNotifications(
user: TokenPayload,
input: BulkCreateNotificationRequest,
): Promise<BulkPublishResult> {
await assertStaffPermission(user, 'notification:publish');
const classIds = normalizeBulkClassIds(input.classIds);
const results: BulkPublishClassResult[] = [];
@@ -202,6 +208,7 @@ export async function createNotification(
user: TokenPayload,
input: CreateNotificationInput,
): Promise<NotificationDto> {
await assertStaffPermission(user, 'notification:publish');
await assertCanPublishNotification(user, input.classId);
const priority = input.priority ?? 'NORMAL';
@@ -227,7 +234,7 @@ export async function createNotification(
title: input.title,
content: input.content,
priority,
createdBy: user.sub,
createdBy: await resolveContentCreator(user, input.classId),
// 按天归档用「本地日期」:晚间发布的通知不会被 UTC 切片算到第二天(与作业同口径)
assignDate: dayKeyLocal(new Date()),
},
@@ -241,6 +248,7 @@ export async function createNotification(
}
export async function deleteNotification(user: TokenPayload, notificationId: string): Promise<void> {
await assertPermission(user, 'notification:delete');
const current = await prisma.notification.findUnique({ where: { id: notificationId } });
if (!current) throw ApiError.notFound('通知不存在');
await assertCanPublishNotification(user, current.classId);
@@ -259,6 +267,7 @@ export async function markAsRead(
user: TokenPayload,
notificationId: string,
): Promise<{ notificationId: string; readAt: string; marked: number }> {
await assertStaffPermission(user, 'notification:read');
const notification = await prisma.notification.findUnique({ where: { id: notificationId } });
if (!notification) throw ApiError.notFound('通知不存在');
await assertClassAccess(user, notification.classId);
@@ -284,6 +293,7 @@ export async function markAsRead(
/** 全部标为已读(可按班级限定) */
export async function markAllAsRead(user: TokenPayload, classId?: string): Promise<{ marked: number }> {
await assertStaffPermission(user, 'notification:read');
const scope = await resolveClassScope(user, classId);
const personalIds = await resolvePersonalIds(user);
const personalWhere = personalIdWhere(personalIds);
@@ -314,6 +324,7 @@ export async function markAllAsRead(user: TokenPayload, classId?: string): Promi
/** 未读数量(客户端红点) */
export async function unreadCount(user: TokenPayload, classId?: string): Promise<number> {
await assertStaffPermission(user, 'notification:read');
const scope = await resolveClassScope(user, classId);
const personalIds = await resolvePersonalIds(user);
return prisma.notification.count({
@@ -1,8 +1,9 @@
import { z } from 'zod';
import { Router } from 'express';
import { sendCreated, sendOk } from '../../lib/http.js';
import { idParamSchema } from '../../lib/schemas.js';
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
import { validate, validatedBody, validatedParams } from '../../middleware/validate.js';
import { validate, validatedBody, validatedParams, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import {
assignUserGroupsSchema,
@@ -38,6 +39,30 @@ router.get('/groups', requirePermission('permission:manage'), async (_req, res)
sendOk(res, await permissionService.listGroups(), '获取权限组成功');
});
router.get('/scopes', requirePermission('permission:manage'), async (_req, res) =>
sendOk(res, await permissionService.scopeOptions()),
);
router.get(
'/audit',
requirePermission('permission:manage'),
validate({
query: z.object({
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(30),
}),
}),
async (req, res) => {
const { page, pageSize } = validatedQuery<{ page: number; pageSize: number }>(req);
sendOk(res, await permissionService.listAudits(page, pageSize));
},
);
router.get(
'/groups/:id/members',
requirePermission('permission:manage'),
validate({ params: idParamSchema }),
async (req, res) => sendOk(res, await permissionService.groupMembers(String(req.params.id))),
);
/** POST /api/permissions/groups - 新建权限组 */
router.post(
'/groups',
@@ -45,7 +70,7 @@ router.post(
validate({ body: createPermissionGroupSchema }),
async (req, res) => {
const input = validatedBody<CreatePermissionGroupInput>(req);
sendCreated(res, await permissionService.createGroup(input), '权限组已创建');
sendCreated(res, await permissionService.createGroup(getAuthUser(req), input), '权限组已创建');
},
);
@@ -57,7 +82,7 @@ router.patch(
async (req, res) => {
const { id } = validatedParams<{ id: string }>(req);
const input = validatedBody<UpdatePermissionGroupInput>(req);
sendOk(res, await permissionService.updateGroup(id, input), '权限组已更新');
sendOk(res, await permissionService.updateGroup(getAuthUser(req), id, input), '权限组已更新');
},
);
@@ -68,7 +93,7 @@ router.delete(
validate({ params: idParamSchema }),
async (req, res) => {
const { id } = validatedParams<{ id: string }>(req);
await permissionService.deleteGroup(id);
await permissionService.deleteGroup(getAuthUser(req), id);
sendOk(res, { id }, '权限组已删除,原成员回到角色默认权限');
},
);
@@ -86,7 +111,7 @@ router.put(
async (req, res) => {
const { id } = validatedParams<{ id: string }>(req);
const input = validatedBody<AssignUserGroupsInput>(req);
sendOk(res, await permissionService.assignUserGroups(id, input), '权限组分配已保存');
sendOk(res, await permissionService.assignUserGroups(getAuthUser(req), id, input), '权限组分配已保存');
},
);
@@ -16,7 +16,14 @@ import {
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 {
parsePermissionScope,
defaultPromotionPlan,
type PermissionScope,
type PromotionPlan,
} from '@classhelper/shared';
import { assertPermission } from '../../lib/permissions.js';
import { getSetting } from '../../lib/settings.js';
import {
ADMIN_UNREMOVABLE_PERMISSIONS,
ensureDefaultGroups,
@@ -66,14 +73,6 @@ const MEMBER_SELECT = {
permissionGroups: { select: { groupId: true } },
} as const;
type MemberRow = {
id: string;
username: string;
name: string;
role: string;
permissionGroups: { groupId: string }[];
};
/** 目录顺序(key → 序号):组的键与"我的权限"都按它排序,前端渲染才稳定 */
const KEY_ORDER = new Map<string, number>(PERMISSION_KEYS.map((key, index) => [key, index]));
@@ -97,6 +96,7 @@ function toGroupDto(row: GroupRow): PermissionGroupDto {
id: row.id,
scope: row.scope as PermissionGroupDto['scope'],
scopeValue: row.scopeValue,
scopeLabel: scopeLabel(row),
name: row.name,
description: row.description,
builtin: row.builtin,
@@ -111,6 +111,252 @@ function toGroupDto(row: GroupRow): PermissionGroupDto {
};
}
function scopeLabel(group: { scope: string; scopeValue: string }) {
try {
return parsePermissionScope(group.scope as PermissionScope, group.scopeValue).scopeLabel;
} catch {
return '未配置范围(无班级访问权)';
}
}
export async function scopeOptions() {
const classes = await prisma.class.findMany({ distinct: ['grade'], select: { grade: true } });
const raw = await getSetting('term.promotionPlan');
const plan: PromotionPlan = raw ? JSON.parse(raw) : defaultPromotionPlan();
const planGrades = plan.tracks.flatMap((track) => track.stages.map((stage) => stage.grade));
return {
grades: [...new Set([...classes.map((cls) => cls.grade), ...planGrades])].sort(),
planGrades,
divisions: plan.tracks.map((track) => ({
name: track.name,
grades: track.stages.map((stage) => stage.grade),
})),
};
}
async function validateScope(input: { scope?: PermissionScope; scopeValue?: string }) {
try {
return parsePermissionScope(
input.scope ?? 'SELF_CLASS',
input.scopeValue ?? '',
(await scopeOptions()).grades,
);
} catch (error) {
throw ApiError.badRequest(String(error instanceof Error ? error.message : error));
}
}
async function assertGrant(
actor: TokenPayload,
group: { scope: string; defaultRole?: string | null; keys: readonly string[] },
) {
await assertPermission(actor, 'permission:manage');
if (actor.role === 'ADMIN') return;
if (group.defaultRole) throw ApiError.forbidden('只有管理员可以管理角色默认组');
if (group.scope !== 'SELF_CLASS') throw ApiError.forbidden('只有管理员可以管理年级、部或全校范围组');
const own = await loadEffectivePermissions(actor);
if (group.keys.some((key) => !own.has(key as PermissionKey)))
throw ApiError.forbidden('不能授予或管理超出自己生效权限的组');
}
function auditData(
actor: TokenPayload,
action: string,
targetType: string,
targetId: string,
before: unknown,
after: unknown,
) {
return {
actorId: actor.sub,
actorName: actor.name,
action,
targetType,
targetId,
beforeJson: JSON.stringify(before),
afterJson: JSON.stringify(after),
};
}
export async function createGroup(
actor: TokenPayload,
input: CreatePermissionGroupRequest,
): Promise<PermissionGroupDto> {
const scope = await validateScope(input);
const keys = normalizeKeys(input.keys);
await assertGrant(actor, { ...scope, keys });
if (await prisma.permissionGroup.findUnique({ where: { name: input.name.trim() } }))
throw ApiError.conflict('权限组名称已存在');
const created = await prisma.$transaction(async (tx) => {
const row = await tx.permissionGroup.create({
data: {
name: input.name.trim(),
description: input.description?.trim() ?? '',
scope: scope.scope,
scopeValue: scope.scopeValue,
items: { create: keys.map((key) => ({ key })) },
},
select: GROUP_SELECT,
});
await tx.permissionAudit.create({
data: auditData(actor, 'create', 'group', row.id, null, toGroupDto(row)),
});
return row;
});
return toGroupDto(created);
}
export async function updateGroup(
actor: TokenPayload,
groupId: string,
input: UpdatePermissionGroupRequest,
): Promise<PermissionGroupDto> {
const current = await prisma.permissionGroup.findUnique({ where: { id: groupId }, select: GROUP_SELECT });
if (!current) throw ApiError.notFound('权限组不存在');
const before = toGroupDto(current);
await assertGrant(actor, { ...current, keys: before.keys });
const scope = await validateScope({
scope: input.scope ?? before.scope,
scopeValue: input.scopeValue ?? before.scopeValue,
});
const keys = normalizeKeys(input.keys ?? before.keys);
await assertGrant(actor, { ...scope, defaultRole: current.defaultRole, keys });
const updated = await prisma.$transaction(async (tx) => {
await tx.permissionGroupItem.deleteMany({ where: { groupId } });
const row = await tx.permissionGroup.update({
where: { id: groupId },
data: {
name: input.name?.trim(),
description: input.description?.trim(),
scope: scope.scope,
scopeValue: scope.scopeValue,
items: { create: keys.map((key) => ({ key })) },
},
select: GROUP_SELECT,
});
await tx.permissionAudit.create({
data: auditData(
actor,
current.defaultRole ? 'update-default' : 'update',
'group',
groupId,
before,
toGroupDto(row),
),
});
return row;
});
return toGroupDto(updated);
}
export async function deleteGroup(actor: TokenPayload, groupId: string) {
const current = await prisma.permissionGroup.findUnique({ where: { id: groupId }, select: GROUP_SELECT });
if (!current) throw ApiError.notFound('权限组不存在');
await assertGrant(actor, { ...current, keys: current.items.map((item) => item.key) });
if (current.builtin) throw ApiError.badRequest('内置权限组不可删除');
// 删除最后一组会回落默认组;非管理员不能借删除制造额外授权。
if (actor.role !== 'ADMIN') throw ApiError.forbidden('删除组会改变成员默认权限,仅管理员可执行');
await prisma.$transaction(async (tx) => {
await tx.permissionGroup.delete({ where: { id: groupId } });
await tx.permissionAudit.create({
data: auditData(actor, 'delete', 'group', groupId, toGroupDto(current), null),
});
});
return { id: groupId };
}
/** 组和成员各预取一次,账号数增长不会增加权限查询次数。 */
export async function listMembers(): Promise<PermissionMemberDto[]> {
await ensureDefaultGroups();
const [rows, groups] = await Promise.all([
prisma.user.findMany({
where: { role: { in: ['ADMIN', 'TEACHER'] } },
select: MEMBER_SELECT,
orderBy: [{ role: 'asc' }, { name: 'asc' }],
}),
prisma.permissionGroup.findMany({ include: { items: true } }),
]);
return rows.map((row) => {
const groupIds = row.permissionGroups.map((link) => link.groupId);
const effective = groups.filter((group) =>
groupIds.length ? groupIds.includes(group.id) : group.defaultRole === row.role,
);
const permissions = normalizeKeys([
...effective.flatMap((group) => group.items.map((item) => item.key)),
...(row.role === 'ADMIN' ? ADMIN_UNREMOVABLE_PERMISSIONS : []),
]);
return {
id: row.id,
username: row.username,
name: row.name,
role: row.role as UserRole,
groupIds,
permissions,
permissionCount: permissions.length,
scopeLabels: [...new Set(effective.map(scopeLabel))],
};
});
}
export async function groupMembers(groupId: string) {
const group = await prisma.permissionGroup.findUnique({ where: { id: groupId } });
if (!group) throw ApiError.notFound('权限组不存在');
return (await listMembers()).filter((member) =>
member.groupIds.length ? member.groupIds.includes(groupId) : member.role === group.defaultRole,
);
}
export async function assignUserGroups(
actor: TokenPayload,
userId: string,
input: AssignUserGroupsRequest,
): Promise<PermissionMemberDto> {
await assertPermission(actor, 'permission:manage');
const user = await prisma.user.findUnique({ where: { id: userId }, select: MEMBER_SELECT });
if (!user || !['ADMIN', 'TEACHER'].includes(user.role)) throw ApiError.notFound('员工账号不存在');
const ids = [...new Set(input.groupIds)];
const groups = await prisma.permissionGroup.findMany({
where: ids.length ? { id: { in: ids } } : { defaultRole: user.role },
include: { items: true },
});
if (ids.length && groups.length !== ids.length) throw ApiError.badRequest('部分权限组不存在');
for (const group of groups) {
if (group.defaultRole && group.defaultRole !== user.role)
throw ApiError.forbidden('默认组只能分配给同角色账号');
// 同角色默认组可显式选中,但仍检查范围与键的授权能力。
await assertGrant(actor, { ...group, defaultRole: null, keys: group.items.map((item) => item.key) });
}
if (actor.role !== 'ADMIN' && user.role === 'ADMIN') throw ApiError.forbidden('非管理员不能更改管理员分组');
const before = (await listMembers()).find((member) => member.id === userId)!;
await prisma.$transaction(async (tx) => {
await tx.userPermissionGroup.deleteMany({ where: { userId } });
if (ids.length)
await tx.userPermissionGroup.createMany({ data: ids.map((groupId) => ({ userId, groupId })) });
await tx.permissionAudit.create({
data: auditData(actor, 'assign', 'user', userId, before, {
groupIds: ids,
permissions: normalizeKeys([
...groups.flatMap((group) => group.items.map((item) => item.key)),
...(user.role === 'ADMIN' ? ADMIN_UNREMOVABLE_PERMISSIONS : []),
]),
}),
});
});
return (await listMembers()).find((member) => member.id === userId)!;
}
export async function listAudits(page = 1, pageSize = 30) {
const [items, total] = await Promise.all([
prisma.permissionAudit.findMany({
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
skip: (page - 1) * pageSize,
take: pageSize,
}),
prisma.permissionAudit.count(),
]);
return { items, total, page, pageSize };
}
/** GET /api/permissions/catalog - 权限键目录(权限页整棵勾选树都按它渲染) */
export async function permissionCatalog(): Promise<PermissionCatalogDto> {
await ensureDefaultGroups();
@@ -150,213 +396,13 @@ export async function listGroups(): Promise<PermissionGroupDto[]> {
select: GROUP_SELECT,
orderBy: [{ builtin: 'desc' }, { createdAt: 'asc' }],
});
return (rows as GroupRow[]).map(toGroupDto);
}
export async function createGroup(input: CreatePermissionGroupRequest): Promise<PermissionGroupDto> {
const name = input.name.trim();
const sameName = await prisma.permissionGroup.findUnique({ where: { name }, select: { id: true } });
if (sameName) throw ApiError.conflict(`已存在名为「${name}」的权限组`);
const keys = normalizeKeys(input.keys);
const created = await prisma.permissionGroup.create({
data: {
name,
scope: input.scope ?? 'SELF_CLASS',
scopeValue: input.scopeValue ?? '',
description: input.description?.trim() ?? '',
items: { create: keys.map((key) => ({ key })) },
},
select: GROUP_SELECT,
});
logger.info(`新建权限组「${name}」(${keys.length} 个权限键)`);
return toGroupDto(created as GroupRow);
}
export async function updateGroup(
groupId: string,
input: UpdatePermissionGroupRequest,
): Promise<PermissionGroupDto> {
const current = await prisma.permissionGroup.findUnique({
where: { id: groupId },
select: { id: true, name: true },
});
if (!current) throw ApiError.notFound('权限组不存在');
const name = input.name?.trim();
if (name && name !== current.name) {
const sameName = await prisma.permissionGroup.findUnique({ where: { name }, select: { id: true } });
if (sameName) throw ApiError.conflict(`已存在名为「${name}」的权限组`);
}
const keys = input.keys ? normalizeKeys(input.keys) : null;
await prisma.$transaction(async (tx) => {
await tx.permissionGroup.update({
where: { id: groupId },
data: {
...(name ? { name } : {}),
...(input.scope ? { scope: input.scope } : {}),
...(input.scopeValue !== undefined ? { scopeValue: input.scopeValue } : {}),
...(input.description !== undefined ? { description: input.description.trim() } : {}),
},
});
// 键是整体替换:先清后建。漏传某个键就等于"取消勾选",不会留下幽灵权限。
if (keys) {
await tx.permissionGroupItem.deleteMany({ where: { groupId } });
if (keys.length > 0) {
await tx.permissionGroupItem.createMany({ data: keys.map((key) => ({ groupId, key })) });
}
}
});
const updated = await prisma.permissionGroup.findUniqueOrThrow({
where: { id: groupId },
select: GROUP_SELECT,
});
logger.info(
`更新权限组「${(updated as GroupRow).name}」(${keys ? `权限键 ${keys.length} 个` : '未改权限键'})`,
);
return toGroupDto(updated as GroupRow);
}
export async function deleteGroup(groupId: string): Promise<{ id: string }> {
const current = await prisma.permissionGroup.findUnique({
where: { id: groupId },
select: { id: true, name: true, builtin: true, _count: { select: { users: true } } },
});
if (!current) throw ApiError.notFound('权限组不存在');
if (current.builtin) {
throw ApiError.badRequest(`「${current.name}」是内置权限组,不能删除(可以改名或调整里面的权限)`);
}
// 键与成员关系都是 onDelete: Cascade,直接删组即可;
// 组内成员随后"没有任何分组",会回落角色默认模板(不会变成空权限)。
await prisma.permissionGroup.delete({ where: { id: groupId } });
logger.info(`删除权限组「${current.name}」(原成员 ${current._count.users} 个,已回到角色默认权限)`);
return { id: groupId };
}
/** 把一行 User(含分组关联)转成权限页用的成员 DTO */
function toMemberDto(row: MemberRow, keysOfGroup: Map<string, PermissionKey[]>): PermissionMemberDto {
const groupIds = row.permissionGroups.map((link) => link.groupId);
const union = new Set<PermissionKey>();
for (const groupId of groupIds) for (const key of keysOfGroup.get(groupId) ?? []) union.add(key);
const role = row.role as UserRole;
const permissionCount =
role === 'ADMIN'
? PERMISSION_KEYS.length
: groupIds.length === 0
? ROLE_DEFAULT_PERMISSIONS[role].length
: union.size;
return {
id: row.id,
username: row.username,
name: row.name,
role,
groupIds,
permissionCount,
fixed: false,
};
}
/** GET /api/permissions/users - 成员列表(管理员/教师账号;班级端账号不参与权限组) */
export async function listMembers(): Promise<PermissionMemberDto[]> {
const [rows, groups] = await Promise.all([
prisma.user.findMany({
where: { role: { in: ['ADMIN', 'TEACHER'] } },
select: MEMBER_SELECT,
orderBy: [{ role: 'asc' }, { name: 'asc' }],
}),
prisma.permissionGroup.findMany({ select: { id: true, items: { select: { key: true } } } }),
]);
const keysOfGroup = new Map<string, PermissionKey[]>(
groups.map((group) => [
group.id,
group.items
.map((item) => item.key)
.filter(knownKey)
.sort(byCatalogOrder),
]),
);
return Promise.all(
(rows as MemberRow[]).map(async (row) => ({
...toMemberDto(row, keysOfGroup),
permissionCount: (
await loadEffectivePermissions({
sub: row.id,
role: row.role as UserRole,
username: row.username,
name: row.name,
classId: null,
})
).size,
})),
);
}
/** PUT /api/permissions/users/:id/groups - 整体替换某账号的权限组 */
export async function assignUserGroups(
userId: string,
input: AssignUserGroupsRequest,
): Promise<PermissionMemberDto> {
const user = await prisma.user.findUnique({ where: { id: userId }, select: MEMBER_SELECT });
if (!user) throw ApiError.notFound('账号不存在');
const row = user as MemberRow;
if (row.role === 'CLASS_DEVICE') {
throw ApiError.badRequest(
'ClassHelper 班级端账号的能力由"班级内角色"决定(见权限矩阵),不参与权限组分配',
);
}
const groupIds = [...new Set(input.groupIds)];
if (groupIds.length > 0) {
const found = await prisma.permissionGroup.count({ where: { id: { in: groupIds } } });
if (found !== groupIds.length) throw ApiError.badRequest('有权限组不存在,请刷新后重试');
}
await prisma.$transaction(async (tx) => {
await tx.userPermissionGroup.deleteMany({ where: { userId } });
if (groupIds.length > 0) {
await tx.userPermissionGroup.createMany({
data: groupIds.map((groupId) => ({ userId, groupId })),
});
}
});
logger.info(
`分配权限组:${row.username} → ${groupIds.length > 0 ? `${groupIds.length} 个组` : '角色默认模板'}`,
);
const groups = await prisma.permissionGroup.findMany({
select: { id: true, items: { select: { key: true } } },
});
const keysOfGroup = new Map<string, PermissionKey[]>(
groups.map((group) => [
group.id,
group.items
.map((item) => item.key)
.filter(knownKey)
.sort(byCatalogOrder),
]),
);
return {
...toMemberDto({ ...row, permissionGroups: groupIds.map((groupId) => ({ groupId })) }, keysOfGroup),
permissionCount: (
await loadEffectivePermissions({
sub: row.id,
role: row.role as UserRole,
username: row.username,
name: row.name,
classId: null,
})
).size,
};
const members = await listMembers();
return (rows as GroupRow[]).map((row) => ({
...toGroupDto(row),
memberCount: members.filter((m) =>
m.groupIds.length ? m.groupIds.includes(row.id) : row.defaultRole === m.role,
).length,
}));
}
/**
@@ -7,7 +7,12 @@ import { ApiError } from '../../lib/http.js';
import { Router } from 'express';
import { sendCreated, sendOk } from '../../lib/http.js';
import { idParamSchema } from '../../lib/schemas.js';
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
import {
authenticate,
getAuthUser,
requireStaffPermission,
requirePermission,
} from '../../middleware/auth.js';
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import {
@@ -23,21 +28,30 @@ import * as scheduleService from './schedules.service.js';
const router = Router();
router.use(authenticate());
router.get('/active-week', validate({ query: gridQuerySchema }), async (req, res) =>
sendOk(res, await scheduleService.getActiveScheduleWeek(getAuthUser(req), validatedQuery(req))),
router.get(
'/active-week',
requireStaffPermission('schedule:read'),
validate({ query: gridQuerySchema }),
async (req, res) =>
sendOk(res, await scheduleService.getActiveScheduleWeek(getAuthUser(req), validatedQuery(req))),
);
router.get(
'/scheduled',
requireStaffPermission('schedule:read'),
validate({ query: z.object({ classId: z.string().min(1) }) }),
async (req, res) => {
const { classId } = validatedQuery<{ classId: string }>(req);
await assertClassAccess(getAuthUser(req), classId);
sendOk(
res,
await prisma.scheduledClassPlan.findMany({
where: { classId },
include: { plan: { select: { name: true, isOverlay: true } } },
orderBy: { date: 'asc' },
}),
);
},
);
router.get('/scheduled', validate({ query: z.object({ classId: z.string().min(1) }) }), async (req, res) => {
const { classId } = validatedQuery<{ classId: string }>(req);
await assertClassAccess(getAuthUser(req), classId);
sendOk(
res,
await prisma.scheduledClassPlan.findMany({
where: { classId },
include: { plan: { select: { name: true, isOverlay: true } } },
orderBy: { date: 'asc' },
}),
);
});
router.delete(
'/scheduled/:id',
requirePermission('schedule:write'),
@@ -93,41 +107,56 @@ router.post(
);
/** GET /api/schedules?classId=&week=&dayOfWeek= - 课表列表 */
router.get('/', validate({ query: listSchedulesQuerySchema }), async (req, res) => {
const user = getAuthUser(req);
const query = validatedQuery<{ classId?: string; week?: number; dayOfWeek?: number }>(req);
sendOk(res, await scheduleService.listSchedules(user, query), '获取课表成功');
});
router.get(
'/',
requireStaffPermission('schedule:read'),
validate({ query: listSchedulesQuerySchema }),
async (req, res) => {
const user = getAuthUser(req);
const query = validatedQuery<{ classId?: string; week?: number; dayOfWeek?: number }>(req);
sendOk(res, await scheduleService.listSchedules(user, query), '获取课表成功');
},
);
/** GET /api/schedules/grid?classId=&week= - 周视图(7 列结构) */
router.get('/grid', validate({ query: gridQuerySchema }), async (req, res) => {
const user = getAuthUser(req);
const { classId, week } = validatedQuery<{ classId?: string; week?: number }>(req);
sendOk(
res,
await scheduleService.getScheduleGrid(user, {
classId,
// 不传 week 就取「本班口径」的当前周次(由 service 内按班级配置解析)
week,
}),
'获取周视图成功',
);
});
router.get(
'/grid',
requireStaffPermission('schedule:read'),
validate({ query: gridQuerySchema }),
async (req, res) => {
const user = getAuthUser(req);
const { classId, week } = validatedQuery<{ classId?: string; week?: number }>(req);
sendOk(
res,
await scheduleService.getScheduleGrid(user, {
classId,
// 不传 week 就取「本班口径」的当前周次(由 service 内按班级配置解析)
week,
}),
'获取周视图成功',
);
},
);
/**
* GET /api/schedules/current?classId=&at= - 班级当前上课状态。
* 客户端灵动岛(上课期间隐藏、下课后弹出)与 Web 端"上课时段发布紧急通知"
* 的全屏二次确认都基于它;`at` 为诊断/联调用时间覆盖。
*/
router.get('/current', validate({ query: classStatusQuerySchema }), async (req, res) => {
const user = getAuthUser(req);
const { classId, at } = validatedQuery<{ classId?: string; at?: string }>(req);
const status = await scheduleService.getClassStatus(user, {
classId,
at: at ? new Date(at) : undefined,
});
sendOk(res, status, status.inClass ? '当前处于上课时间段' : '当前不在上课时间段');
});
router.get(
'/current',
requireStaffPermission('schedule:read'),
validate({ query: classStatusQuerySchema }),
async (req, res) => {
const user = getAuthUser(req);
const { classId, at } = validatedQuery<{ classId?: string; at?: string }>(req);
const status = await scheduleService.getClassStatus(user, {
classId,
at: at ? new Date(at) : undefined,
});
sendOk(res, status, status.inClass ? '当前处于上课时间段' : '当前不在上课时间段');
},
);
/** POST /api/schedules - 新增课表 */
router.post(
@@ -1,11 +1,14 @@
import { Router } from 'express';
import { sendCreated, sendOk } from '../../lib/http.js';
import { ApiError, sendCreated, sendOk } from '../../lib/http.js';
import { idParamSchema } from '../../lib/schemas.js';
import { authenticate, getAuthUser, requirePermission, requireAnyPermission } from '../../middleware/auth.js';
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import {
createStudentSchema,
transferInSchema,
restoreStudentSchema,
deleteStudentQuerySchema,
listStudentsQuerySchema,
listTransfersQuerySchema,
transferOutStudentsSchema,
@@ -29,11 +32,11 @@ router.use(authenticate());
* - 学生**没有账号**:这里是"名单记录"的增删改,没有任何密码/角色字段;
* - 读:教师/管理员按自己可访问的班级过滤(服务层 `resolveClassScope`);
* 教室机器读本班名单请用 `GET /api/classes/:id/students`;
* - 写:管理员或**本班班主任**(服务层逐班判权限),调班与转出仅管理员。
* - 写:管理员或**本班班主任**(服务层逐班判权限),调班与转出还需覆盖源班和目标班。
*/
router.get(
'/',
requireAnyPermission('roster:read', 'roster:manage'),
requirePermission('roster:read'),
validate({ query: listStudentsQuerySchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -45,7 +48,7 @@ router.get(
/** GET /api/students/transfers - 调班 / 转出历史(管理员看全部,也可按学生或班级过滤) */
router.get(
'/transfers',
requireAnyPermission('roster:read', 'roster:manage'),
requirePermission('roster:read'),
validate({ query: listTransfersQuerySchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -62,7 +65,7 @@ router.get(
*/
router.post(
'/transfer',
requirePermission('class:manage'),
requirePermission('roster:manage'),
validate({ body: transferStudentsSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -74,7 +77,7 @@ router.post(
/** POST /api/students/transfer-out - 学生转出(学籍离开本校,不是调班) */
router.post(
'/transfer-out',
requirePermission('class:manage'),
requirePermission('roster:manage'),
validate({ body: transferOutStudentsSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -83,10 +86,24 @@ router.post(
},
);
router.post(
'/transfer-in',
requirePermission('roster:manage'),
validate({ body: transferInSchema }),
async (req, res) => sendCreated(res, await studentService.transferInStudent(getAuthUser(req), req.body)),
);
router.post(
'/:id/restore',
requirePermission('roster:manage'),
validate({ params: idParamSchema, body: restoreStudentSchema }),
async (req, res) =>
sendOk(res, await studentService.restoreStudent(getAuthUser(req), String(req.params.id), req.body)),
);
/** GET /api/students/:id/transfers - 单个学生的调班 / 转出历史 */
router.get(
'/:id/transfers',
requirePermission('roster:manage'),
requirePermission('roster:read'),
validate({ params: idParamSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -115,6 +132,11 @@ router.post(
router.patch(
'/:id',
requirePermission('roster:manage'),
(req, _res, next) => {
if (req.body && ('classId' in req.body || 'status' in req.body))
return next(ApiError.badRequest('班级和状态请使用调班、转出或复学接口修改'));
next();
},
validate({ params: idParamSchema, body: updateStudentSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -131,12 +153,16 @@ router.patch(
router.delete(
'/:id',
requirePermission('roster:manage'),
validate({ params: idParamSchema }),
validate({ params: idParamSchema, query: deleteStudentQuerySchema }),
async (req, res) => {
const user = getAuthUser(req);
const { id } = validatedParams<{ id: string }>(req);
await studentService.deleteStudent(user, id);
sendOk(res, { id }, '学生记录已删除');
const query = validatedQuery<{ hard?: string; confirm?: string }>(req);
await studentService.deleteStudent(user, id, {
hard: query.hard === 'true',
confirm: query.confirm === 'true',
});
sendOk(res, { id }, '学生名单操作已完成');
},
);
@@ -23,22 +23,44 @@ export const listStudentsQuerySchema = z.object({
// 学生只有「名单」没有「账号」:没有密码字段、没有角色字段,也不能登录。
// 学号是学生的唯一标识与查询键,`studentNoSchema` 负责归一化与格式校验。
export const createStudentSchema = z.object({
studentNo: studentNoSchema,
name: studentNameSchema,
classId: z.string().min(1).nullish(),
gender: studentGenderSchema,
guardianPhone: guardianPhoneSchema,
status: studentStatusSchema,
});
export const createStudentSchema = z
.object({
studentNo: studentNoSchema,
name: studentNameSchema,
classId: z.string().min(1).nullish(),
gender: studentGenderSchema,
guardianPhone: guardianPhoneSchema,
status: z.enum(['active', 'inactive']).optional(),
})
.strict();
export const updateStudentSchema = z.object({
studentNo: studentNoSchema.optional(),
name: studentNameSchema.optional(),
classId: z.string().min(1).nullish(),
gender: studentGenderSchema,
guardianPhone: guardianPhoneSchema,
status: studentStatusSchema,
export const updateStudentSchema = z
.object({
studentNo: studentNoSchema.optional(),
name: studentNameSchema.optional(),
gender: studentGenderSchema,
guardianPhone: guardianPhoneSchema,
})
.strict();
export const transferInSchema = z
.object({
studentId: z.string().min(1).optional(),
studentNo: studentNoSchema.optional(),
name: studentNameSchema.optional(),
toClassId: z.string().min(1),
gender: studentGenderSchema,
guardianPhone: guardianPhoneSchema,
fromSchool: z.string().trim().max(200).optional(),
note: z.string().trim().max(200).optional(),
})
.strict();
export const restoreStudentSchema = z
.object({ toClassId: z.string().min(1).optional(), note: z.string().trim().max(200).optional() })
.strict();
export const deleteStudentQuerySchema = z.object({
hard: z.enum(['true', 'false']).optional(),
confirm: z.enum(['true', 'false']).optional(),
});
/**
@@ -49,17 +71,21 @@ export const updateStudentSchema = z.object({
export const transferStudentsSchema = z.object({
studentIds: z.array(z.string().min(1)).min(1, '请至少选择一名学生').max(500),
toClassId: z.string().min(1).nullable(),
allowGradeChange: z.boolean().optional(),
note: z.string().trim().max(200).optional(),
});
/** 转出(学籍离开本校,不是调班):班级归属保留作历史 */
export const transferOutStudentsSchema = z.object({
toSchool: z.string().trim().max(200).optional(),
studentIds: z.array(z.string().min(1)).min(1, '请至少选择一名学生').max(500),
note: z.string().trim().max(200).optional(),
});
/** 调班历史查询 */
export const listTransfersQuerySchema = z.object({
batchId: z.string().optional(),
academicYear: z.coerce.number().int().optional(),
studentId: z.string().min(1).optional(),
classId: z.string().min(1).optional(),
page: z.coerce.number().int().min(1).optional(),
@@ -5,15 +5,17 @@ import {
assertClassAccess,
classScopeWhere,
isAdmin,
getAccessibleClassIds,
resolveClassScope,
} from '../../lib/access.js';
import { activeStudentWhere, isActiveStudent } from '../../lib/students.js';
import { assertStaffPermission, assertPermission } from '../../lib/permissions.js';
import { changeStudentLifecycle, notifyStudentClasses } from '../../lib/student-lifecycle.js';
import { activeStudentWhere } from '../../lib/students.js';
import { prisma } from '../../lib/db.js';
import { hasAllScope } from '../../lib/permission-scope.js';
import { ApiError } from '../../lib/http.js';
import type { TokenPayload } from '../../lib/jwt.js';
import { toStudentDto, toStudentTransferDto } from '../../lib/mappers.js';
import { SOCKET_EVENTS } from '@classhelper/shared';
import { emitToClass } from '../../realtime/bus.js';
import type {
CreateStudentInput,
ListStudentsQuery,
@@ -36,13 +38,13 @@ const withClass = { class: { select: { name: true, grade: true } } } as const;
/** 学生名单(按班级权限过滤) */
export async function listStudents(user: TokenPayload, options: ListStudentsQuery): Promise<StudentDto[]> {
const scope = await resolveClassScope(user, options.classId);
await assertStaffPermission(user, 'roster:read');
const scope = await resolveClassScope(user, options.classId, 'roster:read');
const students = await prisma.student.findMany({
where: {
...classScopeWhere(scope),
...activeStudentWhere(),
...(options.status ? { AND: [{ status: options.status }, activeStudentWhere()] } : {}),
status: options.status ?? 'active',
// 默认不显示已归档(毕业 / 转出)的学生:他们属于归档页,不该混在在读名单里
...(options.includeArchived ? {} : { archivedYearId: null }),
...(options.keyword
@@ -74,181 +76,141 @@ export async function findStudentByNo(studentNo: string, classId?: string): Prom
export async function createStudent(user: TokenPayload, input: CreateStudentInput): Promise<StudentDto> {
await assertCanManageRoster(user, input.classId ?? undefined);
await assertStudentNoFree(input.studentNo);
if (input.classId) await assertClassExists(input.classId);
const created = await prisma.student.create({
data: {
const result = await changeStudentLifecycle(user, {
studentIds: [],
kind: 'class-change',
toClassId: input.classId,
newStudent: {
studentNo: input.studentNo,
name: input.name,
classId: input.classId ?? null,
gender: input.gender ?? '',
guardianPhone: input.guardianPhone ?? '',
status: input.status ?? 'active',
gender: input.gender,
guardianPhone: input.guardianPhone,
status: input.status,
},
});
const created = await prisma.student.findUniqueOrThrow({
where: { id: result.studentId },
include: withClass,
});
if (created.classId) {
emitToClass(created.classId, SOCKET_EVENTS.classUpdated, {
classId: created.classId,
action: 'updated',
});
}
return toStudentDto(created);
}
/** 修改学生信息(含调班;走本接口的单个调班同样会记入调班历史) */
/** 资料修改不承担状态或班级迁移;旧请求明确报错。 */
export async function updateStudent(
user: TokenPayload,
studentId: string,
input: UpdateStudentInput,
): Promise<StudentDto> {
if ('classId' in input || 'status' in input)
throw ApiError.badRequest('班级和状态请使用调班、转出或复学接口修改');
const student = await prisma.student.findUnique({ where: { id: studentId } });
if (!student || !isActiveStudent(student)) throw ApiError.notFound('学生不存在');
if (!student) throw ApiError.notFound('学生不存在');
if (student.archivedYearId || !['active', 'inactive'].includes(student.status))
throw ApiError.conflict('历史学生资料只读,请先办理转入或撤销归档');
await assertCanManageRoster(user, student.classId ?? undefined);
if (input.studentNo && input.studentNo !== student.studentNo) {
await assertStudentNoFree(input.studentNo, studentId);
}
const nextClassId = input.classId === undefined ? student.classId : (input.classId ?? null);
const classChanged = nextClassId !== student.classId;
if (classChanged) await assertCanManageRoster(user, nextClassId ?? undefined);
const updated = await prisma.student.update({
where: { id: studentId },
data: {
...(input.studentNo ? { studentNo: input.studentNo } : {}),
...(input.name ? { name: input.name } : {}),
...(input.gender !== undefined ? { gender: input.gender } : {}),
...(input.guardianPhone !== undefined ? { guardianPhone: input.guardianPhone } : {}),
...(input.status !== undefined ? { status: input.status } : {}),
classId: nextClassId,
},
include: withClass,
});
// 换班要留痕(单个调班与批量调班都写同一种历史)
if (classChanged) {
await recordTransfer(user, [updated], student.classId, nextClassId, 'single', '');
}
if (input.studentNo) await assertStudentNoFree(input.studentNo, studentId);
const updated = await prisma.student.update({ where: { id: studentId }, data: input, include: withClass });
notifyStudentClasses([student.classId]);
return toStudentDto(updated);
}
/** 删除学生记录(级联删除其作业状态、成绩、已读记录与调班历史) */
export async function deleteStudent(user: TokenPayload, studentId: string): Promise<void> {
/** 默认停用且保留业务;硬删除须管理员确认并证明没有任何历史。 */
export async function deleteStudent(
user: TokenPayload,
studentId: string,
options: { hard?: boolean; confirm?: boolean } = {},
): Promise<void> {
const student = await prisma.student.findUnique({ where: { id: studentId } });
if (!student) throw ApiError.notFound('学生不存在');
await assertCanManageRoster(user, student.classId ?? undefined);
await prisma.student.delete({ where: { id: studentId } });
if (!options.hard) {
await changeStudentLifecycle(user, {
studentIds: [studentId],
kind: 'deactivate',
note: '名单停用,保留全部业务历史',
});
return;
}
if (!isAdmin(user) || !options.confirm) throw ApiError.forbidden('硬删除需要管理员二次确认');
await prisma.$transaction(async (tx) => {
const current = await tx.student.findUniqueOrThrow({
where: { id: studentId },
include: {
_count: {
select: { grades: true, homeworkStatuses: true, notificationReads: true, transfers: true },
},
},
});
if (
current.archivedYearId ||
!['active', 'inactive'].includes(current.status) ||
Object.values(current._count).some(Boolean)
)
throw ApiError.conflict('该生有成绩、作业、已读或生命周期历史,不能硬删除;请停用保留记录');
await tx.student.delete({ where: { id: studentId } });
});
notifyStudentClasses([student.classId]);
}
/* ---------------------------------------------------------------- 调班 / 转出 */
/**
* 调班:单个与批量共用(传一个 id 就是单个)。
*
* 规则(需求「管理员调班」):
* - 只改学生**当前班级**,学号不变、不创建任何账号;
* - 原班级、新班级、操作人、时间写入 `StudentClassTransfer`,历史可查;
* - 历史作业与成绩保留原归属(它们挂在各自的 `classId` 上,不随学生走)。
*/
export async function transferStudents(
user: TokenPayload,
input: TransferStudentsInput,
): Promise<{ moved: number; transfers: StudentTransferDto[] }> {
if (!isAdmin(user)) throw ApiError.forbidden('只有管理员可以调班');
const students = await prisma.student.findMany({
where: { id: { in: input.studentIds }, ...activeStudentWhere() },
export async function transferStudents(user: TokenPayload, input: TransferStudentsInput) {
const result = await changeStudentLifecycle(user, {
...input,
kind: input.toClassId ? 'class-change' : 'unassign',
});
if (students.length !== input.studentIds.length) {
throw ApiError.badRequest('部分学生不存在,请刷新后重试');
}
if (input.toClassId) await assertClassExists(input.toClassId);
const movable = students.filter((item) => item.classId !== input.toClassId);
if (movable.length === 0) {
throw ApiError.badRequest('所选学生已经全部在这个班级里');
}
await prisma.$transaction(
movable.map((item) =>
prisma.student.update({ where: { id: item.id }, data: { classId: input.toClassId } }),
),
);
const mode = movable.length > 1 ? 'batch' : 'single';
// 注意传的是**调班前**的 movable(classId 还是原班级):历史里的"原班级"要按每条自己的来
const transfers = await recordTransfer(user, movable, null, input.toClassId, mode, input.note ?? '');
notifyClassChange(
movable.map((item) => item.classId),
input.toClassId,
);
return { moved: movable.length, transfers };
return { ...result, moved: result.count };
}
/**
* 学生转出:学籍离开本校(**不是调班**)。
*
* 班级归属保留作历史(成绩/作业/已读都还在),状态置为 `transferred`,
* 并写一条 `mode = 'transfer-out'` 的历史,归档页据此统计"当年转出的学生"。
*/
export async function transferOutStudents(
export async function transferOutStudents(user: TokenPayload, input: TransferOutStudentsInput) {
const result = await changeStudentLifecycle(user, { ...input, kind: 'transfer-out' });
return { ...result, transferred: result.count };
}
export async function transferInStudent(
user: TokenPayload,
input: TransferOutStudentsInput,
): Promise<{ transferred: number; transfers: StudentTransferDto[] }> {
if (!isAdmin(user)) throw ApiError.forbidden('只有管理员可以办理学生转出');
const students = await prisma.student.findMany({ where: { id: { in: input.studentIds } } });
if (students.length !== input.studentIds.length) {
throw ApiError.badRequest('部分学生不存在,请刷新后重试');
}
const pending = students.filter((item) => item.status !== 'transferred');
if (pending.length === 0) throw ApiError.badRequest('所选学生都已经转出');
const now = new Date();
await prisma.$transaction(
pending.map((item) =>
prisma.student.update({
where: { id: item.id },
data: { status: 'transferred', transferredAt: now, transferNote: input.note ?? '' },
}),
),
);
// 班级名快照:转出时把学生"离开的那个班"记下来,之后班级改名也不影响历史
const classNames = await loadClassNames(pending.map((item) => item.classId));
await prisma.studentClassTransfer.createMany({
data: pending.map((item) => ({
studentId: item.id,
studentNo: item.studentNo,
studentName: item.name,
fromClassId: item.classId,
fromClassName: item.classId ? (classNames.get(item.classId) ?? '') : '',
toClassId: null,
toClassName: '转出本校',
operatorId: user.sub,
operatorName: user.name,
mode: 'transfer-out',
note: input.note ?? '',
})),
input: {
studentId?: string;
studentNo?: string;
name?: string;
toClassId: string;
gender?: string;
guardianPhone?: string;
fromSchool?: string;
note?: string;
},
) {
if (!input.studentId && (!input.studentNo || !input.name))
throw ApiError.badRequest('新转入学生必须填写学号与姓名');
const result = await changeStudentLifecycle(user, {
...input,
kind: 'transfer-in',
studentIds: input.studentId ? [input.studentId] : [],
...(!input.studentId
? {
newStudent: {
studentNo: input.studentNo!,
name: input.name!,
gender: input.gender,
guardianPhone: input.guardianPhone,
},
}
: {}),
});
notifyClassChange(
pending.map((item) => item.classId),
null,
return toStudentDto(
await prisma.student.findUniqueOrThrow({ where: { id: result.studentId }, include: withClass }),
);
const created = await prisma.studentClassTransfer.findMany({
where: { studentId: { in: pending.map((item) => item.id) }, mode: 'transfer-out' },
orderBy: { createdAt: 'desc' },
take: pending.length,
}
export async function restoreStudent(
user: TokenPayload,
id: string,
input: { toClassId?: string; note?: string } = {},
) {
const student = await prisma.student.findUnique({ where: { id } });
if (!student) throw ApiError.notFound('学生不存在');
return changeStudentLifecycle(user, {
studentIds: [id],
kind: 'restore',
toClassId: input.toClassId ?? student.classId,
note: input.note ?? '撤回转出或复学,保留原历史',
});
return { transferred: pending.length, transfers: created.map(toStudentTransferDto) };
}
/** 调班 / 转出历史(单个学生或某个班级,管理员看全部) */
@@ -256,9 +218,8 @@ export async function listTransfers(
user: TokenPayload,
options: ListTransfersQuery,
): Promise<PaginatedResult<StudentTransferDto>> {
if (!isAdmin(user) && !options.classId && !options.studentId) {
throw ApiError.forbidden('查看全部调班历史需要管理员权限');
}
await assertPermission(user, 'roster:read');
const hasGlobalRosterScope = isAdmin(user) || (await hasAllScope(user, 'roster:read'));
if (options.classId) await assertClassAccess(user, options.classId);
if (options.studentId) {
@@ -268,11 +229,16 @@ export async function listTransfers(
});
if (!student) throw ApiError.notFound('学生不存在');
if (student.classId) await assertClassAccess(user, student.classId);
else if (!isAdmin(user)) throw ApiError.forbidden('无权查看未分班学生的调班历史');
else if (!hasGlobalRosterScope) throw ApiError.forbidden('无权查看未分班学生的调班历史');
}
const accessible = hasGlobalRosterScope ? null : await getAccessibleClassIds(user, 'roster:read');
const where = {
student: activeStudentWhere(),
...(options.batchId ? { batchId: options.batchId } : {}),
...(options.academicYear ? { academicYear: options.academicYear } : {}),
...(accessible
? { AND: [{ OR: [{ fromClassId: { in: accessible } }, { toClassId: { in: accessible } }] }] }
: {}),
...(options.studentId ? { studentId: options.studentId } : {}),
...(options.classId ? { OR: [{ fromClassId: options.classId }, { toClassId: options.classId }] } : {}),
};
@@ -292,76 +258,6 @@ export async function listTransfers(
return { items: items.map(toStudentTransferDto), total, page, pageSize };
}
/* ---------------------------------------------------------------- 内部工具 */
/**
* 写入调班历史。
*
* - `fromOverride` 非空时,所有条目都用它作为"原班级"(单个调班场景);
* - 为空时逐条取**该学生自己的 classId** 作为"原班级"(批量调班场景,
* 各人的来源班可能不同)。
*/
async function recordTransfer(
user: TokenPayload,
students: { id: string; studentNo: string; name: string; classId: string | null }[],
fromOverride: string | null,
toClassId: string | null,
mode: 'single' | 'batch',
note: string,
): Promise<StudentTransferDto[]> {
const fromIds = students.map((item) => fromOverride ?? item.classId);
const classNames = await loadClassNames([...fromIds, toClassId]);
await prisma.studentClassTransfer.createMany({
data: students.map((item, index) => {
const fromClassId = fromIds[index] ?? null;
return {
studentId: item.id,
studentNo: item.studentNo,
studentName: item.name,
fromClassId,
fromClassName: fromClassId ? (classNames.get(fromClassId) ?? '') : '',
toClassId,
toClassName: toClassId ? (classNames.get(toClassId) ?? '') : '未分班',
operatorId: user.sub,
operatorName: user.name,
mode,
note,
};
}),
});
const created = await prisma.studentClassTransfer.findMany({
where: { studentId: { in: students.map((item) => item.id) } },
orderBy: { createdAt: 'desc' },
take: students.length,
});
return created.map(toStudentTransferDto);
}
async function loadClassNames(classIds: (string | null)[]): Promise<Map<string, string>> {
const ids = [...new Set(classIds.filter((id): id is string => Boolean(id)))];
if (ids.length === 0) return new Map();
const classes = await prisma.class.findMany({
where: { id: { in: ids } },
select: { id: true, name: true },
});
return new Map(classes.map((item) => [item.id, item.name]));
}
/** 调班后让相关班级的前端刷新名单(旧班与新班都要收到) */
function notifyClassChange(fromClassIds: (string | null)[], toClassId: string | null): void {
const targets = new Set([...fromClassIds, toClassId].filter((id): id is string => Boolean(id)));
for (const classId of targets) {
emitToClass(classId, SOCKET_EVENTS.classUpdated, { classId, action: 'updated' });
}
}
async function assertClassExists(classId: string): Promise<void> {
const record = await prisma.class.findUnique({ where: { id: classId }, select: { id: true } });
if (!record) throw ApiError.badRequest('班级不存在');
}
/** 学号唯一:`Student.studentNo` 上已有唯一约束,这里给出一句人话的 409 */
async function assertStudentNoFree(studentNo: string, exceptId?: string): Promise<void> {
const existing = await prisma.student.findUnique({
@@ -1,210 +1,462 @@
import { randomUUID } from 'node:crypto';
import { createHash, randomUUID } from 'node:crypto';
import fs from 'node:fs/promises';
import path from 'node:path';
import { z } from 'zod';
import { dayKeyLocal, formatClassName } from '@classhelper/shared';
import {
dayKeyLocal,
formatClassName,
isDayKey,
defaultPromotionPlan,
SOCKET_EVENTS,
type PromotionPlan,
} from '@classhelper/shared';
import { prisma } from '../../lib/db.js';
import type { Prisma } from '../../generated/prisma/client.js';
import type { TokenPayload } from '../../lib/jwt.js';
import { env } from '../../config/env.js';
import { ApiError } from '../../lib/http.js';
import { assertGlobalPermission } from '../../lib/access.js';
import { exportSnapshot } from '../../lib/snapshot.js';
import { getSetting, setSetting, SETTING_KEYS } from '../../lib/settings.js';
import { SETTING_KEYS } from '../../lib/settings.js';
import { getMaxTermWeeks } from '../../lib/term.js';
import { hashPassword } from '../../lib/password.js';
import { logger } from '../../lib/logger.js';
import { emitToClass } from '../../realtime/bus.js';
import { notifyScheduleChanged } from '../class-plans/class-plans.service.js';
export const promotionConfigSchema = z.object({
month: z.number().int().min(1).max(12),
day: z.number().int().min(1).max(31),
hour: z.number().int().min(0).max(23),
classCount: z.number().int().min(1).max(99).default(1),
});
export async function promotionStatus(now = new Date()) {
const raw = await getSetting(SETTING_KEYS.promotionAt);
let config = { month: 8, day: 1, hour: 0, classCount: 1 };
if (raw) {
const parsed = promotionConfigSchema.safeParse(JSON.parse(raw));
if (parsed.success) config = parsed.data;
}
export const promotionPlanSchema = z
.object({
tracks: z
.array(
z.object({
id: z.string().regex(/^[a-z][a-z0-9-]{0,19}$/),
name: z.string().trim().min(1).max(40),
entryClassCount: z.number().int().min(0).max(99),
stages: z
.array(z.object({ grade: z.string().trim().min(1).max(32), terminal: z.boolean().optional() }))
.min(1)
.max(20),
}),
)
.min(1)
.max(20),
termStartDate: z.string().refine(isDayKey, '学期开始日期无效').nullable().default(null),
resetTermWeeks: z.boolean().default(true),
})
.superRefine((plan, ctx) => {
const grades = new Set<string>(),
tracks = new Set<string>();
for (const [index, track] of plan.tracks.entries()) {
if (tracks.has(track.id))
ctx.addIssue({ code: 'custom', path: ['tracks', index, 'id'], message: '轨道标识不能重复' });
tracks.add(track.id);
for (const [i, stage] of track.stages.entries()) {
if (grades.has(stage.grade))
ctx.addIssue({
code: 'custom',
path: ['tracks', index, 'stages', i],
message: '每个年级只能出现一次',
});
grades.add(stage.grade);
if (Boolean(stage.terminal) !== (i === track.stages.length - 1))
ctx.addIssue({
code: 'custom',
path: ['tracks', index, 'stages', i],
message: '仅最后一个学段必须标记为毕业学段',
});
}
}
});
export const promotionConfigSchema = z
.object({
month: z.number().int().min(1).max(12),
day: z.number().int().min(1).max(31),
hour: z.number().int().min(0).max(23),
classCount: z.number().int().min(0).max(99).optional(),
plan: promotionPlanSchema.optional(),
})
.refine((config) => config.day <= new Date(2000, config.month, 0).getDate(), '该月份没有所选日期');
type Db = Prisma.TransactionClient;
async function loadConfig(db: Db = prisma) {
const settings = await db.appSetting.findMany({
where: { key: { in: [SETTING_KEYS.promotionAt, SETTING_KEYS.promotionPlan] } },
});
const raw = settings.find((setting) => setting.key === SETTING_KEYS.promotionAt)?.value;
const config = raw
? promotionConfigSchema.parse(JSON.parse(raw))
: { month: 8, day: 1, hour: 0, classCount: 1 };
const rawPlan = settings.find((setting) => setting.key === SETTING_KEYS.promotionPlan)?.value;
const plan = rawPlan
? promotionPlanSchema.parse(JSON.parse(rawPlan))
: defaultPromotionPlan(config.classCount ?? 1);
return {
config: {
month: config.month,
day: config.day,
hour: config.hour,
classCount: plan.tracks[0].entryClassCount,
},
plan,
};
}
export async function loadPromotionPlan(): Promise<PromotionPlan> {
return (await loadConfig()).plan;
}
function dueYear(config: { month: number; day: number; hour: number }, now: Date) {
const due = new Date(
now.getFullYear(),
config.month - 1,
Math.min(config.day, new Date(now.getFullYear(), config.month, 0).getDate()),
config.hour,
);
const year = now < due ? now.getFullYear() - 1 : now.getFullYear();
return now < due ? now.getFullYear() - 1 : now.getFullYear();
}
export async function promotionStatus(now = new Date()) {
const { config, plan } = await loadConfig();
const year = dueYear(config, now);
const run = await prisma.promotionRun.findUnique({ where: { year } });
return {
config,
plan,
year,
pending: !run || run.status !== 'done',
runs: await prisma.promotionRun.findMany({ orderBy: { year: 'desc' }, take: 20 }),
pending: run?.status !== 'done',
runs: await prisma.promotionRun.findMany({ orderBy: { year: 'desc' }, take: 30 }),
today: dayKeyLocal(now),
};
}
export async function configurePromotion(input: z.infer<typeof promotionConfigSchema>) {
await setSetting(SETTING_KEYS.promotionAt, JSON.stringify(input));
export async function configurePromotion(input: z.infer<typeof promotionConfigSchema>, actor?: TokenPayload) {
if (actor) await assertGlobalPermission(actor, 'promotion:manage');
const parsed = promotionConfigSchema.parse(input);
const current = await loadConfig();
const plan =
parsed.plan ??
(parsed.classCount === undefined
? current.plan
: {
...current.plan,
tracks: current.plan.tracks.map((track, i) =>
i ? track : { ...track, entryClassCount: parsed.classCount! },
),
});
await prisma.$transaction(async (tx) => {
for (const [key, value] of [
[
SETTING_KEYS.promotionAt,
JSON.stringify({
month: parsed.month,
day: parsed.day,
hour: parsed.hour,
classCount: plan.tracks[0].entryClassCount,
}),
],
[SETTING_KEYS.promotionPlan, JSON.stringify(plan)],
])
await tx.appSetting.upsert({ where: { key }, create: { key, value }, update: { value } });
});
return promotionStatus();
}
/** 一年一个事务幂等键;快照失败不得升级。启动遗漏只提示补跑,不擅自改演示库。 */
export async function runPromotion(year: number, dryRun = false) {
const existing = await prisma.promotionRun.findUnique({ where: { year } });
if (existing?.status === 'done') return existing;
const classes = await prisma.class.findMany({
where: { archivedYearId: null, grade: { in: ['高一', '高二', '高三'] } },
/** 只读预览与执行校验共用同一查询,哈希覆盖配置、学生、班级和周次。 */
async function buildPreview(year: number, db: Db = prisma) {
const { config, plan } = await loadConfig(db);
const [allClasses, students, weeks, archives] = await Promise.all([
db.class.findMany({ orderBy: { id: 'asc' } }),
db.student.findMany({
orderBy: { id: 'asc' },
select: { id: true, classId: true, status: true, archivedYearId: true, updatedAt: true },
}),
db.termWeek.findMany({ orderBy: { id: 'asc' } }),
db.archivedYear.findMany({ orderBy: { id: 'asc' } }),
]);
const classes = allClasses.filter((cls) => !cls.archivedYearId);
const blockers: { code: string; message: string; classId?: string }[] = [];
const warnings = ['新建班级只有骨架,没有班主任或任课关系,请随后分配。'];
const grades = new Set(plan.tracks.flatMap((track) => track.stages.map((stage) => stage.grade)));
for (const cls of classes) {
if (!grades.has(cls.grade))
blockers.push({
code: 'UNKNOWN_GRADE',
classId: cls.id,
message: `${cls.name} 的年级「${cls.grade}」不在升级计划内`,
});
if (!cls.enrollmentYear || !cls.classIndex)
blockers.push({ code: 'MISSING_COHORT', classId: cls.id, message: `${cls.name} 缺少入学年份或班号` });
if (
students.some(
(student) => student.classId === cls.id && (student.archivedYearId || student.status === 'graduated'),
)
)
blockers.push({
code: 'INCONSISTENT_STUDENT',
classId: cls.id,
message: `${cls.name} 含已归档或已毕业学生,请先处理数据一致性`,
});
}
const tracks = plan.tracks.map((track, ti) => {
const relevant = classes.filter((cls) => track.stages.some((stage) => stage.grade === cls.grade));
const seen = new Set<string>();
const affected = relevant.map((cls) => {
const key = `${cls.enrollmentYear}:${cls.classIndex}`;
if (seen.has(key))
blockers.push({
code: 'DUPLICATE_INDEX',
classId: cls.id,
message: `${track.name} 同届存在重复班号:${cls.name}`,
});
seen.add(key);
const index = track.stages.findIndex((stage) => stage.grade === cls.grade);
const terminal = Boolean(track.stages[index].terminal);
if (terminal && archives.some((archive) => archive.enrollmentYear === cls.enrollmentYear))
blockers.push({
code: 'ARCHIVE_EXISTS',
classId: cls.id,
message: `${cls.enrollmentYear} 届已存在档案`,
});
const members = students.filter((student) => student.classId === cls.id);
return {
id: cls.id,
name: cls.name,
grade: cls.grade,
enrollmentYear: cls.enrollmentYear,
classIndex: cls.classIndex,
nextGrade: terminal ? '毕业归档' : track.stages[index + 1].grade,
terminal,
active: members.filter((student) => student.status === 'active').length,
inactive: members.filter((student) => student.status === 'inactive').length,
transferred: members.filter((student) => student.status === 'transferred').length,
};
});
const newClasses = [];
for (let i = 1; i <= track.entryClassCount; i++) {
if (
relevant.some(
(cls) => cls.enrollmentYear === year && cls.classIndex === i && cls.grade === track.stages[0].grade,
)
)
continue;
const code =
ti === 0 ? `N${year}${String(i).padStart(2, '0')}` : `T${ti}${year}${String(i).padStart(2, '0')}`;
if (allClasses.some((cls) => cls.code === code))
blockers.push({ code: 'CODE_CONFLICT', message: `新班级码 ${code} 已被占用,请先修改冲突班级码` });
newClasses.push({
name: formatClassName(year, i),
grade: track.stages[0].grade,
code,
classIndex: i,
enrollmentYear: year,
});
}
return { id: track.id, name: track.name, classes: affected, newClasses };
});
const classIds = classes.map((c) => c.id);
const studentCount = await prisma.student.count({ where: { classId: { in: classIds }, status: 'active' } });
if (dryRun)
return { year, classCount: classes.length, studentCount, status: 'dry-run', snapshotRequired: true };
warnings.push(
plan.resetTermWeeks
? `将删除 ${weeks.length} 条全校及班级周次区间。`
: '保留原有周次区间,它们会继续覆盖新学年日期。',
);
const planHash = createHash('sha256')
.update(JSON.stringify({ year, config, plan, allClasses, students, weeks, archives }))
.digest('hex');
return {
year,
classCount: classes.length,
studentCount: students.filter(
(student) => student.status === 'active' && classes.some((cls) => cls.id === student.classId),
).length,
status: 'dry-run',
snapshotRequired: true,
plan,
tracks,
blockers,
warnings,
termWeekDeleteCount: plan.resetTermWeeks ? weeks.length : 0,
termStartDate: plan.termStartDate ?? `${year}-09-01`,
planHash,
};
}
export async function previewPromotion(year: number) {
return buildPreview(year);
}
function assertExecutable(preview: Awaited<ReturnType<typeof buildPreview>>, hash?: string) {
if (hash && hash !== preview.planHash)
throw new ApiError(409, 'CONFLICT', '预览已过期,配置或业务数据已变化,请重新预览', {
code: 'PLAN_CHANGED',
});
if (preview.blockers.length)
throw new ApiError(409, 'CONFLICT', '升级存在阻断项,请修正后重新预览', preview.blockers);
}
type RunOptions = {
dryRun?: boolean;
actor?: TokenPayload;
planHash?: string;
confirm?: boolean;
catchUp?: boolean;
};
const running = new Map<number, Promise<Awaited<ReturnType<typeof executePromotion>>>>();
export async function runPromotion(year: number, dryRunOrOptions: boolean | RunOptions = false) {
const options = typeof dryRunOrOptions === 'boolean' ? { dryRun: dryRunOrOptions } : dryRunOrOptions;
if (options.actor) {
await assertGlobalPermission(options.actor, 'promotion:manage');
const status = await promotionStatus();
if (year > status.year) throw ApiError.badRequest('不能执行尚未到期的未来学年');
if (year < status.year && !(options.actor.role === 'ADMIN' && options.catchUp))
throw ApiError.forbidden('过去学年仅管理员明确补跑');
if (!options.dryRun && (!options.confirm || !options.planHash))
throw ApiError.badRequest('执行必须确认学年并提供本次预览哈希');
}
if (options.dryRun) return buildPreview(year);
const prior = running.get(year);
if (prior) {
if (options.planHash && (await prisma.promotionRun.findUnique({ where: { year } }))?.status !== 'done')
assertExecutable(await buildPreview(year), options.planHash);
return prior;
}
const task = executePromotion(year, options);
running.set(year, task);
try {
return await task;
} finally {
running.delete(year);
}
}
async function executePromotion(year: number, options: RunOptions) {
const existing = await prisma.promotionRun.findUnique({ where: { year } });
if (existing?.status === 'done') return { ...existing, alreadyDone: true };
const preview = await buildPreview(year);
assertExecutable(preview, options.planHash);
const dir = path.resolve(
env.sqliteFilePath ? path.dirname(env.sqliteFilePath) : path.resolve(env.serverRoot, '..', 'data'),
'promotion-snapshots',
);
const snapshotPath = path.join(dir, `${year}-${Date.now()}-${randomUUID()}.json`);
let snapshotSha256 = '';
const actor = options.actor;
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();
try {
const content = JSON.stringify(await exportSnapshot(prisma, env.databaseProvider));
await fs.writeFile(snapshotPath, content, { flag: 'wx' });
snapshotSha256 = createHash('sha256').update(content).digest('hex');
const passwordHash = await hashPassword(env.defaultClassPassword);
const maxWeeks = await getMaxTermWeeks();
await prisma.$transaction(
async (tx) => {
// 唯一键在事务里争抢,重入/多进程都只能有一个提交成功。
if (existing) await tx.promotionRun.deleteMany({ where: { year, status: { not: 'done' } } });
const fresh = await buildPreview(year, tx);
assertExecutable(fresh, preview.planHash);
const current = await tx.promotionRun.findUnique({ where: { year } });
if (current?.status === 'done') return;
await tx.promotionRun.deleteMany({ where: { year, status: { not: 'done' } } });
await tx.promotionRun.create({
data: { year, status: 'running', snapshotPath, classCount: classes.length, studentCount },
data: {
year,
status: 'running',
snapshotPath,
snapshotSha256,
operatorId: actor?.sub,
operatorName: actor?.name ?? '学年自动升级',
detailsJson: JSON.stringify(fresh),
classCount: fresh.classCount,
studentCount: fresh.studentCount,
},
});
const graduating = classes.filter((c) => c.grade === '高三');
const cohorts = [...new Set(graduating.map((c) => c.enrollmentYear ?? year - 3))];
for (const enrollmentYear of cohorts) {
const ids = graduating
.filter((c) => (c.enrollmentYear ?? year - 3) === enrollmentYear)
.map((c) => c.id);
const archived = await tx.archivedYear.create({
const graduating = fresh.tracks.flatMap((track) => track.classes.filter((cls) => cls.terminal));
for (const enrollmentYear of new Set(graduating.map((cls) => cls.enrollmentYear!))) {
const ids = graduating.filter((cls) => cls.enrollmentYear === enrollmentYear).map((cls) => cls.id);
const counts = graduating.filter((cls) => ids.includes(cls.id));
const archive = await tx.archivedYear.create({
data: {
enrollmentYear,
graduationYear: year,
name: `${enrollmentYear} 级`,
operatorName: '学年自动升级',
operatorId: actor?.sub,
operatorName: actor?.name ?? '学年自动升级',
classCount: ids.length,
studentCount: await tx.student.count({ where: { classId: { in: ids }, status: 'active' } }),
studentCount: counts.reduce((n, cls) => n + cls.active, 0),
transferredCount: counts.reduce((n, cls) => n + cls.transferred, 0),
inactiveCount: counts.reduce((n, cls) => n + cls.inactive, 0),
homeworkCount: await tx.homework.count({ where: { classId: { in: ids } } }),
notificationCount: await tx.notification.count({ where: { classId: { in: ids } } }),
},
});
await tx.class.updateMany({
where: { id: { in: ids } },
data: { archivedYearId: archived.id, archivedAt: new Date() },
data: { archivedYearId: archive.id, archivedAt: new Date() },
});
await tx.student.updateMany({
where: { classId: { in: ids }, status: 'active' },
data: { status: 'graduated', archivedYearId: archived.id, archivedAt: new Date() },
data: { status: 'graduated' },
});
await tx.student.updateMany({
where: { classId: { in: ids } },
data: { archivedYearId: archive.id, archivedAt: new Date() },
});
}
for (const cls of classes.filter((c) => c.grade !== '高三'))
await tx.class.update({
where: { id: cls.id },
data: {
grade: cls.grade === '高一' ? '高二' : '高三',
termStartDate: null,
termWeeks: Math.min(cls.termWeeks, maxWeeks),
},
});
for (let i = 1; i <= config.classCount; i++) {
if (
await tx.class.findFirst({ where: { enrollmentYear: year, classIndex: i, archivedYearId: null } })
)
continue;
await tx.class.create({
data: {
name: formatClassName(year, i),
enrollmentYear: year,
classIndex: i,
grade: '高一',
teacherId: null,
code: `N${year}${String(i).padStart(2, '0')}`,
passwordHash,
termWeeks: Math.min(20, maxWeeks),
},
});
for (const track of fresh.tracks) {
for (const cls of track.classes.filter((cls) => !cls.terminal))
await tx.class.update({
where: { id: cls.id },
data: { grade: cls.nextGrade, termStartDate: null },
});
for (const cls of track.newClasses)
await tx.class.create({
data: {
...cls,
teacherId: null,
passwordHash,
termWeeks: Math.min(20, maxWeeks),
termStartDate: null,
},
});
}
await tx.termWeek.deleteMany();
if (fresh.plan.resetTermWeeks) await tx.termWeek.deleteMany();
await tx.appSetting.upsert({
where: { key: SETTING_KEYS.termStartDate },
create: { key: SETTING_KEYS.termStartDate, value: `${year}-09-01` },
update: { value: `${year}-09-01` },
create: { key: SETTING_KEYS.termStartDate, value: fresh.termStartDate },
update: { value: fresh.termStartDate },
});
await tx.promotionRun.update({
where: { year },
data: {
status: 'done',
message: '已升级、归档并建立无班主任/任课关系的新班骨架',
finishedAt: new Date(),
},
data: { status: 'done', message: '已按计划升级、归档并建立新班骨架', finishedAt: new Date() },
});
},
{ timeout: 30000 },
);
} catch (error) {
if ((await prisma.promotionRun.findUnique({ where: { year } }))?.status === 'done')
return prisma.promotionRun.findUniqueOrThrow({ where: { year } });
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 = {
const completed = await prisma.promotionRun.findUnique({ where: { year } });
if (completed?.status === 'done') return { ...completed, alreadyDone: true };
const failure = {
status: 'failed',
snapshotPath,
message: String(error),
classCount,
studentCount,
snapshotSha256,
operatorId: actor?.sub,
operatorName: actor?.name ?? '学年自动升级',
detailsJson: JSON.stringify(preview),
error: String(error),
message: '升级失败,业务事务未提交',
finishedAt: new Date(),
};
if (current) await tx.promotionRun.update({ where: { year }, data });
else await tx.promotionRun.create({ data: { year, ...data } });
});
await prisma.promotionRun.upsert({ where: { year }, create: { year, ...failure }, update: failure });
if (error instanceof ApiError) throw error;
throw ApiError.internal(`学年升级失败,事务已回滚:${String(error)}`);
}
for (const track of preview.tracks)
for (const cls of track.classes)
emitToClass(cls.id, SOCKET_EVENTS.classUpdated, { classId: cls.id, action: 'updated' });
await notifyScheduleChanged();
return { ...(await prisma.promotionRun.findUniqueOrThrow({ where: { year } })), alreadyDone: false };
}
let timer: ReturnType<typeof setInterval> | null = null;
export async function startPromotionScheduler() {
if (timer) return;
let observed = new Date();
if ((await promotionStatus(observed)).pending)
logger.warn('有遗漏的学年升级,请管理员在学期周次页查看预览与快照说明后补跑');
if ((await promotionStatus(observed)).pending) logger.warn('有遗漏的学年升级,请在学年升级页预览后补跑');
timer = setInterval(() => {
void (async () => {
const now = new Date();
const { config } = await promotionStatus(now);
const due = new Date(
now.getFullYear(),
config.month - 1,
Math.min(config.day, new Date(now.getFullYear(), config.month, 0).getDate()),
config.hour,
);
if (observed < due && now >= due) await runPromotion(now.getFullYear());
const { config } = await loadConfig();
if (dueYear(config, now) > dueYear(config, observed)) await runPromotion(now.getFullYear());
observed = now;
})().catch((e) => logger.error(`自动升级失败:${String(e)}`));
}, 30_000);
})().catch((error) => logger.error(`自动升级失败:${String(error)}`));
}, 30000);
timer.unref();
}
@@ -1,12 +1,14 @@
import { assertGlobalPermission } from '../../lib/access.js';
import { z } from 'zod';
import {
promotionStatus,
previewPromotion,
promotionConfigSchema,
configurePromotion,
runPromotion,
} from './promotion.service.js';
import { Router } from 'express';
import { sendOk } from '../../lib/http.js';
import { ApiError, sendOk } from '../../lib/http.js';
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
import { validate, validatedBody, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
@@ -22,22 +24,47 @@ import * as termService from './term.service.js';
const router = Router();
router.use(authenticate());
router.get('/promotion', requirePermission('system:manage'), async (_req, res) =>
router.use('/promotion', async (req, _res, next) => {
await assertGlobalPermission(getAuthUser(req), 'promotion:manage');
next();
});
router.get('/promotion', requirePermission('promotion:manage'), async (_req, res) =>
sendOk(res, await promotionStatus()),
);
router.put(
'/promotion',
requirePermission('system:manage'),
requirePermission('promotion:manage'),
validate({ body: promotionConfigSchema }),
async (req, res) => sendOk(res, await configurePromotion(req.body)),
async (req, res) => sendOk(res, await configurePromotion(req.body, getAuthUser(req))),
);
router.post(
'/promotion/preview',
requirePermission('promotion:manage'),
validate({ body: z.object({ year: z.number().int().min(2000).max(2100) }) }),
async (req, res) => sendOk(res, await previewPromotion(req.body.year)),
);
router.post(
'/promotion/run',
requirePermission('system:manage'),
requirePermission('promotion:manage'),
(req, _res, next) => {
if (req.body && 'dryRun' in req.body)
return next(
ApiError.badRequest('请更新页面:预览使用 /term/promotion/preview,执行需提供计划哈希并确认学年'),
);
next();
},
validate({
body: z.object({ year: z.number().int().min(2000).max(2100), dryRun: z.boolean().default(true) }),
body: z
.object({
year: z.number().int().min(2000).max(2100),
planHash: z.string().length(64),
confirm: z.literal(true),
catchUp: z.boolean().optional(),
})
.strict(),
}),
async (req, res) => sendOk(res, await runPromotion(req.body.year, req.body.dryRun)),
async (req, res) =>
sendOk(res, await runPromotion(req.body.year, { ...req.body, actor: getAuthUser(req) })),
);
/**
@@ -4,7 +4,8 @@ import {
type HolidaySuggestionDto,
type TermWeeksDto,
} from '@classhelper/shared';
import { assertCanManageClasses, assertClassAccess } from '../../lib/access.js';
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';
@@ -17,7 +18,7 @@ import { SOCKET_EVENTS } from '@classhelper/shared';
import type { AutoTermWeeksInput, UpdateTermWeeksInput } from './term.schemas.js';
/**
* 学期周次(仅管理员可写)。
* 学期周次(具有 term:write 的员工可写)。
*
* 为什么需要逐周配置:`TERM_START_DATE + 每周七天`的线性推算遇到法定节假日调休、
* 周末补课、错峰开学就会**整体错位**——而课表、作业、成绩都按周次组织,错一周就全错。
@@ -65,7 +66,7 @@ export async function getTermWeeks(user: TokenPayload, classId?: string | null):
}
/**
* 保存逐周配置(整表覆盖)。仅管理员。
* 保存逐周配置(整表覆盖)。需要 term:write。
*
* 校验:日期格式、起止顺序、**区间不重叠**(重叠会让"当前第几周"出现歧义)。
* 保存后把新的周次口径下发给该班所有在线设备。
@@ -74,7 +75,7 @@ export async function updateTermWeeks(
user: TokenPayload,
input: UpdateTermWeeksInput,
): Promise<TermWeeksDto> {
assertCanManageClasses(user);
await assertPermission(user, 'term:write');
const scope = scopeOf(input.classId);
const maximum = input.maxTermWeeks ?? (await getMaxTermWeeks());
@@ -135,13 +136,13 @@ export async function updateTermWeeks(
}
/**
* 按学期开始日期**一键生成**逐周区间(管理员再逐周微调)。仅管理员。
* 按学期开始日期**一键生成**逐周区间(管理员再逐周微调)。需要 term:write。
*
* 这是配置的起点:先生成一份"标准学期",再把调休那几周改掉,
* 比从空白开始一周周填要快得多。
*/
export async function autoTermWeeks(user: TokenPayload, input: AutoTermWeeksInput): Promise<TermWeeksDto> {
assertCanManageClasses(user);
await assertPermission(user, 'term:write');
const scope = scopeOf(input.classId);
const context = await loadTermContext(scope || null);
const termStartDate = input.termStartDate || context.termStartDate || env.termStartDate;