feat(authz): 重写权限模板分配与班级授权边界

拆分能力、分配范围和业务关系,固定管理员能力并收紧组织级写入口。
补齐内容、课表、设备会话和实时通道授权,兼容旧库分配及历史快照。
重写权限分配预览,按实际可操作范围展示维护入口,合并学生入口并独立学年升级页。
增加授权攻击矩阵及静态门禁,更新隔离回归与开发契约。

验证:授权矩阵 23 段通过,Node 24 下 Web 63/63,类型检查、lint 与文档严格构建通过。
This commit is contained in:
LoveChengke committed 2026-10-31 09:40:23 +08:00
1 parent 682818dd4a
commit 66be3a63b1
115 files changed
+4463 -2747

No files matched your search

+74 -324
View File
@@ -1,379 +1,129 @@
import type { PermissionKey as ContractPermissionKey } from '@classhelper/shared';
import {
canManageGrades,
canManageRoster,
canManageSchedule,
canManageSubjectContent,
canPublishNotification,
resolveClassRole,
type ClassRole,
} from '@classhelper/shared';
import { teacherScopeWhere } from './permission-scope.js';
import type { Prisma } from '../generated/prisma/client.js';
import type { PermissionKey, ClassRole } from '@classhelper/shared';
import { resolveClassRole } from '@classhelper/shared';
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';
/**
* 权限(RBAC)规则。
*
* | 角色 | 范围 | 能做什么 |
* | ---- | ---- | -------- |
* | ADMIN | 全局 | 班级/教师/学生名单/调班/归档/全部科目的作业与成绩 |
* | HEAD 班主任 | 本班 | 本班学生名单、课表、本班汇总;**作业与成绩仍按科目收口** |
* | SUBJECT 科任 | 班级 + 科目 | 只能发布/修改/删除**自己任教科目**的作业与成绩 |
* | CLASS_DEVICE | 绑定班级 | ClassHelper 班级端:代本班读写个人数据、按开关查成绩明细 |
* | 学生 | 无账号 | 仅作为 `Student` 名单记录,不登录、无权限 |
*
* 三条铁律(都是这次重构的落点):
* 1. **任课关系的唯一来源是 `Course`**(班级 + 科目 + 教师),没有第二张关系表;
* 2. **班主任不自动拥有本班所有科目的作业成绩编辑权** —— 只有同时是该科科任老师时才有;
* 3. **所有权限都在后端判**,前端只做展示控制。
*
* 判定逻辑与前端共用 `@classhelper/shared/permissions`,避免前后端口径漂移。
*/
export function isAdmin(user: TokenPayload): boolean {
return user.role === 'ADMIN';
}
export function isTeacher(user: TokenPayload): boolean {
return user.role === 'TEACHER';
}
/** 教师与管理员(staff):能以自己的身份操作班级内容 */
export function isStaff(user: TokenPayload): boolean {
return user.role === 'ADMIN' || user.role === 'TEACHER';
}
/**
* 是否是 ClassHelper 班级端(教室机器上那台绑定到某个班的客户端)。
* 它代全班读写个人数据(作业完成、通知已读),也能按开关查本班学生成绩明细。
*/
export function isClassDevice(user: TokenPayload): boolean {
return user.role === 'CLASS_DEVICE';
}
/** 班级端代录内容归属班主任账号,班级主体不能写入 User 外键。 */
import {
assertAdmin,
assertPermission,
assertClassRead,
assertManage,
classIdsFor,
classWhere,
classDeviceClassId,
isHead,
hasOrganizationalGrant,
} from './authz/index.js';
export { classDeviceClassId } from './authz/relations.js';
export const isAdmin = (user: TokenPayload) => user.role === 'ADMIN';
export const isTeacher = (user: TokenPayload) => user.role === 'TEACHER';
export const isStaff = (user: TokenPayload) => user.role === 'ADMIN' || user.role === 'TEACHER';
export const isClassDevice = (user: TokenPayload) => user.role === 'CLASS_DEVICE';
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('请先为班级配置班主任,再在班级端发布内容');
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;
export function requireClassDeviceClassId(user: TokenPayload): string {
const id = classDeviceClassId(user);
if (!id) throw ApiError.forbidden('当前会话未绑定班级');
return id;
}
/**
* 班级访问的判定条件:班主任,**或在该班有任课关系**(`Course.teacherId`)。
*
* 这里就是「科任老师」的判定源 —— 以前查的是没有科目的 `ClassTeacher` 协作表,
* 现在查 `Course`,于是"能不能进这个班"与"能不能改这一科的作业成绩"用的是同一份事实。
*/
/** 当前用户可访问的全部班级 id */
export async function getAccessibleClassIds(
user: TokenPayload,
permission?: ContractPermissionKey,
): Promise<string[]> {
if (isAdmin(user)) {
const classes = await prisma.class.findMany({ select: { id: true } });
return classes.map((item) => item.id);
}
if (isTeacher(user)) {
const classes = await prisma.class.findMany({
where: await teacherScopeWhere(user, permission),
select: { id: true },
});
return classes.map((item) => item.id);
}
const classId = classDeviceClassId(user);
return classId ? [classId] : [];
}
export const getAccessibleClassIds = classIdsFor;
export async function canAccessClass(
user: TokenPayload,
classId: string,
permission?: ContractPermissionKey,
key: PermissionKey,
): Promise<boolean> {
if (isAdmin(user)) return true;
if (isTeacher(user)) {
const count = await prisma.class.count({
where: { AND: [{ id: classId }, await teacherScopeWhere(user, permission)] },
});
return count > 0;
}
return classDeviceClassId(user) === classId;
if (isClassDevice(user)) return classDeviceClassId(user) === classId;
return !!(await prisma.class.count({ where: { AND: [{ id: classId }, await classWhere(user, key)] } }));
}
/** 读权限断言 */
/** 兼容窄封装,键必须由调用点显式提供。 */
export async function assertClassAccess(
user: TokenPayload,
classId: string,
permission?: ContractPermissionKey,
key: PermissionKey,
): Promise<void> {
const exists = await prisma.class.findUnique({ where: { id: classId }, select: { id: true } });
if (!exists) throw ApiError.notFound('班级不存在');
if (!(await canAccessClass(user, classId, permission))) throw ApiError.forbidden('无权访问该班级的数据');
await assertClassRead(user, key, classId, { allowClassDevice: true });
}
/** 写权限断言:无账号的学生不在会话里,因此这里只放行 staff 与 ClassHelper 班级端 */
export async function assertClassWritable(user: TokenPayload, classId: string): Promise<void> {
if (!isStaff(user) && !isClassDevice(user)) throw ApiError.forbidden('当前身份没有管理权限');
await assertClassAccess(user, classId);
}
/* ---------------------------------------------------------------- 班级内角色 */
/** 解析当前用户在某班级里的角色(ADMIN / HEAD / SUBJECT / CLASS_DEVICE / NONE) */
export async function resolveUserClassRole(user: TokenPayload, classId: string): Promise<ClassRole> {
if (user.role === 'ADMIN') return 'ADMIN';
if (user.role === 'CLASS_DEVICE') return (await canAccessClass(user, classId)) ? 'CLASS_DEVICE' : 'NONE';
const record = await prisma.class.findUnique({
where: { id: classId },
select: {
teacherId: true,
courses: { where: { teacherId: user.sub }, select: { id: true } },
},
});
if (!record) return 'NONE';
if (isAdmin(user)) return 'ADMIN';
if (isClassDevice(user)) return classDeviceClassId(user) === classId ? 'CLASS_DEVICE' : 'NONE';
return resolveClassRole({
role: user.role,
isHeadTeacher: record.teacherId === user.sub,
teachesSubjects: record.courses.length > 0,
isHeadTeacher: await isHead(user, classId),
teachesSubjects: !!(await prisma.course.count({ where: { classId, teacherId: user.sub } })),
});
}
/** 班级本身(增删改)与人员分配:仅管理员 */
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 assertCanManageClasses(user: TokenPayload, classId?: string) {
assertAdmin(user);
if (classId && (await prisma.class.findUnique({ where: { id: classId } }))?.archivedYearId)
throw ApiError.conflict('该班级已归档');
}
export async function assertGlobalPermission(user: TokenPayload, key: ContractPermissionKey) {
export const assertCanAssignTeachers = assertCanManageClasses;
export async function assertGlobalPermission(user: TokenPayload, key: PermissionKey) {
assertAdmin(user);
await assertPermission(user, key);
if (!(await hasAllScope(user, key))) 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: PermissionKey) {
await assertClassRead(user, key, 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) {
export async function assertClassesInScope(user: TokenPayload, ids: string[], key: PermissionKey) {
for (const id of new Set(ids)) await assertClassInScope(user, id, key);
}
/**
* 学生名单(增删学生 / 导入名单 / 调班):管理员,或**本班班主任**。
*
* @param classId 目标班级;不传表示"全局名单操作"(例如未分班的学生),此时仅管理员
*/
export async function assertCanManageRoster(user: TokenPayload, classId?: string): Promise<void> {
await assertPermission(user, 'roster:manage');
export async function assertCanManageRoster(
user: TokenPayload,
classId?: string,
db: Prisma.TransactionClient = prisma,
) {
if (!classId) {
if (!(await hasAllScope(user, 'roster:manage')))
throw ApiError.forbidden('未分班学生操作需要全校名单范围');
await assertPermission(user, 'roster:manage');
if (!(await hasOrganizationalGrant(user, 'roster:manage')))
throw ApiError.forbidden('未分班学生需要全校名单范围');
return;
}
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(
classRole === 'SUBJECT'
? '科任老师不能管理学生名单,请联系班主任或管理员'
: '只能管理自己担任班主任的班级的学生名单',
);
}
await assertManage(user, 'roster:manage', classId, db);
}
/** 课表管理(含时间配置导入):管理员或本班班主任 */
export async function assertCanManageSchedule(user: TokenPayload, classId: string): Promise<void> {
await assertClassAccess(user, classId);
const classRole = await resolveUserClassRole(user, classId);
if (!canManageSchedule(classRole)) {
throw ApiError.forbidden(
classRole === 'SUBJECT' || classRole === 'CLASS_DEVICE'
? '科任老师与班级端不能管理课表,请联系班主任或管理员'
: '无权管理该班级的课表',
);
}
}
/** 导入需同时覆盖导入能力和对应写能力,范围不能从另一项能力借用。 */
export async function assertCanImportSchedule(
export async function assertCanManageSchedule(
user: TokenPayload,
classId: string,
writeKey: ContractPermissionKey = 'schedule:write',
): Promise<void> {
await assertClassInScope(user, classId, 'import:schedule');
await assertClassInScope(user, classId, writeKey);
db: Prisma.TransactionClient = prisma,
) {
await assertManage(user, 'schedule:write', classId, db);
}
export async function assertCanImportSchedule(user: TokenPayload, classId: string) {
await assertManage(user, 'import:schedule', classId);
await assertCanManageSchedule(user, classId);
}
/** 通知 / 叫人:管理员、班主任、科任老师、ClassHelper 班级端(面向全班,不分科目) */
export async function assertCanPublishNotification(user: TokenPayload, classId: string): Promise<void> {
await assertClassAccess(user, classId);
const classRole = await resolveUserClassRole(user, classId);
if (!canPublishNotification(classRole)) {
throw ApiError.forbidden('当前账号没有发布通知或叫人的权限');
}
}
/**
* 成绩录入 / 修改 / 导入的**班级维度**准入:管理员,或本班班主任。
*
* @param classId 目标成绩所属班级;不传时按"全局"判定(仅管理员可用,例如空白模板下载)
*
* 这只是第一道门:具体某一条成绩能不能动,还要过 `assertCanManageSubjectContent` 的**科目**那道门。
*/
export async function assertCanManageGrades(user: TokenPayload, classId?: string): Promise<void> {
if (!classId) {
if (!isAdmin(user)) throw ApiError.forbidden('该操作需要指定班级,只有管理员可以全局执行');
return;
}
await assertClassAccess(user, classId);
const classRole = await resolveUserClassRole(user, classId);
if (canManageGrades(classRole)) return;
throw ApiError.forbidden(
classRole === 'SUBJECT' || classRole === 'CLASS_DEVICE'
? '科任老师与班级端不能代录全班成绩,请在自己的科目里录入'
: '只能录入自己担任班主任的班级的成绩',
);
}
/**
* **科目维度的写权限**(作业与成绩共用的那道细门)—— 本次重构的核心规则。
*
* | 情形 | 谁可以写 |
* | ---- | -------- |
* | 管理员 | 全部 |
* | ClassHelper 班级端 | 仅当 `allowClassDevice`(作业允许、成绩不允许) |
* | 挂了科目(courseId 有值) | **该科的任课老师**(`Course.teacherId`) |
* | 没挂科目(courseId 为空) | 本班**班主任** |
*
* 于是:班主任若不教这一科,就改不了这一科的作业与成绩 —— 这正是需求
* 「班主任不自动拥有所有科目作业成绩编辑权,只有同时是该科科任老师时才有该科权限」。
*
* @param courseId 目标内容挂在哪门课(科目)上;null/undefined 表示"不指定科目"
* @param action 给用户看的动作名,例如「布置作业」「录入成绩」
* @param options.allowClassDevice 是否允许教室机器代写(作业为 true,成绩为 false)
*/
export async function assertCanManageSubjectContent(
user: TokenPayload,
classId: string,
courseId: string | null | undefined,
action: string,
options: { allowClassDevice?: boolean } = {},
): Promise<void> {
await assertClassAccess(user, classId);
const classRole = await resolveUserClassRole(user, classId);
const allowClassDevice = options.allowClassDevice === true;
// 管理员不受科目限制,提前返回省掉一次课程查询
if (classRole === 'ADMIN') return;
if (classRole === 'CLASS_DEVICE') {
if (allowClassDevice) return;
throw ApiError.forbidden(`ClassHelper 班级端不能${action},请由任课老师在 Web 端操作`);
}
let isSubjectTeacherOfCourse = false;
let courseName = '';
if (courseId) {
const course = await prisma.course.findUnique({
where: { id: courseId },
select: { classId: true, teacherId: true, name: true },
});
if (!course) throw ApiError.badRequest('课程不存在');
if (course.classId !== classId) throw ApiError.badRequest('该课程不属于所选班级');
courseName = course.name;
isSubjectTeacherOfCourse = course.teacherId === user.sub;
}
const allowed = canManageSubjectContent({
classRole,
hasSubject: Boolean(courseId),
isSubjectTeacherOfCourse,
allowClassDevice,
});
if (allowed) return;
throw ApiError.forbidden(
courseId
? `「${courseName}」不是你任教的科目,不能${action}该科目的内容`
: `未指定科目的内容只有班主任或管理员可以${action}`,
);
}
/** 学生的主班级(ClassHelper 班级端会话);未绑定班级时抛 403 */
export function requireClassDeviceClassId(user: TokenPayload): string {
const classId = classDeviceClassId(user);
if (!classId) throw ApiError.forbidden('当前会话未绑定班级,请联系管理员');
return classId;
}
/**
* 查询范围解析:
* - 传入 classId:校验后只查该班级
* - 未传:教师只能查自己可访问的班级;管理员不加限制
*/
export type ClassScope = { mode: 'all' } | { mode: 'in'; classIds: string[] };
export async function resolveClassScope(
user: TokenPayload,
requestedClassId?: string,
permission?: ContractPermissionKey,
requestedClassId: string | undefined,
key: PermissionKey,
): Promise<ClassScope> {
if (requestedClassId) {
await assertClassAccess(user, requestedClassId, permission);
await assertClassAccess(user, requestedClassId, key);
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);
// 没有任何班级时不抛错,返回空集合让列表接口给出空数组,避免新教师首次登录报错
return { mode: 'in', classIds };
if (isClassDevice(user)) return { mode: 'in', classIds: [requireClassDeviceClassId(user)] };
await assertPermission(user, key);
if (Object.keys(await classWhere(user, key)).length === 0) return { mode: 'all' };
// 其余范围显式枚举班级,避免空集合被下游当成全量。
return { mode: 'in', classIds: await classIdsFor(user, key) };
}
/** 把 ClassScope 转换成 Prisma 的 where 片段(子表:classId 字段) */
export function classScopeWhere(scope: ClassScope): { classId?: { in: string[] } } {
return scope.mode === 'all' ? {} : { classId: { in: scope.classIds } };
}
/** Class 表自身的范围过滤:注意这里过滤的是主键 id,而不是子表的 classId */
export function classScopeIdFilter(scope: ClassScope): { id?: { in: string[] } } {
return scope.mode === 'all' ? {} : { id: { in: scope.classIds } };
}
/** 单班级场景:班级端强制取自己绑定的班级,其他角色沿用请求参数 */
export function resolveEffectiveClassId(user: TokenPayload, requestedClassId?: string): string | undefined {
if (isClassDevice(user)) return requireClassDeviceClassId(user);
if (isClassDevice(user)) {
const id = requireClassDeviceClassId(user);
if (requestedClassId && requestedClassId !== id) throw ApiError.forbidden('不能访问其他班级');
return id;
}
return requestedClassId;
}
+104
View File
@@ -0,0 +1,104 @@
import type { Prisma } from '../../generated/prisma/client.js';
import { PERMISSION_LABELS, type PermissionKey } from '@classhelper/shared';
import { prisma } from '../db.js';
import { ApiError } from '../http.js';
import type { TokenPayload } from '../jwt.js';
import { loadAuthPrincipal } from './principal.js';
import { assertClassInScope, hasOrganizationalGrant } from './scope.js';
import { classDeviceClassId, isHead, isTeaching } from './relations.js';
export function assertAdmin(user: TokenPayload): void {
if (user.role !== 'ADMIN' || user.classSession) throw ApiError.forbidden('此操作仅限管理员');
}
export async function assertPermission(user: TokenPayload, key: PermissionKey): Promise<void> {
if (!(await loadAuthPrincipal(user)).permissions.has(key))
throw ApiError.forbidden(`缺少「${PERMISSION_LABELS[key]}」权限`);
}
export async function assertAnyPermission(user: TokenPayload, keys: PermissionKey[]): Promise<void> {
const auth = await loadAuthPrincipal(user);
if (!keys.some((key) => auth.permissions.has(key))) throw ApiError.forbidden('缺少此操作所需权限');
}
export function assertClassDevice(user: TokenPayload, classId: string): void {
if (classDeviceClassId(user) !== classId) throw ApiError.forbidden('仅限绑定本班的班级端会话');
}
export async function assertPermissionOrClassDevice(user: TokenPayload, key: PermissionKey): Promise<void> {
if (user.role !== 'CLASS_DEVICE') await assertPermission(user, key);
}
export async function assertClassRead(
user: TokenPayload,
key: PermissionKey,
classId: string,
options: { allowClassDevice?: boolean; db?: Prisma.TransactionClient } = {},
): Promise<void> {
if (user.role === 'CLASS_DEVICE' && options.allowClassDevice) {
assertClassDevice(user, classId);
return;
}
await assertPermission(user, key);
await assertClassInScope(user, key, classId, options.db);
}
export async function assertHeadOnly(
user: TokenPayload,
key: PermissionKey,
classId: string,
db: Prisma.TransactionClient = prisma,
): Promise<void> {
await assertClassRead(user, key, classId, { db });
if (user.role !== 'ADMIN' && !(await isHead(user, classId, db)))
throw ApiError.forbidden('此操作仅限本班班主任或管理员');
}
export async function assertManage(
user: TokenPayload,
key: PermissionKey,
classId: string,
db: Prisma.TransactionClient = prisma,
): Promise<void> {
await assertClassRead(user, key, classId, { db });
if (
user.role !== 'ADMIN' &&
!(await isHead(user, classId, db)) &&
!(await hasOrganizationalGrant(user, key, classId, db))
)
throw ApiError.forbidden('此操作需要班主任关系或该功能的年级、部、全校范围');
}
export async function assertContentWrite(
user: TokenPayload,
key: PermissionKey,
classId: string,
courseId: string | null | undefined,
action: string,
options: { allowClassDevice?: boolean; db?: Prisma.TransactionClient } = {},
): Promise<void> {
await assertClassRead(user, key, classId, options);
if (courseId) {
const course = await (options.db ?? prisma).course.findUnique({ where: { id: courseId } });
if (!course || course.classId !== classId) throw ApiError.badRequest('课程不属于目标班级');
if (
user.role === 'ADMIN' ||
(user.role === 'CLASS_DEVICE' && options.allowClassDevice) ||
course.teacherId === user.sub
)
return;
} else if (
user.role === 'ADMIN' ||
(user.role === 'CLASS_DEVICE' && options.allowClassDevice) ||
(await isHead(user, classId, options.db))
)
return;
throw ApiError.forbidden(`只能${action}本人任教科目或班主任班未指定科目的内容`);
}
export async function assertPublish(
user: TokenPayload,
key: PermissionKey,
classId: string,
options: { allowClassDevice?: boolean; db?: Prisma.TransactionClient } = {},
): Promise<void> {
await assertClassRead(user, key, classId, options);
if (
user.role === 'ADMIN' ||
(user.role === 'CLASS_DEVICE' && options.allowClassDevice) ||
(await isTeaching(user, classId, options.db))
)
return;
throw ApiError.forbidden('只有班主任或本班任课老师可以发布');
}
+4
View File
@@ -0,0 +1,4 @@
export * from './principal.js';
export * from './scope.js';
export * from './relations.js';
export * from './assert.js';
@@ -0,0 +1,99 @@
import {
PERMISSION_KEYS,
PERMISSION_META,
parsePermissionScope,
type PermissionKey,
type PermissionScope,
} from '@classhelper/shared';
import { prisma } from '../db.js';
import type { TokenPayload } from '../jwt.js';
import { ensureDefaultGroups } from '../permissions.js';
export interface Grant {
scope: PermissionScope;
scopeValue: string;
groupId: string;
}
export interface AuthPrincipal {
permissions: ReadonlySet<PermissionKey>;
grants: ReadonlyMap<PermissionKey, readonly Grant[]>;
fromGroups: boolean;
}
export interface AssignmentRow {
groupId: string;
scope: string;
scopeValue: string;
group: { defaultRole: string | null; items: { key: string }[] };
}
let requests = new WeakMap<TokenPayload, Promise<AuthPrincipal>>();
export function invalidateAuthPrincipals() {
requests = new WeakMap();
}
/** 缓存仅附着于一个请求的主体;没有可变的“当前权限键”。 */
export function loadAuthPrincipal(user: TokenPayload): Promise<AuthPrincipal> {
let principal = requests.get(user);
if (!principal) {
principal = load(user);
requests.set(user, principal);
}
return principal;
}
async function load(user: TokenPayload): Promise<AuthPrincipal> {
if (user.role !== 'TEACHER') return principalForAssignments(user, []);
await ensureDefaultGroups();
const assignments = await prisma.userPermissionGroup.findMany({
where: { userId: user.sub },
include: { group: { include: { items: true } } },
});
return principalForAssignments(user, assignments);
}
/** 分配预览与运行时共用求值,不写数据库。 */
export async function principalForAssignments(
user: TokenPayload,
assignments: AssignmentRow[],
defaultGroup?: AssignmentRow['group'] & { id: string },
): Promise<AuthPrincipal> {
const permissions = new Set<PermissionKey>();
const grants = new Map<PermissionKey, Grant[]>();
if (user.role === 'CLASS_DEVICE' || user.classSession) return { permissions, grants, fromGroups: false };
if (user.role === 'ADMIN') {
for (const key of PERMISSION_KEYS) {
permissions.add(key);
grants.set(key, [{ scope: 'ALL', scopeValue: '', groupId: '' }]);
}
return { permissions, grants, fromGroups: false };
}
const fromGroups = assignments.length > 0;
if (!fromGroups) {
const group =
defaultGroup ??
(await prisma.permissionGroup.findUnique({
where: { defaultRole: 'TEACHER' },
include: { items: true },
}));
assignments = group ? [{ groupId: group.id, scope: 'TEACHING', scopeValue: '', group }] : [];
}
for (const assignment of assignments) {
if (assignment.group.defaultRole === 'ADMIN') continue;
for (const { key: raw } of assignment.group.items) {
if (!(PERMISSION_KEYS as readonly string[]).includes(raw)) continue;
const key = raw as PermissionKey;
if (PERMISSION_META[key].kind === 'admin') continue;
permissions.add(key);
try {
const parsed = parsePermissionScope(assignment.scope as PermissionScope, assignment.scopeValue);
grants.set(key, [
...(grants.get(key) ?? []),
{ scope: parsed.scope, scopeValue: parsed.scopeValue, groupId: assignment.groupId },
]);
} catch {
/* 非法旧分配不授予班级。 */
}
}
}
return { permissions, grants, fromGroups };
}
@@ -0,0 +1,24 @@
import type { Prisma } from '../../generated/prisma/client.js';
import { prisma } from '../db.js';
import type { TokenPayload } from '../jwt.js';
export function classDeviceClassId(user: TokenPayload): string | null {
return user.role === 'CLASS_DEVICE' && user.classSession ? (user.classId ?? user.sub) : null;
}
export async function isHead(
user: TokenPayload,
classId: string,
db: Prisma.TransactionClient = prisma,
): Promise<boolean> {
return !!(await db.class.count({ where: { id: classId, teacherId: user.sub } }));
}
export async function isTeaching(
user: TokenPayload,
classId: string,
db: Prisma.TransactionClient = prisma,
): Promise<boolean> {
return (
(await isHead(user, classId, db)) ||
!!(await db.course.count({ where: { classId, teacherId: user.sub } }))
);
}
+124
View File
@@ -0,0 +1,124 @@
import { PERMISSION_META, parsePermissionScope, type PermissionKey } from '@classhelper/shared';
import type { Prisma } from '../../generated/prisma/client.js';
import { prisma } from '../db.js';
import { ApiError } from '../http.js';
import type { TokenPayload } from '../jwt.js';
import { loadAuthPrincipal, type AuthPrincipal } from './principal.js';
/** 返回 Class 表过滤条件;无键、无授权、组织只读键均不产生班级集合。 */
export async function classWhere(
user: TokenPayload,
key: PermissionKey,
principal?: AuthPrincipal,
): Promise<Prisma.ClassWhereInput> {
if (!key) return { id: { in: [] } };
const auth = principal ?? (await loadAuthPrincipal(user));
if (!auth.permissions.has(key)) return { id: { in: [] } };
if (user.role === 'ADMIN') return {};
if (PERMISSION_META[key].kind !== 'class') return { id: { in: [] } };
const alternatives: Prisma.ClassWhereInput[] = [];
for (const grant of auth.grants.get(key) ?? []) {
if (grant.scope === 'ALL') return PERMISSION_META[key].relation === 'head' ? { teacherId: user.sub } : {};
if (grant.scope === 'HEAD') alternatives.push({ teacherId: user.sub });
else if (grant.scope === 'TEACHING')
alternatives.push({ OR: [{ teacherId: user.sub }, { courses: { some: { teacherId: user.sub } } }] });
else alternatives.push({ grade: { in: parsePermissionScope(grant.scope, grant.scopeValue).grades } });
}
// 班主任专属键仍须满足分配范围与关系的交集。
if (PERMISSION_META[key].relation === 'head') {
return { AND: [{ OR: alternatives }, { teacherId: user.sub }] };
}
return { OR: alternatives };
}
export async function classIdsFor(
user: TokenPayload,
key: PermissionKey,
principal?: AuthPrincipal,
db: Prisma.TransactionClient = prisma,
): Promise<string[]> {
const rows = await db.class.findMany({
where: await classWhere(user, key, principal),
select: { id: true },
});
return rows.map((row) => row.id);
}
/** 名单、日课表、导入及考试配置的班级管理范围,供界面准确显示操作入口。 */
export async function manageClassIdsFor(
user: TokenPayload,
key: PermissionKey,
principal: AuthPrincipal,
): Promise<string[]> {
if (user.role === 'ADMIN') return classIdsFor(user, key, principal);
const grants = (principal.grants.get(key) ?? []).filter((g) =>
['GRADE', 'DIVISION', 'ALL'].includes(g.scope),
);
const broad = await classWhere(user, key, { ...principal, grants: new Map([[key, grants]]) });
const rows = await prisma.class.findMany({
where: { AND: [await classWhere(user, key, principal), { OR: [{ teacherId: user.sub }, broad] }] },
select: { id: true },
});
return rows.map((row) => row.id);
}
/** 每项输入都携带权限键;关系一次读取,输出仍逐键与各自授权范围求交。 */
export async function actionScopesFor(
user: TokenPayload,
scopes: ReadonlyArray<readonly [PermissionKey, readonly string[]]>,
management: ReadonlyArray<readonly [PermissionKey, readonly string[]]>,
): Promise<Partial<Record<PermissionKey, readonly string[]>>> {
const result = { ...Object.fromEntries(scopes), ...Object.fromEntries(management) };
const related = scopes.filter(([key]) =>
['subject', 'publish', 'creator'].includes(PERMISSION_META[key].relation),
);
if (user.role === 'ADMIN' || !related.length) return result;
const rows = await prisma.class.findMany({
where: { id: { in: [...new Set(related.flatMap(([, ids]) => [...ids]))] } },
select: {
id: true,
teacherId: true,
courses: { where: { teacherId: user.sub }, select: { id: true }, take: 1 },
notifications: { where: { createdBy: user.sub }, select: { id: true }, take: 1 },
},
});
const relations = new Map(rows.map((row) => [row.id, row]));
for (const [key, ids] of related) {
const allowed = ids.filter((id) => {
const row = relations.get(id);
return (
row &&
(row.teacherId === user.sub ||
(PERMISSION_META[key].relation === 'creator'
? row.notifications.length > 0
: row.courses.length > 0))
);
});
// 成绩页还包含考试口径/查询开关,显式组织范围可维护这些配置。
result[key] =
key === 'grade:write'
? [...new Set([...allowed, ...(management.find(([k]) => k === key)?.[1] ?? [])])]
: allowed;
}
return result;
}
export async function assertClassInScope(
user: TokenPayload,
key: PermissionKey,
classId: string,
db: Prisma.TransactionClient = prisma,
): Promise<void> {
if (!(await db.class.count({ where: { AND: [{ id: classId }, await classWhere(user, key)] } })))
throw ApiError.forbidden('目标班级不在此功能的授权范围内');
}
/** 管理动作只有显式年级/部/全校分配可越过班主任关系。 */
export async function hasOrganizationalGrant(
user: TokenPayload,
key: PermissionKey,
classId?: string,
db: Prisma.TransactionClient = prisma,
): Promise<boolean> {
if (user.role === 'ADMIN') return true;
const auth = await loadAuthPrincipal(user);
const grants = (auth.grants.get(key) ?? []).filter((g) => ['GRADE', 'DIVISION', 'ALL'].includes(g.scope));
if (!classId) return grants.some((g) => g.scope === 'ALL');
return (await classIdsFor(user, key, { ...auth, grants: new Map([[key, grants]]) }, db)).includes(classId);
}
+23
View File
@@ -0,0 +1,23 @@
import type { UserRole } from '@classhelper/shared';
import { prisma } from '../db.js';
import { ApiError } from '../http.js';
import type { TokenPayload } from '../jwt.js';
export async function loadSessionUser(payload: TokenPayload): Promise<TokenPayload> {
if (payload.classSession && payload.role === 'CLASS_DEVICE') {
const cls = await prisma.class.findUnique({ where: { id: payload.classId ?? payload.sub } });
if (!cls || cls.archivedYearId) throw ApiError.unauthorized('班级不存在或已归档');
return {
sub: cls.id,
username: cls.code,
name: cls.name,
role: 'CLASS_DEVICE',
classId: cls.id,
classSession: true,
classCode: cls.code,
};
}
if (payload.role === 'CLASS_DEVICE') throw ApiError.unauthorized('无效班级会话');
const row = await prisma.user.findUnique({ where: { id: payload.sub } });
if (!row || !['ADMIN', 'TEACHER'].includes(row.role)) throw ApiError.unauthorized('账号不存在');
return { sub: row.id, username: row.username, name: row.name, role: row.role as UserRole, classId: null };
}
@@ -1,52 +0,0 @@
import { prisma } from './db.js';
import type { TokenPayload } from './jwt.js';
import type { Prisma } from '../generated/prisma/client.js';
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 } } }] };
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 groups) {
if (permission && !group.items.some((item) => item.key === permission)) continue;
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
);
}
+11 -90
View File
@@ -1,94 +1,11 @@
import {
BUILTIN_TEACHER_GROUPS,
PERMISSION_KEYS,
PERMISSION_LABELS,
ROLE_DEFAULT_PERMISSIONS,
defaultPermissionsForRole,
type PermissionKey,
} 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);
}
/**
* 生效权限集合的**唯一加载入口**(权限组功能的全部运行时逻辑都收在这里)。
*
* 语义(与 shared 的 `PERMISSION_DEFAULT_ROLES` 一一对应):
* - `ADMIN`:按权限组计算,另外**永远**保留 {@link ADMIN_UNREMOVABLE_PERMISSIONS},避免管理员
* 把自己(或最后一个管理员)锁在权限页之外;
* - `CLASS_DEVICE`(ClassHelper 班级端):**空集,且不受权限组影响** —— 班级端的会话主体是
* 班级而非账号,它的能力由班级维度断言(`canManageSubjectContent(..., { allowClassDevice })`
* 等)决定,给某个班级端"分配权限组"没有意义;
* - 其他账号:**有分组**时取这些组的键并集(因此可以比默认更少 ⇒ 也能用来收回权限);
* **没有任何分组**时使用数据库中可编辑的角色默认组(逐条镜像权限组上线前各路由的 `requireRole`)。
*
* 每次请求实时查询(不缓存):权限改动**立即生效**,代价是每请求多一条 `findMany`
* (账号规模很小,这个代价换"改了立刻生效"是值的)。
*/
export async function loadEffectivePermissions(user: TokenPayload): Promise<Set<PermissionKey>> {
const permissions = new Set<PermissionKey>();
if (user.classSession || user.role === 'CLASS_DEVICE') return permissions;
await ensureDefaultGroups();
const assignments = await prisma.userPermissionGroup.findMany({
where: { userId: user.sub },
select: { group: { select: { items: { select: { key: true } } } } },
});
if (assignments.length === 0) {
const group = await prisma.permissionGroup.findUnique({
where: { defaultRole: user.role },
include: { items: true },
});
for (const key of group ? group.items.map((item) => item.key) : defaultPermissionsForRole(user.role)) {
if ((PERMISSION_KEYS as readonly string[]).includes(key)) permissions.add(key as PermissionKey);
}
if (user.role === 'ADMIN') for (const key of ADMIN_UNREMOVABLE_PERMISSIONS) permissions.add(key);
return permissions;
}
const known = new Set<string>(PERMISSION_KEYS);
for (const assignment of assignments) {
for (const item of assignment.group.items) {
// 库里可能留着已下线的键(旧版本写入的),这里静默忽略,避免整账号权限异常
if (known.has(item.key)) permissions.add(item.key as PermissionKey);
}
}
if (user.role === 'ADMIN') for (const key of ADMIN_UNREMOVABLE_PERMISSIONS) permissions.add(key);
return permissions;
}
/** 首次升级创建默认组;后续启动保留管理员的编辑结果(包括空权限组)。 */
let defaultGroupsReady: Promise<void> | null = null;
@@ -114,10 +31,10 @@ async function createDefaultGroups(): Promise<void> {
},
});
}
for (const role of ['ADMIN', 'TEACHER'] as const) {
for (const role of ['TEACHER'] as const) {
if (await prisma.permissionGroup.findUnique({ where: { defaultRole: role }, select: { id: true } }))
continue;
const baseName = `${role === 'ADMIN' ? '管理员' : '教师'}默认权限`;
const baseName = '教师默认权限';
let name = baseName;
let suffix = 1;
// 旧库可能已有同名自定义组,保留它并为角色默认组选一个不冲突的名称。
@@ -132,18 +49,22 @@ async function createDefaultGroups(): Promise<void> {
defaultRole: role,
name,
builtin: true,
scope: role === 'ADMIN' ? 'ALL' : 'SELF_CLASS',
description: '未分配其他权限组时自动生效;可调整权限。管理员始终保留权限与系统管理入口。',
scope: 'TEACHING',
description: '未显式分配时使用此模板与固定任教班级范围。',
items: { create: ROLE_DEFAULT_PERMISSIONS[role].map((key) => ({ key })) },
},
});
}
}
/** 管理员不可被剥夺的权限键(防锁死) */
export const ADMIN_UNREMOVABLE_PERMISSIONS: readonly PermissionKey[] = ['permission:manage', 'system:manage'];
/** 把权限键翻译成中文提示(403 文案与权限页共用) */
export function permissionLabel(key: PermissionKey): string {
return PERMISSION_LABELS[key];
}
export { loadAuthPrincipal } from './authz/principal.js';
export { assertPermission, assertAnyPermission, assertPermissionOrClassDevice } from './authz/assert.js';
import { loadAuthPrincipal } from './authz/principal.js';
export async function hasEffectivePermission(user: TokenPayload, key: PermissionKey) {
return (await loadAuthPrincipal(user)).permissions.has(key);
}
+23
View File
@@ -1,3 +1,6 @@
import { refreshClassSubscriptions } from '../realtime/bus.js';
import { invalidateAuthPrincipals } from './authz/principal.js';
import { migratePermissionScope } from '@classhelper/shared';
import type { PrismaClient } from '../generated/prisma/client.js';
import type { DatabaseProvider } from './db.js';
@@ -183,6 +186,24 @@ export async function restoreSnapshot(
}
for (const table of SNAPSHOT_TABLES) {
const rows = (snapshot.data[table] ?? []).map((row) => {
if (table === 'PermissionGroup') {
const mapped = migratePermissionScope(
String(row.scope ?? 'TEACHING'),
String(row.scopeValue ?? ''),
);
return {
...row,
...mapped,
...(row.defaultRole === 'TEACHER' ? { scope: 'TEACHING', scopeValue: '' } : {}),
};
}
if (table === 'UserPermissionGroup' && row.scope === undefined) {
const group = snapshot.data.PermissionGroup.find((g) => g.id === row.groupId);
return {
...row,
...migratePermissionScope(String(group?.scope ?? 'TEACHING'), String(group?.scopeValue ?? '')),
};
}
if (table === 'StudentClassTransfer') {
const { mode, ...rest } = row;
return {
@@ -209,6 +230,8 @@ export async function restoreSnapshot(
} finally {
await setForeignKeys(prisma, provider, true).catch(() => undefined);
}
invalidateAuthPrincipals();
refreshClassSubscriptions();
return { counts };
}
+12 -7
View File
@@ -1,3 +1,4 @@
import type { Prisma } from '../generated/prisma/client.js';
import { randomUUID } from 'node:crypto';
import { currentAcademicYearStart, SOCKET_EVENTS, type StudentLifecycleKind } from '@classhelper/shared';
import { prisma } from './db.js';
@@ -31,17 +32,20 @@ export async function changeStudentLifecycle(
status?: 'active' | 'inactive';
};
},
transaction?: Prisma.TransactionClient,
) {
const batchId = randomUUID();
const ids = [...new Set(input.studentIds)];
const existing = ids.length ? await prisma.student.findMany({ where: { id: { in: ids } } }) : [];
const existing = ids.length
? await (transaction ?? 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);
await assertCanManageRoster(user, cls ?? undefined, transaction);
if (input.toClassId) await assertCanManageRoster(user, input.toClassId, transaction);
else if (!existing.length) await assertCanManageRoster(user, undefined, transaction);
const changedClasses: (string | null)[] = [];
const result = await prisma.$transaction(async (tx) => {
const work = async (tx: Prisma.TransactionClient) => {
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('目标班级已归档,不能调入');
@@ -160,7 +164,8 @@ export async function changeStudentLifecycle(
transfers: records.map(toStudentTransferDto),
studentId: students[0].id,
};
});
notifyStudentClasses(changedClasses);
};
const result = transaction ? await work(transaction) : await prisma.$transaction(work);
if (!transaction) notifyStudentClasses(changedClasses);
return result;
}
+8 -49
View File
@@ -1,13 +1,9 @@
import type { Request, RequestHandler } from 'express';
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 {
bindRequestPermission,
bindRequestPermissions,
loadEffectivePermissions,
} from '../lib/permissions.js';
import { loadAuthPrincipal } from '../lib/authz/index.js';
import { loadSessionUser } from '../lib/authz/session.js';
import { extractBearerToken, verifyToken, type TokenPayload } from '../lib/jwt.js';
/**
@@ -25,44 +21,8 @@ export function authenticate(): RequestHandler {
const payload = verifyToken(token);
// ClassHelper 班级端:主体是**班级**而不是某个账号,回查 Class 表
if (payload.classSession) {
const record = await prisma.class.findUnique({
where: { id: payload.classId ?? payload.sub },
select: { id: true, name: true, code: true },
});
if (!record) throw ApiError.unauthorized('班级不存在或已被删除');
const user: TokenPayload = {
sub: record.id,
username: record.code,
name: record.name,
role: 'CLASS_DEVICE',
classId: record.id,
classSession: true,
classCode: record.code,
};
req.auth = { token, user, permissions: await loadEffectivePermissions(user) };
next();
return;
}
// 账号(教师 / 管理员):`User` 是纯账号表,学生不在里面
const user = await prisma.user.findUnique({
where: { id: payload.sub },
select: { id: true, username: true, name: true, role: true },
});
if (!user) throw ApiError.unauthorized('账号不存在或已被删除');
const authUser: TokenPayload = {
sub: user.id,
username: user.username,
name: user.name,
role: user.role as UserRole,
classId: null,
};
req.auth = { token, user: authUser, permissions: await loadEffectivePermissions(authUser) };
bindRequestPermissions(authUser, req.auth.permissions!);
const user = await loadSessionUser(payload);
req.auth = { token, user, permissions: (await loadAuthPrincipal(user)).permissions };
next();
} catch (error) {
next(error);
@@ -89,7 +49,7 @@ export function requireRole(...roles: UserRole[]): RequestHandler {
/**
* 权限守卫:要求当前登录主体拥有**全部**列出的权限键。
*
* 权限集合来自 {@link loadEffectivePermissions}(权限组并集,或角色默认模板)。
* 权限集合来自 {@link loadAuthPrincipal}(管理员全集,或教师模板与分配)。
* 提示文案用中文权限名,用户看到的是"缺少「导入成绩」权限"而不是一堆键名。
*/
export function requirePermission(...keys: PermissionKey[]): RequestHandler {
@@ -105,13 +65,12 @@ export function requirePermission(...keys: PermissionKey[]): RequestHandler {
next(ApiError.forbidden(`当前账号缺少${labels}权限,请联系管理员在「权限组」里分配`));
return;
}
if (keys[0]) bindRequestPermission(req.auth.user, keys[0]);
next();
};
}
/** 班级端按班级范围开放读取;教师/管理员仍按可编辑的功能权限校验。 */
export function requireStaffPermission(...keys: PermissionKey[]): RequestHandler {
export function requirePermissionOrClassDevice(...keys: PermissionKey[]): RequestHandler {
const guard = requirePermission(...keys);
return (req, res, next) => {
if (req.auth?.user.role === 'CLASS_DEVICE') return next();
@@ -139,8 +98,6 @@ export function requireAnyPermission(...keys: PermissionKey[]): RequestHandler {
);
return;
}
const matched = keys.find((key) => permissions.has(key));
if (matched) bindRequestPermission(req.auth.user, matched);
next();
};
}
@@ -161,3 +118,5 @@ export function getAuthPermissions(req: Request): ReadonlySet<PermissionKey> {
export function hasAuthPermission(req: Request, key: PermissionKey): boolean {
return (req.auth?.permissions ?? new Set<PermissionKey>()).has(key);
}
export const requireAdmin = (): RequestHandler => requireRole('ADMIN');
+14 -2
View File
@@ -1,5 +1,6 @@
import type { Request, RequestHandler } from 'express';
import type { ZodType } from 'zod';
import { ZodError, type ZodType } from 'zod';
import { ApiError } from '../lib/http.js';
interface ValidationTargets<B, Q, P> {
body?: ZodType<B>;
@@ -14,6 +15,7 @@ interface ValidationTargets<B, Q, P> {
*/
export function validate<B = unknown, Q = unknown, P = unknown>(
targets: ValidationTargets<B, Q, P>,
errorStatus: 400 | 422 = 422,
): RequestHandler {
return (req, _res, next) => {
try {
@@ -22,11 +24,21 @@ export function validate<B = unknown, Q = unknown, P = unknown>(
if (targets.params) req.validatedParams = targets.params.parse(req.params) as unknown;
next();
} catch (error) {
next(error);
next(
errorStatus === 400 && error instanceof ZodError
? ApiError.badRequest('请求参数不合法', error.issues)
: error,
);
}
};
}
export function validateBadRequest<B = unknown, Q = unknown, P = unknown>(
targets: ValidationTargets<B, Q, P>,
): RequestHandler {
return validate(targets, 400);
}
/** 读取已校验的 query(类型由调用方用 zod schema 保证) */
/** 取出校验后的请求体(与 validatedQuery/validatedParams 同一套约定) */
export function validatedBody<T>(req: Request): T {
+26 -7
View File
@@ -1,3 +1,5 @@
import type { PermissionKey } from '@classhelper/shared';
import { assertAdmin, assertPermission, classIdsFor } from '../../lib/authz/index.js';
import { activeStudentWhere } from '../../lib/students.js';
import {
dayKeyLocal,
@@ -84,6 +86,7 @@ export async function updateAiSettings(
user: TokenPayload,
input: UpdateAiSettingsInput,
): Promise<AiSettingsDto> {
assertAdmin(user);
if (input.enabled !== undefined) await setSetting(SETTING_KEYS.aiEnabled, input.enabled ? 'true' : 'false');
if (input.provider !== undefined) await setSetting(SETTING_KEYS.aiProvider, input.provider);
if (input.baseUrl !== undefined) await setSetting(SETTING_KEYS.aiBaseUrl, input.baseUrl.trim());
@@ -105,17 +108,33 @@ interface AssistantContext {
}
async function resolveContext(user: TokenPayload, classId?: string): Promise<AssistantContext> {
if (classId) await assertClassAccess(user, classId);
const classIds = await getAccessibleClassIds(user);
if (classId) await assertClassAccess(user, classId, 'ai:use');
await assertPermission(user, 'ai:use');
const candidates = new Set(
(
await Promise.all(
['notification:publish', 'homework:write', 'call:create'].map((key) =>
classIdsFor(user, key as PermissionKey),
),
)
).flat(),
);
const classIds = (await getAccessibleClassIds(user, 'ai:use')).filter((id) => candidates.has(id));
const classes = await prisma.class.findMany({
where: { id: { in: classIds } },
where: {
id: { in: classIds },
...(user.role !== 'ADMIN'
? { OR: [{ teacherId: user.sub }, { courses: { some: { teacherId: user.sub } } }] }
: {}),
},
select: { id: true, name: true },
orderBy: { name: 'asc' },
take: 80,
});
const courses = classId
? await prisma.course.findMany({ where: { classId }, select: { id: true, name: true } })
: [];
const courses =
classId && classIds.includes(classId)
? await prisma.course.findMany({ where: { classId }, select: { id: true, name: true } })
: [];
return { classes, courses };
}
@@ -345,7 +364,7 @@ export async function interpret(user: TokenPayload, input: AiInterpretInput): Pr
}
if (typeof params.classId === 'string') {
await assertClassAccess(user, params.classId);
await assertClassAccess(user, params.classId, 'ai:use');
if (typeof params.courseId === 'string') {
const course = await prisma.course.findFirst({
where: { id: params.courseId, classId: params.classId },
@@ -1,3 +1,4 @@
import { requireAdmin } from '../../middleware/auth.js';
import { restoreStudentSchema } from '../students/students.schemas.js';
import { listTransferred, restoreTransferred } from './transferred.service.js';
import { Router } from 'express';
@@ -15,7 +16,7 @@ import {
import * as archiveService from './archives.service.js';
const router = Router();
router.use(authenticate());
router.use(authenticate(), requireAdmin());
router.get('/transferred', requirePermission('archive:manage'), async (req, res) =>
sendOk(res, await listTransferred(getAuthUser(req))),
);
@@ -1,6 +1,6 @@
import { Router } from 'express';
import { sendCreated } from '../../lib/http.js';
import { authenticate, getAuthUser, requireStaffPermission } from '../../middleware/auth.js';
import { authenticate, getAuthUser, requirePermissionOrClassDevice } from '../../middleware/auth.js';
import { validate } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import { createCallSchema, type CreateCallInput } from './calls.schemas.js';
@@ -15,7 +15,7 @@ router.use(authenticate());
*/
router.post(
'/',
requireStaffPermission('call:create'),
requirePermissionOrClassDevice('call:create'),
validate({ body: createCallSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -1,7 +1,7 @@
import { assertStaffPermission } from '../../lib/permissions.js';
import { assertPublish } from '../../lib/authz/index.js';
import { assertPermissionOrClassDevice } 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';
import { prisma } from '../../lib/db.js';
import { ApiError } from '../../lib/http.js';
@@ -29,8 +29,8 @@ 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);
await assertPermissionOrClassDevice(user, 'call:create');
await assertPublish(user, 'call:create', input.classId, { allowClassDevice: true });
const student = await prisma.student.findUnique({
where: { id: input.studentId },
@@ -9,8 +9,9 @@ import { idParamSchema } from '../../lib/schemas.js';
import {
authenticate,
getAuthUser,
requireStaffPermission,
requirePermissionOrClassDevice,
requirePermission,
requireAnyPermission,
} from '../../middleware/auth.js';
import { validate, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
@@ -21,7 +22,7 @@ const layouts = Router();
layouts.use(authenticate());
layouts.get(
'/',
requireStaffPermission('schedule:read'),
requirePermissionOrClassDevice('schedule:read'),
validate({ query: schemas.scopeQuery }),
async (req, res) =>
sendOk(
@@ -31,26 +32,27 @@ layouts.get(
);
layouts.post(
'/',
requirePermission('timetable:manage'),
requireAnyPermission('timetable:manage', 'schedule:write'),
validate({ body: schemas.layoutSchema }),
async (req, res) => sendCreated(res, await service.saveLayout(req.body)),
async (req, res) => sendCreated(res, await service.saveLayout(getAuthUser(req), req.body)),
);
layouts.put(
'/:id',
requirePermission('timetable:manage'),
requireAnyPermission('timetable:manage', 'schedule:write'),
validate({ params: idParamSchema, body: schemas.layoutSchema }),
async (req, res) => sendOk(res, await service.saveLayout(req.body, String(req.params.id))),
async (req, res) =>
sendOk(res, await service.saveLayout(getAuthUser(req), req.body, String(req.params.id))),
);
layouts.post(
'/:id/clone',
requirePermission('timetable:manage'),
requireAnyPermission('timetable:manage', 'schedule:write'),
validate({ params: idParamSchema, body: schemas.cloneSchema }),
async (req, res) => {
const current = (await service.listLayouts(getAuthUser(req))).find((p) => p.id === req.params.id);
if (!current) throw ApiError.notFound('时间表不存在');
sendCreated(
res,
await service.saveLayout({
await service.saveLayout(getAuthUser(req), {
...current,
name: req.body.name || `${current.name} 副本`,
points: current.points.map((p) => ({ ...p, id: undefined, timeType: p.timeType as 'LESSON' })),
@@ -60,7 +62,7 @@ layouts.post(
);
layouts.post(
'/:id/import',
requirePermission('timetable:manage'),
requireAnyPermission('timetable:manage', 'schedule:write'),
validate({ params: idParamSchema, body: schemas.importLayoutSchema }),
async (req, res) => {
const all = await service.listLayouts(getAuthUser(req));
@@ -70,6 +72,7 @@ layouts.post(
sendOk(
res,
await service.saveLayout(
getAuthUser(req),
{
...target,
points: source.points.map((p) => ({ ...p, id: undefined, timeType: p.timeType as 'LESSON' })),
@@ -81,21 +84,21 @@ layouts.post(
);
layouts.post(
'/:id/points/:pointId/apply-default',
requirePermission('timetable:manage'),
requireAnyPermission('timetable:manage', 'schedule:write'),
validate({ params: z.object({ id: z.string().min(1), pointId: z.string().min(1) }) }),
async (req, res) =>
sendOk(res, await service.applyDefaultCourse(String(req.params.id), String(req.params.pointId))),
sendOk(
res,
await service.applyDefaultCourse(getAuthUser(req), String(req.params.id), String(req.params.pointId)),
),
);
layouts.delete(
'/:id',
requirePermission('timetable:manage'),
requireAnyPermission('timetable:manage', 'schedule:write'),
validate({ params: idParamSchema }),
async (req, res) => {
const id = String(req.params.id);
if (await prisma.classPlan.count({ where: { timeLayoutId: id } }))
throw ApiError.conflict('仍有课表使用该时间表');
await prisma.timeLayout.delete({ where: { id } });
await service.notifyScheduleChanged();
await service.deleteLayout(getAuthUser(req), id);
sendOk(res, { id });
},
);
@@ -104,7 +107,7 @@ const plans = Router();
plans.use(authenticate());
plans.get(
'/profile',
requireStaffPermission('schedule:read'),
requirePermissionOrClassDevice('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)),
@@ -127,7 +130,7 @@ plans.post(
);
plans.get(
'/',
requireStaffPermission('schedule:read'),
requirePermissionOrClassDevice('schedule:read'),
validate({ query: schemas.scopeQuery }),
async (req, res) => {
const q = validatedQuery<{ classId?: string; week?: number }>(req);
@@ -175,7 +178,7 @@ plans.post(
);
plans.get(
'/:id/export',
requireStaffPermission('schedule:read'),
requirePermissionOrClassDevice('schedule:read'),
validate({ params: idParamSchema }),
async (req, res) => {
const p = await service.requirePlan(getAuthUser(req), String(req.params.id), false);
@@ -207,22 +210,22 @@ const groups = Router();
groups.use(authenticate());
groups.get(
'/selection',
requireStaffPermission('schedule:read'),
requirePermissionOrClassDevice('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);
await assertClassAccess(getAuthUser(req), classId, 'schedule:read');
const value = await prisma.appSetting.findUnique({ where: { key: `schedule.group.${classId}` } });
sendOk(res, value ? JSON.parse(value.value) : {});
},
);
groups.get(
'/',
requireStaffPermission('schedule:read'),
requirePermissionOrClassDevice('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);
await assertClassAccess(getAuthUser(req), classId, 'schedule:read');
sendOk(res, await prisma.classPlanGroup.findMany({ where: { classId } }));
},
);
@@ -1,3 +1,4 @@
import { assertAdmin, assertHeadOnly, assertPermissionOrClassDevice } from '../../lib/authz/index.js';
import { SOCKET_EVENTS } from '@classhelper/shared';
import { prisma } from '../../lib/db.js';
import { assertCanManageSchedule, assertClassAccess, getAccessibleClassIds } from '../../lib/access.js';
@@ -24,20 +25,24 @@ export async function notifyScheduleChanged(classId?: string | null) {
for (const id of ids) emitToClass(id, SOCKET_EVENTS.scheduleUpdated, { classId: id, action: 'updated' });
}
export async function listLayouts(user: TokenPayload, classId?: string) {
if (classId) await assertClassAccess(user, classId);
const ids = classId ? [classId] : await getAccessibleClassIds(user);
await assertPermissionOrClassDevice(user, 'schedule:read');
if (classId) await assertClassAccess(user, classId, 'schedule:read');
const ids = classId ? [classId] : await getAccessibleClassIds(user, 'schedule:read');
const rows = await prisma.timeLayout.findMany({
where: { OR: [{ classId: null }, { classId: { in: ids } }] },
include: { points: { orderBy: { position: 'asc' } } },
});
return Promise.all(rows.map((row) => decorateLayout(row)));
}
export async function saveLayout(input: z.infer<typeof layoutSchema>, id?: string) {
export async function saveLayout(user: TokenPayload, input: z.infer<typeof layoutSchema>, id?: string) {
const existing = id
? await prisma.timeLayout.findUnique({ where: { id }, select: { classId: true } })
: null;
if (id && !existing) throw ApiError.notFound('时间表不存在');
const layoutClassId = existing ? existing.classId : (input.classId ?? null);
await assertLayoutWrite(user, layoutClassId, id);
if (existing && input.classId !== undefined && input.classId !== existing.classId)
throw ApiError.forbidden('不能移动时间表到其他班级');
// 默认课程属于具体班级:全校时间表不保留默认课程,非上课时间点也不保留。
const points = input.points.map((point) => ({
...point,
@@ -83,12 +88,13 @@ export async function saveLayout(input: z.infer<typeof layoutSchema>, id?: strin
* 把时间点的默认课程写入所有引用该时间表的日课表(ClassIsland 的「覆盖现有课程」)。
* 只处理班级时间表:全校时间表的默认课程没有归属班级。
*/
export async function applyDefaultCourse(layoutId: string, pointId: string) {
export async function applyDefaultCourse(user: TokenPayload, layoutId: string, pointId: string) {
const layout = await prisma.timeLayout.findUnique({
where: { id: layoutId },
include: { points: true },
});
if (!layout) throw ApiError.notFound('时间表不存在');
await assertLayoutWrite(user, layout.classId, layout.id);
if (!layout.classId) throw ApiError.badRequest('全校时间表不能覆盖默认课程');
const point = layout.points.find((p) => p.id === pointId);
if (!point || point.timeType !== 'LESSON') throw ApiError.badRequest('时间点不是上课时间点');
@@ -121,10 +127,11 @@ export async function applyDefaultCourse(layoutId: string, pointId: string) {
return { plans: plans.length };
}
export async function listPlans(user: TokenPayload, classId?: string, week?: number) {
if (classId) await assertClassAccess(user, classId);
await assertPermissionOrClassDevice(user, 'schedule:read');
if (classId) await assertClassAccess(user, classId, 'schedule:read');
return prisma.classPlan.findMany({
where: {
classId: { in: classId ? [classId] : await getAccessibleClassIds(user) },
classId: { in: classId ? [classId] : await getAccessibleClassIds(user, 'schedule:read') },
...(week ? { weekStart: { lte: week }, weekEnd: { gte: week } } : {}),
},
include: planInclude,
@@ -134,7 +141,8 @@ export async function listPlans(user: TokenPayload, classId?: string, week?: num
export async function requirePlan(user: TokenPayload, id: string, write = true) {
const p = await prisma.classPlan.findUnique({ where: { id }, include: planInclude });
if (!p) throw ApiError.notFound('课表不存在');
await (write ? assertCanManageSchedule : assertClassAccess)(user, p.classId);
if (write) await assertCanManageSchedule(user, p.classId);
else await assertClassAccess(user, p.classId, 'schedule:read');
return p;
}
export async function savePlan(user: TokenPayload, input: z.infer<typeof planSchema>, id?: string) {
@@ -285,3 +293,24 @@ export async function createOverlay(
await notifyScheduleChanged(source.classId);
return result;
}
export async function assertLayoutWrite(user: TokenPayload, classId: string | null, layoutId?: string) {
if (classId) await assertHeadOnly(user, 'schedule:write', classId);
else assertAdmin(user);
if (layoutId) {
const refs = await prisma.classPlan.findMany({
where: { timeLayoutId: layoutId },
select: { classId: true },
});
for (const id of new Set(refs.map((p) => p.classId))) await assertHeadOnly(user, 'schedule:write', id);
}
}
export async function deleteLayout(user: TokenPayload, id: string) {
const layout = await prisma.timeLayout.findUnique({ where: { id } });
if (!layout) throw ApiError.notFound('时间表不存在');
await assertLayoutWrite(user, layout.classId, id);
if (await prisma.classPlan.count({ where: { timeLayoutId: id } }))
throw ApiError.conflict('仍有课表使用该时间表');
await prisma.timeLayout.delete({ where: { id } });
await notifyScheduleChanged(layout.classId);
}
@@ -4,7 +4,7 @@ import type { TokenPayload } from '../../lib/jwt.js';
/** 按时间点外键生成完整档案,空课、停用课程与非上课时间点均保留。 */
export async function exportProfile(user: TokenPayload, classId: string) {
await assertClassAccess(user, classId);
await assertClassAccess(user, classId, 'schedule:read');
const [cls, layouts, plans, subjects, groups, scheduled, setting] = await Promise.all([
prisma.class.findUniqueOrThrow({ where: { id: classId } }),
prisma.timeLayout.findMany({
@@ -4,8 +4,9 @@ import { idParamSchema } from '../../lib/schemas.js';
import {
authenticate,
getAuthUser,
requireStaffPermission,
requirePermissionOrClassDevice,
requirePermission,
requireAdmin,
} from '../../middleware/auth.js';
import { validate, validatedBody, validatedParams, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
@@ -19,6 +20,8 @@ import {
promoteClassesSchema,
updateClassAccountSchema,
updateClassSchema,
updateClassSettingsSchema,
type UpdateClassSettingsInput,
updateStudentGradeQuerySchema,
type AddStudentInput,
type AssignSubjectTeachersInput,
@@ -42,7 +45,7 @@ router.use(authenticate());
*/
router.get(
'/',
requireStaffPermission('class:read'),
requirePermissionOrClassDevice('class:read'),
validate({ query: listClassesQuerySchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -59,6 +62,7 @@ router.get(
*/
router.post(
'/promote',
requireAdmin(),
requirePermission('promotion:manage'),
validate({ body: promoteClassesSchema }),
async (req, res) => {
@@ -71,7 +75,7 @@ router.post(
/** GET /api/classes/:id - 班级详情(学生名单 + 课程 + 班主任 + 各科任课老师) */
router.get(
'/:id',
requireStaffPermission('class:read'),
requirePermissionOrClassDevice('class:read'),
validate({ params: idParamSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -83,6 +87,7 @@ router.get(
/** POST /api/classes - 创建班级(称呼由入学年份 + 班号生成,如「2026级1班」) */
router.post(
'/',
requireAdmin(),
requirePermission('class:manage'),
validate({ body: createClassSchema }),
async (req, res) => {
@@ -94,6 +99,7 @@ router.post(
/** PATCH /api/classes/:id - 编辑班级(年级 / 届别 / 教学周数),按功能权限及范围校验 */
router.patch(
'/:id',
requireAdmin(),
requirePermission('class:manage'),
(req, _res, next) => {
if (req.body && 'grade' in req.body) return next(ApiError.badRequest('年级请通过学年升级入口修改'));
@@ -107,9 +113,25 @@ router.patch(
},
);
router.patch(
'/:id/settings',
requirePermission('class:settings'),
validate({ params: idParamSchema, body: updateClassSettingsSchema }),
async (req, res) => {
sendOk(
res,
await classService.updateClassSettings(
getAuthUser(req),
String(req.params.id),
validatedBody<UpdateClassSettingsInput>(req),
),
);
},
);
/** DELETE /api/classes/:id - 删除班级 */
router.delete(
'/:id',
requireAdmin(),
requirePermission('class:manage'),
validate({ params: idParamSchema }),
async (req, res) => {
@@ -126,7 +148,7 @@ router.delete(
*/
router.patch(
'/:id/class-account',
requirePermission('class:manage'),
requirePermission('class:account'),
validate({ params: idParamSchema, body: updateClassAccountSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -145,6 +167,7 @@ router.patch(
*/
router.patch(
'/:id/head-teacher',
requireAdmin(),
requirePermission('class:manage'),
validate({ params: idParamSchema, body: assignHeadTeacherSchema }),
async (req, res) => {
@@ -164,6 +187,7 @@ router.patch(
*/
router.put(
'/:id/subject-teachers',
requireAdmin(),
requirePermission('class:manage'),
validate({ params: idParamSchema, body: assignSubjectTeachersSchema }),
async (req, res) => {
@@ -183,7 +207,7 @@ router.put(
*/
router.get(
'/:id/student-grade-query',
requireStaffPermission('class:read'),
requirePermissionOrClassDevice('class:read'),
validate({ params: idParamSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -210,7 +234,7 @@ router.patch(
/** GET /api/classes/:id/students - 班级学生名单 */
router.get(
'/:id/students',
requireStaffPermission('roster:read'),
requirePermissionOrClassDevice('roster:read'),
validate({ params: idParamSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -41,12 +41,15 @@ export const createClassSchema = z.object({
* `enrollmentYear` / `classIndex` 是给"有名无实的旧数据"补届别用的,改了会重算班级称呼。
*/
export const updateClassSchema = z.strictObject({
enrollmentYear: enrollmentYearSchema.optional(),
classIndex: classIndexSchema.optional(),
});
export const updateClassSettingsSchema = z.strictObject({
/** 本班学期开始日期(第 1 教学周的周一);空串 = 回落到全局配置 */
termStartDate: z
.union([z.string().trim().refine(isDayKey, { message: '日期格式应为 YYYY-MM-DD' }), z.literal('')])
.nullish(),
enrollmentYear: enrollmentYearSchema.optional(),
classIndex: classIndexSchema.optional(),
termWeeks: z.coerce.number().int().min(1).max(60).optional(),
});
@@ -125,3 +128,5 @@ export type UpdateClassAccountInput = z.infer<typeof updateClassAccountSchema>;
export type UpdateStudentGradeQueryInput = z.infer<typeof updateStudentGradeQuerySchema>;
export type AddStudentInput = z.infer<typeof addStudentSchema>;
export type AssignSubjectTeachersInput = z.infer<typeof assignSubjectTeachersSchema>;
export type UpdateClassSettingsInput = z.infer<typeof updateClassSettingsSchema>;
@@ -1,3 +1,6 @@
import { disconnectUserSessions, refreshClassSubscriptions } from '../../realtime/bus.js';
import { assertHeadOnly, assertManage } from '../../lib/authz/index.js';
import { auditData } from '../permissions/permissions.service.js';
import type { ClassDetailDto, ClassDto, StudentDto, SubjectTeacherDto } from '@classhelper/shared';
import { SOCKET_EVENTS, formatClassName } from '@classhelper/shared';
import { env } from '../../config/env.js';
@@ -23,12 +26,17 @@ import type {
AssignSubjectTeachersInput,
CreateClassInput,
UpdateClassInput,
UpdateClassSettingsInput,
UpdateStudentGradeQueryInput,
} from './classes.schemas.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 {
assertPermissionOrClassDevice,
assertPermission,
hasEffectivePermission,
} from '../../lib/permissions.js';
import { activeStudentWhere } from '../../lib/students.js';
import { getMaxTermWeeks } from '../../lib/term.js';
import { logger } from '../../lib/logger.js';
@@ -79,7 +87,7 @@ function toClassDtoWithAccount(
* 混在常规列表里会让老师误以为那届还在上课。需要看归档班级请用 `/api/archives`。
*/
export async function listClasses(user: TokenPayload, keyword?: string): Promise<ClassDto[]> {
await assertStaffPermission(user, 'class:read');
await assertPermissionOrClassDevice(user, 'class:read');
const scope = await resolveClassScope(user, undefined, 'class:read');
// Class 表按主键 id 过滤(classScopeWhere 针对的是带 classId 字段的子表)
const where = {
@@ -102,8 +110,8 @@ 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);
await assertPermissionOrClassDevice(user, 'class:read');
await assertClassAccess(user, classId, 'class:read');
const item = await prisma.class.findUnique({
where: { id: classId },
@@ -147,7 +155,7 @@ export async function listSubjectTeachers(
user: TokenPayload,
classId: string,
): Promise<{ headTeacher: SubjectTeacherDto | null; subjectTeachers: SubjectTeacherDto[] }> {
await assertClassAccess(user, classId);
await assertClassAccess(user, classId, 'class:read');
const item = await prisma.class.findUnique({
where: { id: classId },
include: {
@@ -206,6 +214,13 @@ export async function createClass(user: TokenPayload, input: CreateClassInput):
},
});
await prisma.permissionAudit.create({
data: auditData(user, 'class-create', 'class', created.id, null, {
name: created.name,
teacherId: created.teacherId,
}),
});
refreshClassSubscriptions();
return toClassDtoWithAccount(user, created);
}
@@ -222,6 +237,10 @@ export async function updateHeadTeacher(
teacherId: string,
): Promise<ClassDto> {
await assertCanAssignTeachers(user, classId);
const before = await prisma.class.findUniqueOrThrow({
where: { id: classId },
select: { teacherId: true },
});
const targetId = await requireTeacher(teacherId, '指定的班主任不存在或角色不是教师');
const updated = await prisma.class.update({
@@ -233,6 +252,10 @@ export async function updateHeadTeacher(
},
});
await prisma.permissionAudit.create({
data: auditData(user, 'class-head', 'class', classId, before, { teacherId: targetId }),
});
disconnectUserSessions([before.teacherId, targetId].filter((id): id is string => !!id));
emitToClass(classId, SOCKET_EVENTS.classUpdated, { classId, action: 'head-teacher-changed' });
return toClassDtoWithAccount(user, updated);
}
@@ -256,6 +279,10 @@ export async function assignSubjectTeachers(
const targetClass = await prisma.class.findUnique({ where: { id: classId }, select: { id: true } });
if (!targetClass) throw ApiError.notFound('班级不存在');
const before = await prisma.course.findMany({
where: { classId },
select: { id: true, name: true, teacherId: true },
});
for (const assignment of input.assignments) {
const teacherId = assignment.teacherId
? await requireTeacher(assignment.teacherId, `「${assignment.subjectName}」的任课老师不存在或不是教师`)
@@ -290,6 +317,17 @@ export async function assignSubjectTeachers(
where: { id: classId },
include: { courses: { orderBy: { name: 'asc' }, include: { teacher: teacherBriefSelect } } },
});
await prisma.permissionAudit.create({
data: auditData(
user,
'class-subjects',
'class',
classId,
before,
refreshed.courses.map((c) => ({ id: c.id, name: c.name, teacherId: c.teacherId })),
),
});
disconnectUserSessions([...new Set([...before, ...refreshed.courses].map((c) => c.teacherId))]);
return refreshed.courses.map((course) => ({
courseId: course.id,
subjectName: course.name,
@@ -304,7 +342,8 @@ export async function updateClassAccount(
classId: string,
input: { code?: string; password?: string },
): Promise<ClassDto> {
await assertCanManageClasses(user, classId);
await assertHeadOnly(user, 'class:account', classId);
const before = await prisma.class.findUniqueOrThrow({ where: { id: classId }, select: { code: true } });
if (
'termWeeks' in input &&
typeof input.termWeeks === 'number' &&
@@ -322,6 +361,12 @@ export async function updateClassAccount(
},
});
if (!updated) throw ApiError.notFound('班级不存在');
await prisma.permissionAudit.create({
data: auditData(user, 'class-account', 'class', classId, before, {
code: updated.code,
passwordChanged: input.password !== undefined,
}),
});
return toClassDtoWithAccount(user, updated);
}
@@ -340,19 +385,12 @@ 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.termWeeks !== undefined ? { termWeeks: input.termWeeks } : {}),
// 学期开始日期:空串/null 都表示"回落到全局配置"(如 .env 的 TERM_START_DATE)
...(input.termStartDate !== undefined
? { termStartDate: input.termStartDate ? input.termStartDate : null }
: {}),
...(input.enrollmentYear !== undefined ? { enrollmentYear } : {}),
...(input.classIndex !== undefined ? { classIndex } : {}),
// 届别齐了就把称呼重算一遍:班级称呼的唯一来源是「入学年份 + 班号」
@@ -364,10 +402,45 @@ export async function updateClass(
},
});
await prisma.permissionAudit.create({
data: auditData(user, 'class-update', 'class', classId, current, {
enrollmentYear: updated.enrollmentYear,
classIndex: updated.classIndex,
name: updated.name,
}),
});
emitToClass(classId, SOCKET_EVENTS.classUpdated, { classId, action: 'updated' });
return toClassDto(updated);
}
export async function updateClassSettings(
user: TokenPayload,
classId: string,
input: UpdateClassSettingsInput,
): Promise<ClassDto> {
await assertHeadOnly(user, 'class:settings', classId);
if (input.termWeeks !== undefined && input.termWeeks > (await getMaxTermWeeks()))
throw ApiError.badRequest('班级周数不能超过全校上限');
const before = await prisma.class.findUniqueOrThrow({
where: { id: classId },
select: { termWeeks: true, termStartDate: true },
});
const updated = await prisma.class.update({
where: { id: classId },
data: {
termWeeks: input.termWeeks,
...(input.termStartDate !== undefined ? { termStartDate: input.termStartDate || null } : {}),
},
});
await prisma.permissionAudit.create({
data: auditData(user, 'class-settings', 'class', classId, before, {
termWeeks: updated.termWeeks,
termStartDate: updated.termStartDate,
}),
});
emitToClass(classId, SOCKET_EVENTS.classUpdated, { classId, action: 'updated' });
return toClassDto(updated);
}
/**
* 学年升级(按升级权限与班级范围):把若干班级的年级改掉,**不归档、数据沿用**。
*
@@ -405,6 +478,7 @@ export async function promoteClasses(
}
});
for (const classId of ids) emitToClass(classId, SOCKET_EVENTS.classUpdated, { classId, action: 'updated' });
refreshClassSubscriptions();
return { promoted: ids.length };
}
@@ -414,13 +488,21 @@ export async function deleteClass(user: TokenPayload, classId: string): Promise<
if (await prisma.student.count({ where: { classId } }))
throw ApiError.conflict('班级仍有学生,请先通过名单生命周期入口移出或归档,不能级联清除归属');
emitToClass(classId, SOCKET_EVENTS.classUpdated, { classId, action: 'deleted' });
const before = await prisma.class.findUniqueOrThrow({
where: { id: classId },
select: { id: true, name: true, grade: true },
});
await prisma.class.delete({ where: { id: classId } });
refreshClassSubscriptions();
await prisma.permissionAudit.create({
data: auditData(user, 'class-delete', 'class', classId, before, null),
});
}
/** 班级学生名单 */
export async function listClassStudents(user: TokenPayload, classId: string): Promise<StudentDto[]> {
await assertStaffPermission(user, 'roster:read');
await assertClassAccess(user, classId);
await assertPermissionOrClassDevice(user, 'roster:read');
await assertClassAccess(user, classId, 'roster:read');
const students = await prisma.student.findMany({
where: { classId, ...activeStudentWhere() },
include: { class: { select: { name: true, grade: true } } },
@@ -472,7 +554,7 @@ export async function getStudentGradeQuery(
user: TokenPayload,
classId: string,
): Promise<{ studentGradeQueryEnabled: boolean }> {
await assertClassAccess(user, classId);
await assertClassAccess(user, classId, 'class:read');
const record = await prisma.class.findUnique({
where: { id: classId },
select: { studentGradeQueryEnabled: true },
@@ -493,7 +575,7 @@ export async function updateStudentGradeQuery(
classId: string,
input: UpdateStudentGradeQueryInput,
): Promise<{ studentGradeQueryEnabled: boolean }> {
await assertClassAccess(user, classId);
await assertManage(user, 'grade:write', classId);
if (!isStaff(user)) {
throw ApiError.forbidden('成绩查询开关由教师/管理员在 Web 端设置,班级端只读');
}
@@ -2,7 +2,7 @@ import { Router } from 'express';
import type { ClientUpdateProgressDto } from '@classhelper/shared';
import { sendCreated, sendOk } from '../../lib/http.js';
import { idParamSchema } from '../../lib/schemas.js';
import { authenticate, getAuthUser, requireAnyPermission, requirePermission } from '../../middleware/auth.js';
import { authenticate, getAuthUser, requireRole, requirePermission } from '../../middleware/auth.js';
import { validate, validatedBody, validatedParams, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import {
@@ -22,14 +22,19 @@ import * as clientService from './clients.service.js';
const router = Router();
router.use(authenticate());
router.post('/update-progress', validate({ body: clientProgressSchema }), async (req, res) => {
const input = validatedBody<{
deviceKey: string;
progress: ClientUpdateProgressDto;
}>(req);
await clientService.reportUpdateProgress(getAuthUser(req), input.deviceKey, input.progress);
sendOk(res, null);
});
router.post(
'/update-progress',
requireRole('CLASS_DEVICE'),
validate({ body: clientProgressSchema }),
async (req, res) => {
const input = validatedBody<{
deviceKey: string;
progress: ClientUpdateProgressDto;
}>(req);
await clientService.reportUpdateProgress(getAuthUser(req), input.deviceKey, input.progress);
sendOk(res, null);
},
);
/**
* POST /api/clients/heartbeat - 客户端每 30 秒上报一次
@@ -37,22 +42,28 @@ router.post('/update-progress', validate({ body: clientProgressSchema }), async
* **不挂权限键**:上报方是教室里的班级端会话(角色 CLASS_DEVICE,权限集为空)。
* 返回值里同时带回"要不要更新 + 安装包信息 + 一条待执行命令",客户端一次请求全拿到。
*/
router.post('/heartbeat', validate({ body: clientHeartbeatSchema }), async (req, res) => {
const user = getAuthUser(req);
const input = validatedBody<ClientHeartbeatInput>(req);
const result = await clientService.heartbeat(user, input, req.ip ?? '');
// 1.3.x 旧客户端误读顶层字段,提供兼容别名让它们仍能接收修复版更新。
sendOk(res, result, '心跳已上报', 200, input.agentProtocol === 2 ? {} : result);
});
router.post(
'/heartbeat',
requireRole('CLASS_DEVICE'),
validate({ body: clientHeartbeatSchema }),
async (req, res) => {
const user = getAuthUser(req);
const input = validatedBody<ClientHeartbeatInput>(req);
const result = await clientService.heartbeat(user, input, req.ip ?? '');
// 1.3.x 旧客户端误读顶层字段,提供兼容别名让它们仍能接收修复版更新。
sendOk(res, result, '心跳已上报', 200, input.agentProtocol === 2 ? {} : result);
},
);
/** POST /api/clients/commands/:id/ack - 执行回执(同样不挂权限键,靠 deviceKey 归属校验) */
router.post(
'/commands/:id/ack',
requireRole('CLASS_DEVICE'),
validate({ params: idParamSchema, body: clientCommandAckSchema }),
async (req, res) => {
const { id } = validatedParams<{ id: string }>(req);
const input = validatedBody<ClientCommandAckInput>(req);
sendOk(res, await clientService.ackCommand(id, input), '回执已记录');
sendOk(res, await clientService.ackCommand(getAuthUser(req), id, input), '回执已记录');
},
);
@@ -79,7 +90,7 @@ router.post(
/** GET /api/clients/devices?classId= - Web 端设备列表(版本 / 在线 / 待执行命令) */
router.get(
'/devices',
requireAnyPermission('client:read', 'client:manage', 'remote:control', 'client:update-push'),
requirePermission('client:read'),
validate({ query: listClientDevicesQuerySchema }),
async (req, res) => {
const query = validatedQuery<ListClientDevicesQuery>(req);
@@ -94,7 +105,7 @@ router.get(
/** POST /api/clients/updates - 统一下发客户端更新 */
router.post(
'/updates',
requirePermission('client:update'),
requirePermission('client:update-push'),
validate({ body: pushClientUpdateSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -1,3 +1,4 @@
import { assertClassDevice } from '../../lib/authz/index.js';
import {
CLIENT_COMMAND_EXPIRE_MS,
CLIENT_COMMAND_LABELS,
@@ -136,6 +137,11 @@ export async function heartbeat(
input: ClientHeartbeatInput,
ip: string,
): Promise<ClientHeartbeatResponse> {
const boundClassId = resolveClassId(user);
assertClassDevice(user, boundClassId ?? '');
const previousDevice = await prisma.clientDevice.findUnique({ where: { deviceKey: input.deviceKey } });
if (previousDevice && previousDevice.classId !== boundClassId)
throw ApiError.forbidden('设备不属于当前班级');
const now = new Date();
const heartbeatAt = {
classId: resolveClassId(user),
@@ -149,11 +155,17 @@ export async function heartbeat(
lastIp: ip,
};
const device = (await prisma.clientDevice.upsert({
where: { deviceKey: input.deviceKey },
create: { deviceKey: input.deviceKey, ...heartbeatAt },
update: heartbeatAt,
})) as unknown as DeviceRow;
const device = (await prisma.clientDevice
.upsert({
where: { deviceKey: input.deviceKey, classId: boundClassId },
create: { deviceKey: input.deviceKey, ...heartbeatAt },
update: heartbeatAt,
})
.catch(async (error: unknown) => {
const current = await prisma.clientDevice.findUnique({ where: { deviceKey: input.deviceKey } });
if (current && current.classId !== boundClassId) throw ApiError.forbidden('设备不属于当前班级');
throw error;
})) as unknown as DeviceRow;
const targetVersion = device.updateTargetVersion.trim();
if (targetVersion && input.clientVersion && compareVersions(input.clientVersion, targetVersion) >= 0) {
@@ -204,7 +216,7 @@ export async function reportUpdateProgress(
progress: ClientUpdateProgressDto,
) {
const device = await prisma.clientDevice.findUnique({ where: { deviceKey } });
// 教师/管理员电脑不绑定班级(会话与设备两侧都是 null),同样允许上报自己的进度。
assertClassDevice(user, device?.classId ?? '');
if (!device || device.classId !== resolveClassId(user)) throw ApiError.forbidden('设备不属于当前班级');
// 已取消或更换目标的迟到进度不得覆盖当前任务。
if (progress.version !== device.updateTargetVersion || progress.stage === 'completed') return;
@@ -240,14 +252,16 @@ function readUpdateProgress(raw: string): ClientUpdateProgressDto | null {
/** POST /api/clients/commands/:id/ack - 客户端执行完远控命令后的回执 */
export async function ackCommand(
user: TokenPayload,
commandId: string,
input: ClientCommandAckRequest,
): Promise<ClientCommandDto> {
const command = (await prisma.clientCommand.findUnique({
where: { id: commandId },
include: { device: { select: { deviceKey: true, name: true } } },
})) as (CommandRow & { device: { deviceKey: string; name: string } }) | null;
include: { device: { select: { deviceKey: true, name: true, classId: true } } },
})) as (CommandRow & { device: { deviceKey: string; name: string; classId: string | null } }) | null;
if (!command) throw ApiError.notFound('命令不存在');
assertClassDevice(user, command.device.classId ?? '');
if (command.device.deviceKey !== input.deviceKey) {
throw ApiError.forbidden('这条命令不是发给这台电脑的');
@@ -271,7 +285,7 @@ export async function listDevices(
user: TokenPayload,
options: { classId?: string } = {},
): Promise<ClientDeviceDto[]> {
const scope = await resolveClassScope(user, options.classId);
const scope = await resolveClassScope(user, options.classId, 'client:read');
const rows = (await prisma.clientDevice.findMany({
where: classScopeWhere(scope),
include: { class: { select: { id: true, name: true } } },
@@ -394,7 +408,7 @@ export async function createCommands(
/** 更新推送的目标集合:优先设备,其次班级,最后"全部" */
async function resolveUpdateTargets(user: TokenPayload, input: PushClientUpdateInput): Promise<DeviceRow[]> {
const scope = classScopeWhere(await resolveClassScope(user, undefined, 'client:update'));
const scope = classScopeWhere(await resolveClassScope(user, undefined, 'client:update-push'));
const deviceIds = [...new Set(input.deviceIds ?? [])];
if (deviceIds.length > 0) {
const rows = (await prisma.clientDevice.findMany({
@@ -4,7 +4,7 @@ import { idParamSchema } from '../../lib/schemas.js';
import {
authenticate,
getAuthUser,
requireStaffPermission,
requirePermissionOrClassDevice,
requirePermission,
} from '../../middleware/auth.js';
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
@@ -24,7 +24,7 @@ router.use(authenticate());
/** GET /api/courses?classId= - 课程列表 */
router.get(
'/',
requireStaffPermission('schedule:read'),
requirePermissionOrClassDevice('schedule:read'),
validate({ query: listCoursesQuerySchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -1,3 +1,4 @@
import { refreshClassSubscriptions } from '../../realtime/bus.js';
import type { CourseDto } from '@classhelper/shared';
import { assertCanManageSchedule, classScopeWhere, resolveClassScope } from '../../lib/access.js';
import { prisma } from '../../lib/db.js';
@@ -9,7 +10,7 @@ import type { CreateCourseInput, UpdateCourseInput } from './courses.schemas.js'
const teacherSelect = { select: { id: true, name: true, username: true } } as const;
export async function listCourses(user: TokenPayload, classId?: string): Promise<CourseDto[]> {
const scope = await resolveClassScope(user, classId);
const scope = await resolveClassScope(user, classId, 'schedule:read');
const courses = await prisma.course.findMany({
where: classScopeWhere(scope),
include: { teacher: teacherSelect },
@@ -26,7 +27,7 @@ export async function createCourse(user: TokenPayload, input: CreateCourseInput)
*
* 这条不是"友好提示",是**权限护栏**:`course.create` 会把创建者写成这门课的任课老师,
* 于是只要能再建一门同名的「数学」,创建者就凭空成了「数学」的老师 ——
* 后面那道 `assertCanManageSubjectContent` 的科目门随即形同虚设
* 后面那道 `` 的科目门随即形同虚设
* (可以布置该科作业、录入该科成绩、删该科内容)。这就是"非该科科任老师
* 能布置其他科作业"的入口。
*
@@ -82,4 +83,5 @@ export async function deleteCourse(user: TokenPayload, courseId: string): Promis
if (!course) throw ApiError.notFound('课程不存在');
await assertCanManageSchedule(user, course.classId);
await prisma.course.delete({ where: { id: courseId } });
refreshClassSubscriptions();
}
@@ -6,7 +6,7 @@ import {
getAuthUser,
requireRole,
requirePermission,
requireStaffPermission,
requirePermissionOrClassDevice,
} from '../../middleware/auth.js';
import { validate, validatedBody, validatedQuery, validatedParams } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
@@ -22,7 +22,7 @@ import * as service from './courseware.service.js';
const router = Router();
router.use(authenticate());
const staff = [requireRole('ADMIN', 'TEACHER'), requirePermission('courseware:write')];
router.get('/', requireStaffPermission('courseware:read'), async (req, res) =>
router.get('/', requirePermissionOrClassDevice('courseware:read'), async (req, res) =>
sendOk(res, await service.listCourseware(getAuthUser(req))),
);
router.get('/classes', ...staff, async (req, res) =>
@@ -83,10 +83,14 @@ router.post('/upload', ...staff, validate({ query: coursewareUploadQuery }), asy
);
router.get(
'/:id/download',
requireStaffPermission('courseware:read'),
requirePermissionOrClassDevice('courseware:read'),
validate({ params: idParamSchema }),
async (req, res, next) => {
const row = await service.readable(getAuthUser(req), validatedParams<{ id: string }>(req).id);
const row = await service.readable(
getAuthUser(req),
validatedParams<{ id: string }>(req).id,
'courseware:read',
);
res.setHeader('Cache-Control', 'private, no-store');
res.setHeader('Content-Type', 'application/octet-stream');
res.download(row.storagePath, row.name, { root: service.coursewareRoot, dotfiles: 'allow' }, (error) => {
@@ -127,7 +131,7 @@ router.delete('/:id', ...staff, validate({ params: idParamSchema }), async (req,
});
router.get(
'/:id',
requireStaffPermission('courseware:read'),
requirePermissionOrClassDevice('courseware:read'),
validate({ params: idParamSchema }),
async (req, res) =>
sendOk(res, await service.getCourseware(getAuthUser(req), validatedParams<{ id: string }>(req).id)),
@@ -1,3 +1,9 @@
import {
assertContentWrite,
assertPermission,
assertPermissionOrClassDevice,
classWhere,
} from '../../lib/authz/index.js';
import fs from 'node:fs';
import fsp from 'node:fs/promises';
import path from 'node:path';
@@ -17,7 +23,7 @@ import {
import { env } from '../../config/env.js';
import { prisma } from '../../lib/db.js';
import { ApiError } from '../../lib/http.js';
import { assertCanManageSubjectContent, requireClassDeviceClassId } from '../../lib/access.js';
import { requireClassDeviceClassId } from '../../lib/access.js';
import type { TokenPayload } from '../../lib/jwt.js';
import type { Prisma } from '../../generated/prisma/client.js';
import { emitToClass } from '../../realtime/bus.js';
@@ -47,13 +53,20 @@ function dto(row: RecordWithRelations): CoursewareDto {
};
}
export async function teachingClasses(user: TokenPayload) {
await assertPermission(user, 'courseware:write');
return prisma.class.findMany({
where: { archivedYearId: null, courses: { some: { teacherId: user.sub } } },
where: {
AND: [
{ archivedYearId: null, courses: { some: { teacherId: user.sub } } },
await classWhere(user, 'courseware:write'),
],
},
select: { id: true, name: true },
orderBy: { name: 'asc' },
});
}
async function validateClasses(user: TokenPayload, ids: string[]) {
await assertPermission(user, 'courseware:write');
const classIds = [...new Set(ids)];
const allowed = new Set((await teachingClasses(user)).map((item) => item.id));
if (classIds.some((id) => !allowed.has(id))) throw ApiError.forbidden('只能选择自己任教的在读班级');
@@ -62,11 +75,12 @@ async function validateClasses(user: TokenPayload, ids: string[]) {
where: { classId, teacherId: user.sub },
select: { id: true },
});
await assertCanManageSubjectContent(user, classId, course?.id, '发布课件');
await assertContentWrite(user, 'courseware:write', classId, course?.id, '发布课件');
}
return classIds;
}
export async function readable(user: TokenPayload, id: string) {
export async function readable(user: TokenPayload, id: string, key: 'courseware:read' | 'courseware:write') {
await assertPermissionOrClassDevice(user, key);
const row = await prisma.courseware.findUnique({ where: { id }, include });
if (!row) throw ApiError.notFound('课件不存在');
if (user.role === 'CLASS_DEVICE') {
@@ -77,17 +91,19 @@ export async function readable(user: TokenPayload, id: string) {
return row;
}
async function owned(user: TokenPayload, id: string) {
const row = await readable(user, id);
await assertPermission(user, 'courseware:write');
const row = await readable(user, id, 'courseware:write');
if (row.ownerId !== user.sub) throw ApiError.forbidden('只能修改自己的课件');
return row;
}
export async function getCourseware(user: TokenPayload, id: string) {
const item = dto(await readable(user, id));
const item = dto(await readable(user, id, 'courseware:read'));
if (user.role === 'CLASS_DEVICE')
item.classes = item.classes.filter((c) => c.id === requireClassDeviceClassId(user));
return item;
}
export async function listCourseware(user: TokenPayload) {
await assertPermissionOrClassDevice(user, 'courseware:read');
const classId = user.role === 'CLASS_DEVICE' ? requireClassDeviceClassId(user) : null;
const rows = await prisma.courseware.findMany({
where: classId
@@ -177,6 +193,7 @@ async function assertFolder(user: TokenPayload, folderPath: string) {
throw ApiError.badRequest('请选择自己创建的文件夹');
}
export async function listFolders(user: TokenPayload) {
await assertPermission(user, 'courseware:write');
const rows = await prisma.coursewareFolder.findMany({
where: { ownerId: user.sub },
orderBy: { path: 'asc' },
@@ -184,6 +201,7 @@ export async function listFolders(user: TokenPayload) {
return rows.map((row) => ({ id: row.id, path: row.path }));
}
export async function createFolder(user: TokenPayload, folderPath: string) {
await assertPermission(user, 'courseware:write');
const row = await prisma.coursewareFolder.upsert({
where: { ownerId_path: { ownerId: user.sub, path: folderPath } },
create: { ownerId: user.sub, path: folderPath },
@@ -259,7 +277,7 @@ export async function acknowledgeTransfer(user: TokenPayload, pushId: string, de
const device = await deviceFor(user, deviceKey);
const push = await prisma.coursewarePush.findFirst({ where: { id: pushId, classId: device.classId } });
if (!push) throw ApiError.notFound('传输任务不存在');
await readable(user, push.coursewareId);
await readable(user, push.coursewareId, 'courseware:read');
await prisma.coursewareReceipt.upsert({
where: { pushId_deviceId: { pushId, deviceId: device.id } },
create: { pushId, deviceId: device.id },
@@ -1,7 +1,7 @@
import { Router } from 'express';
import { sendOk } from '../../lib/http.js';
import { resolveCurrentWeek, loadTermContext } from '../../lib/term.js';
import { authenticate, getAuthUser, requireStaffPermission } from '../../middleware/auth.js';
import { authenticate, getAuthUser, requirePermissionOrClassDevice } from '../../middleware/auth.js';
import { defineModule } from '../module.types.js';
import * as dashboardService from './dashboard.service.js';
@@ -9,7 +9,7 @@ const router = Router();
router.use(authenticate());
/** GET /api/dashboard/summary - 首页汇总(按角色权限收敛数据范围) */
router.get('/summary', requireStaffPermission('dashboard:read'), async (req, res) => {
router.get('/summary', requirePermissionOrClassDevice('dashboard:read'), async (req, res) => {
const user = getAuthUser(req);
sendOk(res, await dashboardService.getDashboardSummary(user), '获取仪表盘数据成功');
});
@@ -21,7 +21,7 @@ router.get('/summary', requireStaffPermission('dashboard:read'), async (req, res
* 这样周次选择器不会再出现"实际 20 周却给到 30 周"的情况;不带 classId 时退回全局默认值。
* 带 classId 时会先校验班级访问权限(见 resolveTermWeeks)。
*/
router.get('/term', requireStaffPermission('term:read'), async (req, res) => {
router.get('/term', requirePermissionOrClassDevice('term:read'), async (req, res) => {
const user = getAuthUser(req);
const classId = typeof req.query.classId === 'string' && req.query.classId ? req.query.classId : undefined;
const maxWeek = await dashboardService.resolveTermWeeks(user, classId);
@@ -1,9 +1,9 @@
import type { PermissionKey } from '@classhelper/shared';
import { classIdsFor, loadAuthPrincipal } from '../../lib/authz/index.js';
import { activeStudentWhere } from '../../lib/students.js';
import type { DashboardSummary, HomeworkDto, NotificationDto } from '@classhelper/shared';
import {
assertClassAccess,
classScopeIdFilter,
classScopeWhere,
isClassDevice,
resolveClassScope,
requireClassDeviceClassId,
@@ -25,7 +25,7 @@ const creatorSelect = { select: { id: true, name: true, username: true } } as co
* - 都不传:全局默认(getMaxTermWeeks 兜底)。
*/
export async function resolveTermWeeks(user: TokenPayload, classId?: string): Promise<number> {
if (classId) await assertClassAccess(user, classId);
if (classId) await assertClassAccess(user, classId, 'term:read');
const targetClassId = classId ?? (isClassDevice(user) ? requireClassDeviceClassId(user) : undefined);
if (!targetClassId) return getMaxTermWeeks();
const record = await prisma.class.findUnique({
@@ -42,10 +42,38 @@ export async function resolveTermWeeks(user: TokenPayload, classId?: string): Pr
export async function getDashboardSummary(user: TokenPayload): Promise<DashboardSummary> {
const device = isClassDevice(user);
// 班级端强制限定在自己班级,避免越权统计
const scope = await resolveClassScope(user, device ? requireClassDeviceClassId(user) : undefined);
const scope = await resolveClassScope(
user,
device ? requireClassDeviceClassId(user) : undefined,
'dashboard:read',
);
// 子表按 classId 过滤;Class 表本身按主键 id 过滤
const where = classScopeWhere(scope);
const classWhere = classScopeIdFilter(scope);
const allowed = scope.mode === 'in' ? scope.classIds : await classIdsFor(user, 'dashboard:read');
const auth = await loadAuthPrincipal(user);
async function segment(key: PermissionKey) {
const ids = device ? allowed : (await classIdsFor(user, key)).filter((id) => allowed.includes(id));
return { classId: { in: ids } };
}
const [classes, students, courses, schedules, homeworks, notifications, grades] = await Promise.all(
[
'class:read',
'roster:read',
'schedule:read',
'schedule:read',
'homework:read',
'notification:read',
'grade:read',
].map((key) => segment(key as PermissionKey)),
);
const sections = {
classes: device || auth.permissions.has('class:read'),
students: device || auth.permissions.has('roster:read'),
courses: device || auth.permissions.has('schedule:read'),
schedules: device || auth.permissions.has('schedule:read'),
homeworks: device || auth.permissions.has('homework:read'),
notifications: device || auth.permissions.has('notification:read'),
grades: device || auth.permissions.has('grade:read'),
};
// ClassHelper 班级端以全班学生为范围
const personalIds = await resolvePersonalIds(user);
const personalWhere = personalIdWhere(personalIds);
@@ -64,27 +92,27 @@ export async function getDashboardSummary(user: TokenPayload): Promise<Dashboard
recentHomeworks,
] = await Promise.all([
// 学生数只算在读的:已毕业/转出的学生不属于"本班现在有多少人"
prisma.class.count({ where: classWhere }),
prisma.student.count({ where: { ...where, ...activeStudentWhere(), archivedYearId: null } }),
prisma.course.count({ where }),
prisma.classPlan.count({ where }),
prisma.homework.count({ where }),
prisma.notification.count({ where }),
prisma.grade.count({ where: { ...where, student: activeStudentWhere() } }),
prisma.notification.count({ where: { ...where, reads: { none: personalWhere } } }),
prisma.class.count({ where: { id: classes.classId } }),
prisma.student.count({ where: { ...students, ...activeStudentWhere(), archivedYearId: null } }),
prisma.course.count({ where: courses }),
prisma.classPlan.count({ where: schedules }),
prisma.homework.count({ where: homeworks }),
prisma.notification.count({ where: notifications }),
prisma.grade.count({ where: { ...grades, student: activeStudentWhere() } }),
prisma.notification.count({ where: { ...notifications, reads: { none: personalWhere } } }),
device
? prisma.homework.count({
where: { ...where, statuses: { none: { ...personalWhere, completed: true } } },
where: { ...homeworks, statuses: { none: { ...personalWhere, completed: true } } },
})
: prisma.homework.count({ where }),
: prisma.homework.count({ where: homeworks }),
prisma.notification.findMany({
where,
where: notifications,
include: { creator: creatorSelect, reads: { where: personalWhere } },
orderBy: { createdAt: 'desc' },
take: 5,
}),
prisma.homework.findMany({
where,
where: homeworks,
include: {
course: courseSelect,
creator: creatorSelect,
@@ -96,6 +124,7 @@ export async function getDashboardSummary(user: TokenPayload): Promise<Dashboard
]);
return {
sections,
classCount,
studentCount,
courseCount,
@@ -1,8 +1,9 @@
import { requireAdmin } from '../../middleware/auth.js';
import fs from 'node:fs';
import path from 'node:path';
import { Router } from 'express';
import { sendCreated, sendOk } from '../../lib/http.js';
import { authenticate, requirePermission } from '../../middleware/auth.js';
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
import { validate, validatedBody, validatedParams } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import {
@@ -30,46 +31,52 @@ import * as databaseService from './database.service.js';
*/
const router = Router();
router.use(authenticate());
router.use(authenticate(), requireAdmin());
router.use(requirePermission('database:manage'));
/** GET /api/database/status - 当前数据库状态(连接/版本/大小/各表行数/备份与定时配置) */
router.get('/status', async (_req, res) => {
sendOk(res, await databaseService.getDatabaseStatus());
router.get('/status', async (req, res) => {
sendOk(res, await databaseService.forAdministrator(getAuthUser(req)).getDatabaseStatus());
});
/** POST /api/database/test-connection - 测试任意目标库连通性(切换前的前置检查) */
router.post('/test-connection', validate({ body: testConnectionSchema }), async (req, res) => {
const result = await databaseService.testConnection(validatedBody<TestConnectionInput>(req));
const result = await databaseService
.forAdministrator(getAuthUser(req))
.testConnection(validatedBody<TestConnectionInput>(req));
sendOk(res, result, result.ok ? '连接成功' : '连接失败');
});
/** GET /api/database/backups - 备份列表 */
router.get('/backups', (_req, res) => {
sendOk(res, databaseService.listBackups());
router.get('/backups', (req, res) => {
sendOk(res, databaseService.forAdministrator(getAuthUser(req)).listBackups());
});
/** POST /api/database/backups - 立即创建一份手动备份 */
router.post('/backups', async (_req, res) => {
sendCreated(res, await databaseService.createBackup('manual'), '备份已创建');
router.post('/backups', async (req, res) => {
sendCreated(
res,
await databaseService.forAdministrator(getAuthUser(req)).createBackup('manual'),
'备份已创建',
);
});
/** POST /api/database/backups/:name/restore - 从备份恢复(整库覆盖) */
router.post('/backups/:name/restore', validate({ params: backupNameParamSchema }), async (req, res) => {
const { name } = validatedParams<{ name: string }>(req);
sendOk(res, await databaseService.restoreBackup(name), '已从备份恢复');
sendOk(res, await databaseService.forAdministrator(getAuthUser(req)).restoreBackup(name), '已从备份恢复');
});
/** DELETE /api/database/backups/:name - 删除备份 */
router.delete('/backups/:name', validate({ params: backupNameParamSchema }), async (req, res) => {
const { name } = validatedParams<{ name: string }>(req);
databaseService.deleteBackup(name);
databaseService.forAdministrator(getAuthUser(req)).deleteBackup(name);
sendOk(res, { name }, '备份已删除');
});
/** GET /api/database/export - 下载当前库的 JSON 快照 */
router.get('/export', async (_req, res) => {
const json = await databaseService.exportSnapshotJson();
router.get('/export', async (req, res) => {
const json = await databaseService.forAdministrator(getAuthUser(req)).exportSnapshotJson();
const filename = `classhelper-snapshot-${new Date().toISOString().slice(0, 19).replaceAll(':', '')}.json`;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
@@ -77,8 +84,8 @@ router.get('/export', async (_req, res) => {
});
/** GET /api/database/sqlite-file - 下载数据库文件(仅 SQLite 模式) */
router.get('/sqlite-file', (_req, res) => {
const filePath = databaseService.sqliteFilePathForDownload();
router.get('/sqlite-file', (req, res) => {
const filePath = databaseService.forAdministrator(getAuthUser(req)).sqliteFilePathForDownload();
res.setHeader('Content-Type', 'application/octet-stream');
res.setHeader('Content-Disposition', `attachment; filename="${path.basename(filePath)}"`);
res.status(200).send(fs.readFileSync(filePath));
@@ -87,33 +94,41 @@ router.get('/sqlite-file', (_req, res) => {
/** POST /api/database/import - 导入快照(整库覆盖;base64 随 JSON 请求体) */
router.post('/import', validate({ body: importSnapshotSchema }), async (req, res) => {
const { data } = validatedBody<ImportSnapshotInput>(req);
sendOk(res, await databaseService.importSnapshotBase64(data), '快照已导入(原数据已被覆盖)');
sendOk(
res,
await databaseService.forAdministrator(getAuthUser(req)).importSnapshotBase64(data),
'快照已导入(原数据已被覆盖)',
);
});
/** GET /api/database/backup-schedule - 定时备份配置 */
router.get('/backup-schedule', (_req, res) => {
sendOk(res, databaseService.getBackupSchedule());
router.get('/backup-schedule', (req, res) => {
sendOk(res, databaseService.forAdministrator(getAuthUser(req)).getBackupSchedule());
});
/** PUT /api/database/backup-schedule - 更新定时备份配置(立即生效,重启后仍有效) */
router.put('/backup-schedule', validate({ body: backupScheduleSchema }), async (req, res) => {
sendOk(
res,
databaseService.saveSchedulePatch(validatedBody<BackupScheduleInput>(req)),
databaseService
.forAdministrator(getAuthUser(req))
.saveSchedulePatch(validatedBody<BackupScheduleInput>(req)),
'定时备份配置已保存',
);
});
/** POST /api/database/switch - 一键切换数据库(异步任务,返回 jobId) */
router.post('/switch', validate({ body: switchDatabaseSchema }), async (req, res) => {
const job = databaseService.startSwitch(validatedBody<SwitchDatabaseInput>(req));
const job = databaseService
.forAdministrator(getAuthUser(req))
.startSwitch(validatedBody<SwitchDatabaseInput>(req));
sendCreated(res, { jobId: job.id }, '切换任务已启动');
});
/** GET /api/database/switch/jobs/:id - 查询切换任务进度 */
router.get('/switch/jobs/:id', validate({ params: jobIdParamSchema }), async (req, res) => {
const { id } = validatedParams<{ id: string }>(req);
const job = databaseService.getSwitchJob(id);
const job = databaseService.forAdministrator(getAuthUser(req)).getSwitchJob(id);
sendOk(res, job, job ? 'ok' : '任务不存在');
});
@@ -1,3 +1,5 @@
import type { TokenPayload } from '../../lib/jwt.js';
import { assertAdmin } from '../../lib/authz/index.js';
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
@@ -795,3 +797,23 @@ async function runSwitch(job: SwitchJob, target: TargetConnectionInput): Promise
job.finishedAt = new Date().toISOString();
}
}
/** 请求入口必须携带 actor;后台调度只调用模块内部维护函数。 */
export function forAdministrator(actor: TokenPayload) {
assertAdmin(actor);
return {
getDatabaseStatus,
testConnection,
listBackups,
createBackup,
restoreBackup,
deleteBackup,
exportSnapshotJson,
importSnapshotBase64,
sqliteFilePathForDownload,
getBackupSchedule,
saveSchedulePatch,
startSwitch,
getSwitchJob,
};
}
@@ -1,11 +1,11 @@
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,
requireStaffPermission,
requirePermissionOrClassDevice,
} from '../../middleware/auth.js';
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
@@ -31,9 +31,10 @@ import * as gradeService from './grades.service.js';
const router = Router();
router.use(authenticate());
router.get('/overview', async (req, res) => {
router.get('/overview', requirePermissionOrClassDevice('grade:read'), async (req, res) => {
const user = getAuthUser(req);
const classId = user.classId ?? user.sub;
const classId = user.role === 'CLASS_DEVICE' ? (user.classId ?? user.sub) : String(req.query.classId ?? '');
if (!classId) throw ApiError.badRequest('请指定班级');
const cls = await (await import('../../lib/db.js')).prisma.class.findUnique({ where: { id: classId } });
sendOk(
res,
@@ -42,14 +43,19 @@ router.get('/overview', async (req, res) => {
: await gradeService.collectiveAnalysis(user, classId),
);
});
router.get('/collective', validate({ query: examListQuerySchema }), async (req, res) => {
const user = getAuthUser(req);
const { classId } = validatedQuery<{ classId: string }>(req);
sendOk(res, await gradeService.collectiveAnalysis(user, classId));
});
router.get(
'/collective',
requirePermissionOrClassDevice('grade:read'),
validate({ query: examListQuerySchema }),
async (req, res) => {
const user = getAuthUser(req);
const { classId } = validatedQuery<{ classId: string }>(req);
sendOk(res, await gradeService.collectiveAnalysis(user, classId));
},
);
/** GET /api/grades/my - ClassHelper 班级端查看本班成绩(教师/管理员返回空数组) */
router.get('/my', async (req, res) => {
router.get('/my', requirePermissionOrClassDevice('grade:read'), async (req, res) => {
const user = getAuthUser(req);
sendOk(res, await gradeService.listMyGrades(user), '获取本班成绩成功');
});
@@ -65,7 +71,7 @@ router.get('/my', async (req, res) => {
*/
router.get(
'/student/:studentNo',
requireStaffPermission('student:grade-query'),
requirePermissionOrClassDevice('student:grade-query'),
validate({ params: studentGradeParamSchema, query: studentGradeQuerySchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -93,11 +99,16 @@ router.get(
* **刻意不挂权限键**:ClassHelper 班级端要用它列出可选考试(班级端的有效权限是空集,
* 挂 `grade:read` 会把教室机器挡在门外)。边界在 service 里:`assertClassAccess` + 班级开关。
*/
router.get('/exams', validate({ query: examListQuerySchema }), async (req, res) => {
const user = getAuthUser(req);
const { classId } = validatedQuery<{ classId: string }>(req);
sendOk(res, await gradeService.listExams(user, classId), '获取考试列表成功');
});
router.get(
'/exams',
requirePermissionOrClassDevice('grade:read'),
validate({ query: examListQuerySchema }),
async (req, res) => {
const user = getAuthUser(req);
const { classId } = validatedQuery<{ classId: string }>(req);
sendOk(res, await gradeService.listExams(user, classId), '获取考试列表成功');
},
);
/** PUT /api/grades/exams - 设置"这次考试是否把所有科目相加做总分排名"(教师/管理员) */
router.put(
@@ -121,11 +132,16 @@ router.put(
* 同样不挂权限键(班级端要看),边界在 service 里 —— 与按学号查明细共用
* `canQueryStudentGrades`:班级端受按班开关控制,教师端不受影响。
*/
router.get('/ranking', validate({ query: examRankingQuerySchema }), async (req, res) => {
const user = getAuthUser(req);
const { classId, examName } = validatedQuery<{ classId: string; examName: string }>(req);
sendOk(res, await gradeService.getExamRanking(user, classId, examName), '获取考试排名成功');
});
router.get(
'/ranking',
requirePermissionOrClassDevice('grade:read'),
validate({ query: examRankingQuerySchema }),
async (req, res) => {
const user = getAuthUser(req);
const { classId, examName } = validatedQuery<{ classId: string; examName: string }>(req);
sendOk(res, await gradeService.getExamRanking(user, classId, examName), '获取考试排名成功');
},
);
/** GET /api/grades?classId=&courseId=&studentId=&examName= - 成绩列表(教师) */
router.get(
@@ -1,3 +1,5 @@
import { assertManage, assertPermissionOrClassDevice } from '../../lib/authz/index.js';
import { assertContentWrite } from '../../lib/authz/index.js';
import type { CollectiveGradeAnalysisDto as ContractCollectiveGradeAnalysisDto } from '@classhelper/shared';
import {
SOCKET_EVENTS,
@@ -14,7 +16,6 @@ import {
type StudentGradeDetailDto,
} from '@classhelper/shared';
import {
assertCanManageSubjectContent,
assertClassAccess,
classScopeWhere,
isClassDevice,
@@ -57,16 +58,10 @@ const classSelect = { select: { name: true } } as const;
const include = { course: courseSelect, student: studentSelect, class: classSelect } as const;
async function assertDetailQuery(user: TokenPayload, classId: string): Promise<void> {
await assertClassAccess(user, classId);
await assertClassAccess(user, classId, 'grade:read');
const cls = await prisma.class.findUniqueOrThrow({ where: { id: classId } });
if (
!canQueryStudentGrades({
classRole: await resolveUserClassRole(user, classId),
gradeQueryEnabled: cls.studentGradeQueryEnabled,
})
) {
if (isClassDevice(user) && !cls.studentGradeQueryEnabled)
throw ApiError.forbidden('本班只开放集体分析,未开放个人成绩');
}
}
/** 服务端聚合;不返回极值、个人标识或可由补集反推的稀疏分段。 */
@@ -74,7 +69,7 @@ export async function collectiveAnalysis(
user: TokenPayload,
classId: string,
): Promise<ContractCollectiveGradeAnalysisDto> {
await assertClassAccess(user, classId);
await assertClassAccess(user, classId, 'grade:read');
const rows = await prisma.grade.findMany({
where: { classId, student: activeStudentWhere() },
select: {
@@ -139,12 +134,17 @@ export interface ListGradeOptions {
* 教师/管理员调用时返回空数组(他们没有"自己的成绩"),请用 `listGrades`。
*/
export async function listMyGrades(user: TokenPayload): Promise<GradeDto[]> {
await assertPermissionOrClassDevice(user, 'grade:read');
if (isClassDevice(user)) await assertDetailQuery(user, user.classId ?? user.sub);
const studentIds = await resolvePersonalIds(user);
if (studentIds.length === 0) return [];
const grades = await prisma.grade.findMany({
where: { studentId: { in: studentIds }, student: activeStudentWhere() },
where: {
classId: user.classId ?? user.sub,
studentId: { in: studentIds },
student: activeStudentWhere(),
},
include,
orderBy: [{ publishedAt: 'desc' }, { examName: 'asc' }],
take: 1000,
@@ -154,7 +154,7 @@ export async function listMyGrades(user: TokenPayload): Promise<GradeDto[]> {
/** 教师 / 管理员查看成绩(按可访问班级过滤) */
export async function listGrades(user: TokenPayload, options: ListGradeOptions): Promise<GradeDto[]> {
const scope = await resolveClassScope(user, options.classId);
const scope = await resolveClassScope(user, options.classId, 'grade:read');
if (isClassDevice(user)) await assertDetailQuery(user, user.classId ?? user.sub);
const grades = await prisma.grade.findMany({
where: {
@@ -220,11 +220,11 @@ export async function getGradeStats(user: TokenPayload, options: ListGradeOption
/**
* 录入一条成绩。
*
* 权限走 `assertCanManageSubjectContent`:**科任老师只能录自己任教科目**的成绩,
* 权限走 ``:**科任老师只能录自己任教科目**的成绩,
* 班主任若不教这一科同样被拒;不指定科目(courseId 为空)时只有班主任/管理员可录。
*/
export async function createGrade(user: TokenPayload, input: CreateGradeInput): Promise<GradeDto> {
await assertCanManageSubjectContent(user, input.classId, input.courseId ?? null, '录入成绩');
await assertContentWrite(user, 'grade:write', input.classId, input.courseId ?? null, '录入成绩');
await assertStudentInClass(input.studentId, input.classId);
if (input.courseId) await assertCourseInClass(input.courseId, input.classId);
@@ -249,7 +249,7 @@ export async function createGrade(user: TokenPayload, input: CreateGradeInput):
/** 批量录入:同一个考试一次提交整班成绩 */
export async function bulkCreateGrades(user: TokenPayload, input: BulkCreateGradeInput): Promise<GradeDto[]> {
await assertCanManageSubjectContent(user, input.classId, input.courseId ?? null, '录入成绩');
await assertContentWrite(user, 'grade:write', input.classId, input.courseId ?? null, '录入成绩');
if (input.courseId) await assertCourseInClass(input.courseId, input.classId);
const publishedAt = parseOptionalDate(input.publishedAt) ?? new Date();
@@ -292,8 +292,11 @@ export async function updateGrade(
if (!current) throw ApiError.notFound('成绩记录不存在');
// 改的是哪条成绩,就按那条成绩所属的班级 + 科目判权限
await assertContentWrite(user, 'grade:write', current.classId, current.courseId, '修改成绩', {
allowClassDevice: false,
});
const nextCourseId = input.courseId === undefined ? current.courseId : (input.courseId ?? null);
await assertCanManageSubjectContent(user, current.classId, nextCourseId, '修改成绩');
await assertContentWrite(user, 'grade:write', current.classId, nextCourseId, '修改成绩');
if (nextCourseId) await assertCourseInClass(nextCourseId, current.classId);
const totalScore = input.totalScore ?? current.totalScore;
@@ -331,7 +334,7 @@ export async function updateGrade(
export async function deleteGrade(user: TokenPayload, gradeId: string): Promise<void> {
const current = await prisma.grade.findFirst({ where: { id: gradeId, student: activeStudentWhere() } });
if (!current) throw ApiError.notFound('成绩记录不存在');
await assertCanManageSubjectContent(user, current.classId, current.courseId, '删除成绩');
await assertContentWrite(user, 'grade:write', current.classId, current.courseId, '删除成绩');
await prisma.grade.delete({ where: { id: gradeId } });
await emitGradeUpdated(current.classId, {
@@ -369,7 +372,7 @@ export async function updateGradeLevels(
// 逐条判权限:可能跨多个班级/科目(管理员在成绩页多选修改时会这样)
for (const item of input.items) {
const record = byId.get(item.id)!;
await assertCanManageSubjectContent(user, record.classId, record.courseId, '修改成绩等级');
await assertContentWrite(user, 'grade:write', record.classId, record.courseId, '修改成绩等级');
}
await prisma.$transaction(
@@ -387,7 +390,7 @@ export async function updateGradeLevels(
// 整批重算
const classId = input.classId!;
await assertCanManageSubjectContent(user, classId, input.courseId ?? null, '调整成绩等级');
await assertContentWrite(user, 'grade:write', classId, input.courseId ?? null, '调整成绩等级');
const levelType = input.levelType!;
if (levelType === 'custom') throw ApiError.badRequest('自定义等级不能由分数推算,请逐条填写等级');
const records = await prisma.grade.findMany({
@@ -433,7 +436,7 @@ export async function getStudentGradeDetail(
requestedClassId?: string,
): Promise<StudentGradeDetailDto> {
if (isClassDevice(user)) await assertDetailQuery(user, user.classId ?? user.sub);
const scope = await resolveClassScope(user, requestedClassId);
const scope = await resolveClassScope(user, requestedClassId, 'student:grade-query');
const student = await prisma.student.findFirst({
where: { ...activeStudentWhere(), studentNo: normalizeStudentNo(studentNo), ...classScopeWhere(scope) },
@@ -458,7 +461,7 @@ export async function getStudentGradeDetail(
}
const grades = await prisma.grade.findMany({
where: { studentId: student.id },
where: { studentId: student.id, ...classScopeWhere(scope) },
include,
orderBy: [{ publishedAt: 'desc' }, { examName: 'asc' }],
take: 500,
@@ -488,7 +491,7 @@ export async function getStudentGradeDetailById(
});
if (!student || ('status' in student && !isActiveStudent(student as { status: string })))
throw ApiError.notFound('学生不存在');
await assertClassAccess(user, student.classId ?? '');
await assertClassAccess(user, student.classId ?? '', 'student:grade-query');
return getStudentGradeDetail(user, student.studentNo);
}
@@ -579,7 +582,8 @@ async function assertCourseInClass(courseId: string, classId: string): Promise<v
* 只列 `Exam` 会让刚导进去的那次考试在界面上根本找不到。
*/
export async function listExams(user: TokenPayload, classId: string): Promise<ExamDto[]> {
await assertClassAccess(user, classId);
if (isClassDevice(user)) await assertDetailQuery(user, classId);
await assertClassAccess(user, classId, 'grade:read');
const [groups, exams] = await Promise.all([
prisma.grade.groupBy({
by: ['examName'],
@@ -619,7 +623,7 @@ export async function setExamCountInTotal(
name: string,
countInTotal: boolean,
): Promise<ExamDto> {
await assertClassAccess(user, classId);
await assertManage(user, 'grade:write', classId);
if (!isStaff(user)) throw ApiError.forbidden('只有教师/管理员可以设置考试口径');
const row = await prisma.exam.upsert({
@@ -666,7 +670,7 @@ export async function getExamRanking(
classId: string,
examName: string,
): Promise<ExamRankingDto> {
await assertClassAccess(user, classId);
await assertClassAccess(user, classId, 'grade:read');
const classRecord = await prisma.class.findUnique({
where: { id: classId },
select: { name: true, studentGradeQueryEnabled: true },
@@ -4,7 +4,7 @@ import { idParamSchema } from '../../lib/schemas.js';
import {
authenticate,
getAuthUser,
requireStaffPermission,
requirePermissionOrClassDevice,
requirePermission,
} from '../../middleware/auth.js';
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
@@ -32,7 +32,7 @@ router.use(authenticate());
/** GET /api/homeworks?classId=&courseId=&pendingOnly=&keyword= - 作业列表 */
router.get(
'/',
requireStaffPermission('homework:read'),
requirePermissionOrClassDevice('homework:read'),
validate({ query: listHomeworksQuerySchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -54,7 +54,7 @@ router.get(
*/
router.get(
'/days',
requireStaffPermission('homework:read'),
requirePermissionOrClassDevice('homework:read'),
validate({ query: listHomeworkDaysQuerySchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -65,7 +65,7 @@ router.get(
router.get(
'/:id',
requireStaffPermission('homework:read'),
requirePermissionOrClassDevice('homework:read'),
validate({ params: idParamSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -82,7 +82,7 @@ router.get(
*/
router.post(
'/',
requireStaffPermission('homework:write'),
requirePermissionOrClassDevice('homework:write'),
validate({ body: createHomeworkSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -99,7 +99,7 @@ router.post(
*
* 与 POST / 的区别:批量发布是 Web 管理端的"多班同发"工作流,因此这里挂 `homework:write`;
* 教室机器的班级账号仍走单班录入(POST /,权限在服务层判,见 AGENTS.md 的现行工程约定)。
* 每个班的科目归属依然由 createHomework 里的 assertCanManageSubjectContent 逐个判定 ——
* 每个班的科目归属依然由 createHomework 里的 逐个判定 ——
* 批量不是绕过科目门的后门(§5 第 5 条)。
*/
router.post(
@@ -145,7 +145,7 @@ router.delete(
/** PATCH /api/homeworks/:id/status - 标记完成 / 取消完成 */
router.patch(
'/:id/status',
requireStaffPermission('homework:write'),
requirePermissionOrClassDevice('homework:write'),
validate({ params: idParamSchema, body: updateHomeworkStatusSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -162,7 +162,7 @@ router.patch(
/** GET /api/homeworks/:id/submissions - 提交名单(未交名单功能) */
router.get(
'/:id/submissions',
requireStaffPermission('homework:read'),
requirePermissionOrClassDevice('homework:read'),
validate({ params: idParamSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -175,7 +175,7 @@ router.get(
/** PATCH /api/homeworks/:id/submissions - 勾选未交名单(其余学生一律视为已交) */
router.patch(
'/:id/submissions',
requireStaffPermission('homework:write'),
requirePermissionOrClassDevice('homework:write'),
validate({ params: idParamSchema, body: updateHomeworkSubmissionsSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -1,4 +1,5 @@
import { assertStaffPermission } from '../../lib/permissions.js';
import { assertContentWrite } from '../../lib/authz/index.js';
import { assertPermissionOrClassDevice } from '../../lib/permissions.js';
import {
SOCKET_EVENTS,
dayKeyLocal,
@@ -12,13 +13,7 @@ import {
type HomeworkSubmissionDto,
type HomeworkSubmissionsDto,
} from '@classhelper/shared';
import {
assertCanManageSubjectContent,
assertClassAccess,
classScopeWhere,
isClassDevice,
resolveClassScope,
} from '../../lib/access.js';
import { assertClassAccess, classScopeWhere, isClassDevice, resolveClassScope } from '../../lib/access.js';
import { normalizeBulkClassIds, summarizeBulk, toBulkFailure } from '../../lib/bulk.js';
import { activeStudentWhere, isActiveStudent } from '../../lib/students.js';
import { prisma } from '../../lib/db.js';
@@ -56,8 +51,8 @@ export async function listHomeworks(
user: TokenPayload,
options: ListHomeworkOptions,
): Promise<HomeworkDto[]> {
await assertStaffPermission(user, 'homework:read');
const scope = await resolveClassScope(user, options.classId);
await assertPermissionOrClassDevice(user, 'homework:read');
const scope = await resolveClassScope(user, options.classId, 'homework:read');
const device = isClassDevice(user);
// ClassHelper 班级端以全班学生为范围;教师/管理员没有"自己的完成状态"
const personalIds = device ? await resolvePersonalIds(user) : [];
@@ -97,8 +92,8 @@ 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);
await assertPermissionOrClassDevice(user, 'homework:read');
const scope = await resolveClassScope(user, options.classId, 'homework:read');
const today = dayKeyLocal(new Date());
const to = options.to ?? today;
const from = options.from ?? shiftDayKey(to, -(options.days ?? 60) + 1);
@@ -122,7 +117,7 @@ export async function listHomeworkDays(
}
export async function getHomework(user: TokenPayload, homeworkId: string): Promise<HomeworkDto> {
await assertStaffPermission(user, 'homework:read');
await assertPermissionOrClassDevice(user, 'homework:read');
const device = isClassDevice(user);
const personalIds = device ? await resolvePersonalIds(user) : [];
const homework = await prisma.homework.findUnique({
@@ -136,7 +131,7 @@ export async function getHomework(user: TokenPayload, homeworkId: string): Promi
},
});
if (!homework) throw ApiError.notFound('作业不存在');
await assertClassAccess(user, homework.classId);
await assertClassAccess(user, homework.classId, 'homework:read');
return toHomeworkDto(homework, {
studentIds: device ? personalIds : [],
@@ -149,15 +144,15 @@ export async function getHomework(user: TokenPayload, homeworkId: string): Promi
/**
* 发布作业并实时推送到班级房间。
*
* 权限走 `assertCanManageSubjectContent`(`allowClassDevice: true`):
* 权限走 ``(`allowClassDevice: true`):
* - **选了科目** → 必须是该科的任课老师(班主任不教这一科也发不了);
* - **没选科目** → 本班班主任或管理员;
* - **ClassHelper 班级端** → 可以录(老师在教室电脑上顺手记一条是常见需求,
* 见 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, '布置作业', {
await assertPermissionOrClassDevice(user, 'homework:write');
await assertContentWrite(user, 'homework:write', input.classId, input.courseId ?? null, '布置作业', {
allowClassDevice: true,
});
if (input.courseId) await assertCourseInClass(input.courseId, input.classId);
@@ -212,7 +207,7 @@ export async function bulkCreateHomeworks(
user: TokenPayload,
input: BulkCreateHomeworkRequest,
): Promise<BulkPublishResult> {
await assertStaffPermission(user, 'homework:write');
await assertPermissionOrClassDevice(user, 'homework:write');
const classIds = normalizeBulkClassIds(input.classIds);
const results: BulkPublishClassResult[] = [];
@@ -261,13 +256,16 @@ export async function updateHomework(
homeworkId: string,
input: UpdateHomeworkInput,
): Promise<HomeworkDto> {
await assertStaffPermission(user, 'homework:write');
await assertPermissionOrClassDevice(user, 'homework:write');
const current = await prisma.homework.findUnique({ where: { id: homeworkId } });
if (!current) throw ApiError.notFound('作业不存在');
// 改科目等于把这条作业挪到另一个科目的权限域下,两端都要判
await assertContentWrite(user, 'homework:write', current.classId, current.courseId, '修改作业', {
allowClassDevice: true,
});
const nextCourseId = input.courseId === undefined ? current.courseId : (input.courseId ?? null);
await assertCanManageSubjectContent(user, current.classId, nextCourseId, '修改作业', {
await assertContentWrite(user, 'homework:write', current.classId, nextCourseId, '修改作业', {
allowClassDevice: true,
});
if (nextCourseId) await assertCourseInClass(nextCourseId, current.classId);
@@ -294,13 +292,13 @@ export async function updateHomework(
}
export async function deleteHomework(user: TokenPayload, homeworkId: string): Promise<void> {
await assertStaffPermission(user, 'homework:write');
await assertPermissionOrClassDevice(user, 'homework:write');
const current = await prisma.homework.findUnique({
where: { id: homeworkId },
include: { course: courseSelect, creator: creatorSelect },
});
if (!current) throw ApiError.notFound('作业不存在');
await assertCanManageSubjectContent(user, current.classId, current.courseId, '删除作业', {
await assertContentWrite(user, 'homework:write', current.classId, current.courseId, '删除作业', {
allowClassDevice: true,
});
@@ -323,11 +321,13 @@ export async function updateHomeworkStatus(
homeworkId: string,
input: UpdateHomeworkStatusInput,
): Promise<HomeworkStatusDto> {
await assertStaffPermission(user, 'homework:write');
await assertPermissionOrClassDevice(user, 'homework:write');
const homework = await prisma.homework.findUnique({ where: { id: homeworkId } });
if (!homework) throw ApiError.notFound('作业不存在');
await assertClassAccess(user, homework.classId);
await assertContentWrite(user, 'homework:write', homework.classId, homework.courseId, '维护作业提交名单', {
allowClassDevice: true,
});
if (isClassDevice(user)) {
const studentIds = await resolvePersonalIds(user);
@@ -417,13 +417,15 @@ export async function listHomeworkSubmissions(
user: TokenPayload,
homeworkId: string,
): Promise<HomeworkSubmissionsDto> {
await assertStaffPermission(user, 'homework:read');
await assertPermissionOrClassDevice(user, 'homework:write');
const homework = await prisma.homework.findUnique({
where: { id: homeworkId },
select: { id: true, classId: true },
select: { id: true, classId: true, courseId: true },
});
if (!homework) throw ApiError.notFound('作业不存在');
await assertClassAccess(user, homework.classId);
await assertContentWrite(user, 'homework:write', homework.classId, homework.courseId, '维护作业提交名单', {
allowClassDevice: true,
});
assertCanManageSubmissions(user);
return buildSubmissions(homework.classId, homeworkId);
}
@@ -437,13 +439,15 @@ export async function updateHomeworkSubmissions(
homeworkId: string,
input: UpdateHomeworkSubmissionsInput,
): Promise<HomeworkSubmissionsDto> {
await assertStaffPermission(user, 'homework:write');
await assertPermissionOrClassDevice(user, 'homework:write');
const homework = await prisma.homework.findUnique({
where: { id: homeworkId },
select: { id: true, classId: true },
select: { id: true, classId: true, courseId: true },
});
if (!homework) throw ApiError.notFound('作业不存在');
await assertClassAccess(user, homework.classId);
await assertContentWrite(user, 'homework:write', homework.classId, homework.courseId, '维护作业提交名单', {
allowClassDevice: true,
});
assertCanManageSubmissions(user);
const students = await prisma.student.findMany({
@@ -6,7 +6,7 @@ import { idParamSchema } from '../../lib/schemas.js';
import {
authenticate,
getAuthUser,
requireStaffPermission,
requirePermissionOrClassDevice,
requirePermission,
requireAnyPermission,
} from '../../middleware/auth.js';
@@ -115,7 +115,7 @@ router.post(
const input = validatedBody<TableCommitInput>(req);
await assertImportKindAllowed(input.kind, getAuthUser(req));
// 教师名单与班级无关;成绩/学生名单必须带班级,否则拒绝(避免写到"全局")
if (input.kind !== 'teachers' && input.kind !== 'classTeachers' && !input.classId) {
if (input.kind === 'grades' && !input.classId) {
throw ApiError.badRequest('请先选择要导入的班级');
}
const result = await commitTable(getAuthUser(req), input);
@@ -147,7 +147,7 @@ router.post(
/** GET /api/imports/time-layout?classId= - 查看该班已导入的时间配置 */
router.get(
'/time-layout',
requireStaffPermission('schedule:read'),
requirePermissionOrClassDevice('schedule:read'),
validate({ query: timeLayoutQuerySchema }),
async (req, res) => {
const { classId } = validatedQuery<{ classId: string }>(req);
@@ -161,7 +161,7 @@ router.get(
*/
router.post(
'/time-layout',
requirePermission('timetable:manage', 'import:schedule'),
requirePermission('schedule:write', 'import:schedule'),
validate({ body: timeLayoutImportSchema }),
async (req, res) => {
const result = await importTimeLayout(getAuthUser(req), validatedBody<TimeLayoutImportInput>(req));
@@ -178,7 +178,7 @@ router.post(
/** DELETE /api/imports/time-layout/:id - 删除某份时间配置 */
router.delete(
'/time-layout/:id',
requirePermission('timetable:manage'),
requirePermission('import:schedule', 'schedule:write'),
validate({ params: idParamSchema }),
async (req, res) => {
const { id } = validatedParams<{ id: string }>(req);
@@ -1,7 +1,8 @@
import { refreshClassSubscriptions } from '../../realtime/bus.js';
import { assertAdmin, assertManage, assertContentWrite } from '../../lib/authz/index.js';
import { changeStudentLifecycle, notifyStudentClasses } from '../../lib/student-lifecycle.js';
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';
@@ -11,8 +12,6 @@ import { ApiError } from '../../lib/http.js';
import type { TokenPayload } from '../../lib/jwt.js';
import { logger } from '../../lib/logger.js';
import { hashPassword } from '../../lib/password.js';
import { resolveUserClassRole } from '../../lib/access.js';
import { canImport, canManageSubjectContent } from '@classhelper/shared';
import type { TableCommitInput, TableFileInput } from './imports.schemas.js';
/**
@@ -362,7 +361,6 @@ export async function commitGrades(user: TokenPayload, input: TableCommitInput):
* 不属于那门课的记录(且覆盖语义的 append/overwrite 会把它写进同一张表)。
* 判定沿用 shared 的 canManageSubjectContent,与 Web 端单条录入、作业发布**同一份规则**。
*/
const classRole = await resolveUserClassRole(user, classId);
const errors: ImportRowError[] = [];
const warnings: string[] = [];
@@ -425,21 +423,7 @@ export async function commitGrades(user: TokenPayload, input: TableCommitInput):
warnings.push(`第 ${rowNumber} 行:科目「${courseName}」不存在,已按"不指定科目"导入`);
// 该科目的成绩能不能由**当前这个人**写(管理员全通;挂了科目就只能是该科任课老师)
const canWriteCourse = canManageSubjectContent({
classRole,
hasSubject: Boolean(course),
isSubjectTeacherOfCourse: course ? course.teacherId === user.sub : false,
allowClassDevice: false,
});
if (!canWriteCourse) {
errors.push({
row: rowNumber,
message: course
? `「${course.name}」不是你任教的科目,不能导入该科目的成绩`
: '不指定科目的成绩只有班主任或管理员可以导入,请填写科目列后再导入',
});
continue;
}
await assertContentWrite(user, 'grade:import', classId, course?.id ?? null, '导入成绩');
// 表格里带等级列就按"自定义等级"落库(教师明确给了等级),否则按百分制换算
const levelType = levelText ? 'custom' : 'percent';
@@ -521,6 +505,15 @@ export async function commitStudents(user: TokenPayload, input: TableCommitInput
else classByName.set(key, item);
}
const operations: {
classId: string;
studentNo: string;
name: string;
gender: string;
guardianPhone: string;
existingId: string | null;
sourceClassId: string | null;
}[] = [];
const errors: ImportRowError[] = [];
const warnings: string[] = [];
let inserted = 0;
@@ -580,19 +573,68 @@ export async function commitStudents(user: TokenPayload, input: TableCommitInput
skipped += 1;
continue;
}
if (existing.classId !== classId)
await addStudent(user, classId, { studentNo, name, gender, guardianPhone });
await updateStudent(user, existing.id, {
if (existing.classId !== classId) await assertCanManageRoster(user, existing.classId ?? undefined);
operations.push({
classId,
studentNo,
name,
...(gender ? { gender } : {}),
...(guardianPhone ? { guardianPhone } : {}),
gender,
guardianPhone,
existingId: existing.id,
sourceClassId: existing.classId,
});
updated += 1;
} else {
await addStudent(user, classId, { studentNo, name, gender, guardianPhone });
operations.push({
classId,
studentNo,
name,
gender,
guardianPhone,
existingId: null,
sourceClassId: null,
});
inserted += 1;
}
}
await prisma.$transaction(
async (tx) => {
for (const op of operations) {
if (!op.existingId || op.sourceClassId !== op.classId)
await changeStudentLifecycle(
user,
{
studentIds: op.existingId ? [op.existingId] : [],
kind: 'class-change',
toClassId: op.classId,
note: '学生名单导入',
...(!op.existingId
? {
newStudent: {
studentNo: op.studentNo,
name: op.name,
gender: op.gender,
guardianPhone: op.guardianPhone,
},
}
: {}),
},
tx,
);
if (op.existingId)
await tx.student.update({
where: { id: op.existingId },
data: {
name: op.name,
...(op.gender ? { gender: op.gender } : {}),
...(op.guardianPhone ? { guardianPhone: op.guardianPhone } : {}),
},
});
}
},
{ timeout: 30000 },
);
notifyStudentClasses(operations.flatMap((op) => [op.sourceClassId, op.classId]));
const result: ImportResult = {
kind: 'students',
@@ -618,73 +660,94 @@ export async function commitStudents(user: TokenPayload, input: TableCommitInput
*/
export async function commitTeachers(user: TokenPayload, input: TableCommitInput): Promise<ImportResult> {
await assertImportAllowed(user, undefined, 'teachers');
const buffer = decodeBase64(input.contentBase64);
const parsed = parseTable(buffer, input.fileName);
assertAdmin(user);
return prisma.$transaction(
async (tx) => {
const buffer = decodeBase64(input.contentBase64);
const parsed = parseTable(buffer, input.fileName);
const errors: ImportRowError[] = [];
const warnings: string[] = [];
let inserted = 0;
let updated = 0;
let skipped = 0;
const errors: ImportRowError[] = [];
const warnings: string[] = [];
let inserted = 0;
let updated = 0;
let skipped = 0;
for (const [index, row] of parsed.rows.entries()) {
const rowNumber = index + 2;
const username = cellValue(row, parsed.columns, input.mapping, 'username');
const name = cellValue(row, parsed.columns, input.mapping, 'name');
const phone = cellValue(row, parsed.columns, input.mapping, 'phone');
const password = cellValue(row, parsed.columns, input.mapping, 'password');
const roleCell = cellValue(row, parsed.columns, input.mapping, 'role');
for (const [index, row] of parsed.rows.entries()) {
const rowNumber = index + 2;
const username = cellValue(row, parsed.columns, input.mapping, 'username');
const name = cellValue(row, parsed.columns, input.mapping, 'name');
const phone = cellValue(row, parsed.columns, input.mapping, 'phone');
const password = cellValue(row, parsed.columns, input.mapping, 'password');
const roleCell = cellValue(row, parsed.columns, input.mapping, 'role');
if (!username || !name) {
errors.push({ row: rowNumber, message: '工号与姓名都必须填写' });
continue;
}
if (!/^[A-Za-z0-9_.-]{3,32}$/.test(username)) {
errors.push({ row: rowNumber, message: `工号「${username}」格式不合法(3~32 位字母/数字/._-)` });
continue;
}
if (!username || !name) {
errors.push({ row: rowNumber, message: '工号与姓名都必须填写' });
continue;
}
if (!/^[A-Za-z0-9_.-]{3,32}$/.test(username)) {
errors.push({ row: rowNumber, message: `工号「${username}」格式不合法(3~32 位字母/数字/._-)` });
continue;
}
const existing = await prisma.user.findUnique({ where: { username }, select: { id: true, role: true } });
if (existing) {
if (input.mode === 'append') {
skipped += 1;
continue;
const existing = await tx.user.findUnique({ where: { username }, select: { id: true, role: true } });
if (existing?.role === 'ADMIN') {
errors.push({ row: rowNumber, message: '管理员账号请通过教师账号管理修改' });
continue;
}
if (existing) {
if (input.mode === 'append') {
skipped += 1;
continue;
}
const nextRole = parseTeacherRole(roleCell) ?? existing.role;
await tx.user.update({
where: { id: existing.id },
data: { name, role: nextRole, ...(phone ? { phone } : {}) },
});
updated += 1;
} else {
const initialPassword = password || env.defaultTeacherPassword;
await tx.user.create({
data: {
username,
name,
phone,
role: parseTeacherRole(roleCell) ?? 'TEACHER',
passwordHash: await hashPassword(initialPassword),
},
});
inserted += 1;
}
}
const nextRole = parseTeacherRole(roleCell) ?? existing.role;
await prisma.user.update({
where: { id: existing.id },
data: { name, role: nextRole, ...(phone ? { phone } : {}) },
});
updated += 1;
} else {
const initialPassword = password || env.defaultTeacherPassword;
await prisma.user.create({
const result: ImportResult = {
kind: 'teachers',
total: parsed.rows.length,
inserted,
updated,
skipped,
failed: errors.length,
errors,
warnings,
};
logger.info(
`教师名单导入:模式=${input.mode} 新增=${inserted} 更新=${updated} 跳过=${skipped} 失败=${errors.length}`,
);
await tx.permissionAudit.create({
data: {
username,
name,
phone,
role: parseTeacherRole(roleCell) ?? 'TEACHER',
passwordHash: await hashPassword(initialPassword),
actorId: user.sub,
actorName: user.name,
action: 'import-commitTeachers',
targetType: 'import',
targetId: '',
beforeJson: 'null',
afterJson: JSON.stringify(result),
},
});
inserted += 1;
}
}
const result: ImportResult = {
kind: 'teachers',
total: parsed.rows.length,
inserted,
updated,
skipped,
failed: errors.length,
errors,
warnings,
};
logger.info(
`教师名单导入:模式=${input.mode} 新增=${inserted} 更新=${updated} 跳过=${skipped} 失败=${errors.length}`,
return result;
},
{ timeout: 30000 },
);
return result;
}
/* ------------------------------------------------------------------ 班级任课老师 */
@@ -705,160 +768,184 @@ export async function commitClassTeachers(
input: TableCommitInput,
): Promise<ImportResult> {
await assertImportAllowed(user, undefined, 'classTeachers');
const buffer = decodeBase64(input.contentBase64);
const parsed = parseTable(buffer, input.fileName);
assertAdmin(user);
return prisma
.$transaction(
async (tx) => {
const buffer = decodeBase64(input.contentBase64);
const parsed = parseTable(buffer, input.fileName);
const useTeacherNo = input.useTeacherNo !== false; // 默认开:有工号就优先按工号匹配
const useTeacherNo = input.useTeacherNo !== false; // 默认开:有工号就优先按工号匹配
const errors: ImportRowError[] = [];
const warnings: string[] = [];
let inserted = 0;
let updated = 0;
let skipped = 0;
const errors: ImportRowError[] = [];
const warnings: string[] = [];
let inserted = 0;
let updated = 0;
let skipped = 0;
const classes = await prisma.class.findMany({
where: { archivedYearId: null },
select: { id: true, name: true, teacherId: true },
});
const classByName = new Map<string, { id: string; name: string; teacherId: string | null }>();
for (const item of classes) {
const key = item.name.toLowerCase();
if (classByName.has(key)) classByName.delete(key);
else classByName.set(key, item);
}
// 本次导入新建的教师账号(同一份表格里再遇到同名/同工号时复用,避免重复建号)
const createdTeachers = new Map<string, string>();
for (const [index, row] of parsed.rows.entries()) {
const rowNumber = index + 2;
const className = cellValue(row, parsed.columns, input.mapping, 'className');
const subjectName = cellValue(row, parsed.columns, input.mapping, 'subjectName');
const teacherNo = cellValue(row, parsed.columns, input.mapping, 'teacherNo');
const teacherName = cellValue(row, parsed.columns, input.mapping, 'teacherName');
const roleCell = cellValue(row, parsed.columns, input.mapping, 'role');
if (!className || !teacherNo) {
errors.push({ row: rowNumber, message: '班级与教师工号都必须填写' });
continue;
}
const targetClass = classByName.get(className.toLowerCase());
if (!targetClass) {
errors.push({ row: rowNumber, message: `找不到班级「${className}」(可能已毕业归档或名称重复)` });
continue;
}
await assertImportAllowed(user, targetClass.id, 'classTeachers');
const role = parseClassTeacherRole(roleCell);
if (!role) {
errors.push({ row: rowNumber, message: `角色「${roleCell}」无法识别,请填「班主任」或「科任」` });
continue;
}
if (role === 'SUBJECT' && !subjectName) {
errors.push({ row: rowNumber, message: '科任老师必须填写科目' });
continue;
}
// ---- 匹配或新建教师账号
let teacherId = createdTeachers.get(teacherNo.toLowerCase());
if (!teacherId) {
if (useTeacherNo) {
const byNo = await prisma.user.findUnique({
where: { username: teacherNo },
select: { id: true },
const classes = await tx.class.findMany({
where: { archivedYearId: null },
select: { id: true, name: true, teacherId: true },
});
if (byNo) {
teacherId = byNo.id;
const classByName = new Map<string, { id: string; name: string; teacherId: string | null }>();
for (const item of classes) {
const key = item.name.toLowerCase();
if (classByName.has(key)) classByName.delete(key);
else classByName.set(key, item);
}
} else if (teacherName) {
const byName = await prisma.user.findMany({
where: { name: teacherName, role: { in: ['TEACHER', 'ADMIN'] } },
select: { id: true },
take: 2,
});
if (byName.length === 1) {
teacherId = byName[0]!.id;
} else if (byName.length > 1) {
errors.push({
row: rowNumber,
message: `姓名「${teacherName}」对应多个教师账号,无法确定是哪一个;请打开「使用工号匹配」再导入`,
});
continue;
// 本次导入新建的教师账号(同一份表格里再遇到同名/同工号时复用,避免重复建号)
const createdTeachers = new Map<string, string>();
for (const [index, row] of parsed.rows.entries()) {
const rowNumber = index + 2;
const className = cellValue(row, parsed.columns, input.mapping, 'className');
const subjectName = cellValue(row, parsed.columns, input.mapping, 'subjectName');
const teacherNo = cellValue(row, parsed.columns, input.mapping, 'teacherNo');
const teacherName = cellValue(row, parsed.columns, input.mapping, 'teacherName');
const roleCell = cellValue(row, parsed.columns, input.mapping, 'role');
if (!className || !teacherNo) {
errors.push({ row: rowNumber, message: '班级与教师工号都必须填写' });
continue;
}
const targetClass = classByName.get(className.toLowerCase());
if (!targetClass) {
errors.push({
row: rowNumber,
message: `找不到班级「${className}」(可能已毕业归档或名称重复)`,
});
continue;
}
const role = parseClassTeacherRole(roleCell);
if (!role) {
errors.push({ row: rowNumber, message: `角色「${roleCell}」无法识别,请填「班主任」或「科任」` });
continue;
}
if (role === 'SUBJECT' && !subjectName) {
errors.push({ row: rowNumber, message: '科任老师必须填写科目' });
continue;
}
// ---- 匹配或新建教师账号
let teacherId = createdTeachers.get(teacherNo.toLowerCase());
if (!teacherId) {
if (useTeacherNo) {
const byNo = await tx.user.findUnique({
where: { username: teacherNo },
select: { id: true },
});
if (byNo) {
teacherId = byNo.id;
}
} else if (teacherName) {
const byName = await tx.user.findMany({
where: { name: teacherName, role: { in: ['TEACHER', 'ADMIN'] } },
select: { id: true },
take: 2,
});
if (byName.length === 1) {
teacherId = byName[0]!.id;
} else if (byName.length > 1) {
errors.push({
row: rowNumber,
message: `姓名「${teacherName}」对应多个教师账号,无法确定是哪一个;请打开「使用工号匹配」再导入`,
});
continue;
}
}
}
if (!teacherId) {
if (!useTeacherNo) {
errors.push({
row: rowNumber,
message: `按姓名找不到教师「${teacherName || teacherNo}」;请打开「使用工号匹配」以便按工号新建账号`,
});
continue;
}
await assertPermission(user, 'teacher:manage');
await assertPermission(user, 'import:teachers');
const created = await tx.user.create({
data: {
username: teacherNo,
name: teacherName || teacherNo,
role: 'TEACHER',
passwordHash: await hashPassword(env.defaultTeacherPassword),
},
select: { id: true },
});
teacherId = created.id;
inserted += 1;
warnings.push(
`第 ${rowNumber} 行:工号 ${teacherNo} 没有对应账号,已新建教师「${teacherName || teacherNo}」(初始密码见部署配置)`,
);
}
createdTeachers.set(teacherNo.toLowerCase(), teacherId);
// ---- 写入角色
if (role === 'HEAD') {
if (targetClass.teacherId === teacherId) {
skipped += 1;
continue;
}
await tx.class.update({ where: { id: targetClass.id }, data: { teacherId } });
targetClass.teacherId = teacherId;
updated += 1;
} else {
const existing = await tx.course.findUnique({
where: { classId_name: { classId: targetClass.id, name: subjectName } },
select: { id: true, teacherId: true },
});
if (existing) {
if (existing.teacherId === teacherId) {
skipped += 1;
continue;
}
await tx.course.update({ where: { id: existing.id }, data: { teacherId } });
} else {
await tx.course.create({
data: { name: subjectName, classId: targetClass.id, teacherId },
});
}
updated += 1;
}
}
}
}
if (!teacherId) {
if (!useTeacherNo) {
errors.push({
row: rowNumber,
message: `按姓名找不到教师「${teacherName || teacherNo}」;请打开「使用工号匹配」以便按工号新建账号`,
const result: ImportResult = {
kind: 'classTeachers',
total: parsed.rows.length,
inserted,
updated,
skipped,
failed: errors.length,
errors,
warnings,
};
logger.info(
`班级任课老师导入:用工号匹配=${useTeacherNo} 新建教师=${inserted} 更新关系=${updated} 跳过=${skipped} 失败=${errors.length}`,
);
await tx.permissionAudit.create({
data: {
actorId: user.sub,
actorName: user.name,
action: 'import-commitClassTeachers',
targetType: 'import',
targetId: '',
beforeJson: 'null',
afterJson: JSON.stringify(result),
},
});
continue;
}
await assertPermission(user, 'teacher:manage');
await assertPermission(user, 'import:teachers');
const created = await prisma.user.create({
data: {
username: teacherNo,
name: teacherName || teacherNo,
role: 'TEACHER',
passwordHash: await hashPassword(env.defaultTeacherPassword),
},
select: { id: true },
});
teacherId = created.id;
inserted += 1;
warnings.push(
`第 ${rowNumber} 行:工号 ${teacherNo} 没有对应账号,已新建教师「${teacherName || teacherNo}」(初始密码见部署配置)`,
);
}
createdTeachers.set(teacherNo.toLowerCase(), teacherId);
// ---- 写入角色
if (role === 'HEAD') {
if (targetClass.teacherId === teacherId) {
skipped += 1;
continue;
}
await prisma.class.update({ where: { id: targetClass.id }, data: { teacherId } });
targetClass.teacherId = teacherId;
updated += 1;
} else {
const existing = await prisma.course.findUnique({
where: { classId_name: { classId: targetClass.id, name: subjectName } },
select: { id: true, teacherId: true },
});
if (existing) {
if (existing.teacherId === teacherId) {
skipped += 1;
continue;
}
await prisma.course.update({ where: { id: existing.id }, data: { teacherId } });
} else {
await prisma.course.create({
data: { name: subjectName, classId: targetClass.id, teacherId },
});
}
updated += 1;
}
}
const result: ImportResult = {
kind: 'classTeachers',
total: parsed.rows.length,
inserted,
updated,
skipped,
failed: errors.length,
errors,
warnings,
};
logger.info(
`班级任课老师导入:用工号匹配=${useTeacherNo} 新建教师=${inserted} 更新关系=${updated} 跳过=${skipped} 失败=${errors.length}`,
);
return result;
return result;
},
{ timeout: 30000 },
)
.then((result) => {
refreshClassSubscriptions();
return result;
});
}
export async function commitTable(user: TokenPayload, input: TableCommitInput): Promise<ImportResult> {
@@ -890,19 +977,15 @@ async function assertImportAllowed(
} as const;
await assertPermission(user, key[kind]);
if (classId) await assertClassAccess(user, classId, key[kind]);
if (kind === 'students') return assertCanManageRoster(user, classId);
if (kind === 'students') {
if (classId) await assertManage(user, 'import:students', classId);
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 === 'grades') {
throw ApiError.forbidden(
classRole === 'SUBJECT' || classRole === 'CLASS_DEVICE'
? '科任老师与 ClassHelper 班级端不能导入名单或成绩,请联系班主任或管理员'
: '只能导入自己担任班主任的班级的数据',
);
}
throw ApiError.forbidden('该导入仅限管理员使用');
if (kind === 'grades' && classId) return;
if (kind === 'scheduleTimes' && classId) return assertManage(user, 'import:schedule', classId);
throw ApiError.forbidden('请指定目标班级');
}
/** 只接受明确的角色值;空值或未识别值保留原角色,避免意外降级。 */
@@ -324,7 +324,7 @@ export async function importTimeLayout(
user: TokenPayload,
input: TimeLayoutImportInput,
): Promise<{ layout: TimeLayoutDto; mode: string; warnings: string[]; replaced: number; merged: number }> {
await assertCanImportSchedule(user, input.classId, 'timetable:manage');
await assertCanImportSchedule(user, input.classId);
const parsed = parseClassIslandTimeLayout(input.payload);
if (parsed.errors.length > 0) {
@@ -380,7 +380,7 @@ export async function importTimeLayout(
actionSet: '{}',
};
});
const layout = await saveLayout({ classId: input.classId, name, points }, current?.id);
const layout = await saveLayout(user, { classId: input.classId, name, points }, current?.id);
logger.info(`课表时间配置导入:班级=${input.classId} 模式=${input.mode} 共 ${combined.length} 个时间点`);
return {
layout: toDto(layout),
@@ -4,7 +4,7 @@ import { idParamSchema } from '../../lib/schemas.js';
import {
authenticate,
getAuthUser,
requireStaffPermission,
requirePermissionOrClassDevice,
requirePermission,
} from '../../middleware/auth.js';
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
@@ -28,7 +28,7 @@ router.use(authenticate());
/** GET /api/notifications?classId=&priority=&unreadOnly=&date=&from=&to=&createdBy= - 通知列表 */
router.get(
'/',
requireStaffPermission('notification:read'),
requirePermissionOrClassDevice('notification:read'),
validate({ query: listNotificationsQuerySchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -40,7 +40,7 @@ router.get(
/** GET /api/notifications/days?classId=&from=&to=&createdBy= - 按天归档(日期 + 条数) */
router.get(
'/days',
requireStaffPermission('notification:read'),
requirePermissionOrClassDevice('notification:read'),
validate({ query: notificationArchiveQuerySchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -52,7 +52,7 @@ router.get(
/** GET /api/notifications/creators?classId=&from=&to=&date= - 按发布人归档 */
router.get(
'/creators',
requireStaffPermission('notification:read'),
requirePermissionOrClassDevice('notification:read'),
validate({ query: notificationArchiveQuerySchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -64,7 +64,7 @@ router.get(
/** GET /api/notifications/unread-count?classId= - 未读数(客户端红点) */
router.get(
'/unread-count',
requireStaffPermission('notification:read'),
requirePermissionOrClassDevice('notification:read'),
validate({ query: notificationScopeQuerySchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -76,7 +76,7 @@ router.get(
/** POST /api/notifications/read-all - 全部标为已读 */
router.post(
'/read-all',
requireStaffPermission('notification:read'),
requirePermissionOrClassDevice('notification:read'),
validate({ body: notificationScopeQuerySchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -88,7 +88,7 @@ router.post(
/** POST /api/notifications - 发布通知(发布后广播 notification:new) */
router.post(
'/',
requireStaffPermission('notification:publish'),
requirePermissionOrClassDevice('notification:publish'),
validate({ body: createNotificationSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -100,7 +100,7 @@ router.post(
/** POST /api/notifications/bulk - 一次给多个班级发同一条通知(逐班结果,不整批回滚) */
router.post(
'/bulk',
requireStaffPermission('notification:publish'),
requirePermissionOrClassDevice('notification:publish'),
validate({ body: bulkCreateNotificationSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -115,7 +115,7 @@ router.post(
/** POST /api/notifications/:id/read - 标记已读 */
router.post(
'/:id/read',
requireStaffPermission('notification:read'),
requirePermissionOrClassDevice('notification:read'),
validate({ params: idParamSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -1,4 +1,6 @@
import { assertStaffPermission, assertPermission } from '../../lib/permissions.js';
import { assertClassRead, isHead } from '../../lib/authz/index.js';
import { assertPublish } from '../../lib/authz/index.js';
import { assertPermissionOrClassDevice, assertPermission } from '../../lib/permissions.js';
import { resolveContentCreator } from '../../lib/access.js';
import {
SOCKET_EVENTS,
@@ -12,12 +14,7 @@ import {
type NotificationDto,
} from '@classhelper/shared';
import { normalizeBulkClassIds, summarizeBulk, toBulkFailure } from '../../lib/bulk.js';
import {
assertClassAccess,
assertCanPublishNotification,
classScopeWhere,
resolveClassScope,
} from '../../lib/access.js';
import { assertClassAccess, classScopeWhere, resolveClassScope } from '../../lib/access.js';
import { activeStudentWhere } from '../../lib/students.js';
import { prisma } from '../../lib/db.js';
import { ApiError } from '../../lib/http.js';
@@ -73,8 +70,8 @@ export async function listNotifications(
user: TokenPayload,
options: ListNotificationOptions,
): Promise<NotificationDto[]> {
await assertStaffPermission(user, 'notification:read');
const scope = await resolveClassScope(user, options.classId);
await assertPermissionOrClassDevice(user, 'notification:read');
const scope = await resolveClassScope(user, options.classId, 'notification:read');
const isStaff = user.role === 'ADMIN' || user.role === 'TEACHER';
// 班级账号(班级设备)以全班学生为"个人范围",普通学生就是自己
const personalIds = await resolvePersonalIds(user);
@@ -112,8 +109,8 @@ 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);
await assertPermissionOrClassDevice(user, 'notification:read');
const scope = await resolveClassScope(user, options.classId, 'notification:read');
const grouped = await prisma.notification.groupBy({
by: ['assignDate'],
where: { ...classScopeWhere(scope), ...archiveWhere(options) },
@@ -133,8 +130,8 @@ 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);
await assertPermissionOrClassDevice(user, 'notification:read');
const scope = await resolveClassScope(user, options.classId, 'notification:read');
const grouped = await prisma.notification.groupBy({
by: ['createdBy'],
where: { ...classScopeWhere(scope), ...archiveWhere(options) },
@@ -171,7 +168,7 @@ export async function bulkCreateNotifications(
user: TokenPayload,
input: BulkCreateNotificationRequest,
): Promise<BulkPublishResult> {
await assertStaffPermission(user, 'notification:publish');
await assertPermissionOrClassDevice(user, 'notification:publish');
const classIds = normalizeBulkClassIds(input.classIds);
const results: BulkPublishClassResult[] = [];
@@ -208,8 +205,8 @@ export async function createNotification(
user: TokenPayload,
input: CreateNotificationInput,
): Promise<NotificationDto> {
await assertStaffPermission(user, 'notification:publish');
await assertCanPublishNotification(user, input.classId);
await assertPermissionOrClassDevice(user, 'notification:publish');
await assertPublish(user, 'notification:publish', input.classId, { allowClassDevice: true });
const priority = input.priority ?? 'NORMAL';
if (priority === 'URGENT' && input.confirmDuringClass !== true) {
@@ -251,7 +248,9 @@ export async function deleteNotification(user: TokenPayload, notificationId: str
await assertPermission(user, 'notification:delete');
const current = await prisma.notification.findUnique({ where: { id: notificationId } });
if (!current) throw ApiError.notFound('通知不存在');
await assertCanPublishNotification(user, current.classId);
await assertClassRead(user, 'notification:delete', current.classId);
if (user.role !== 'ADMIN' && current.createdBy !== user.sub && !(await isHead(user, current.classId)))
throw ApiError.forbidden('只有发布者、班主任或管理员可删除通知');
await prisma.notification.delete({ where: { id: notificationId } });
emitToClass(current.classId, SOCKET_EVENTS.notificationDeleted, {
id: notificationId,
@@ -267,10 +266,10 @@ export async function markAsRead(
user: TokenPayload,
notificationId: string,
): Promise<{ notificationId: string; readAt: string; marked: number }> {
await assertStaffPermission(user, 'notification:read');
await assertPermissionOrClassDevice(user, 'notification:read');
const notification = await prisma.notification.findUnique({ where: { id: notificationId } });
if (!notification) throw ApiError.notFound('通知不存在');
await assertClassAccess(user, notification.classId);
await assertClassAccess(user, notification.classId, 'notification:read');
const personalIds = await resolvePersonalIds(user);
// 先过滤已读过的记录再创建:SQLite 下 createMany 不支持 skipDuplicates,
@@ -293,8 +292,8 @@ 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);
await assertPermissionOrClassDevice(user, 'notification:read');
const scope = await resolveClassScope(user, classId, 'notification:read');
const personalIds = await resolvePersonalIds(user);
const personalWhere = personalIdWhere(personalIds);
@@ -324,8 +323,8 @@ 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);
await assertPermissionOrClassDevice(user, 'notification:read');
const scope = await resolveClassScope(user, classId, 'notification:read');
const personalIds = await resolvePersonalIds(user);
return prisma.notification.count({
where: { ...classScopeWhere(scope), reads: { none: personalIdWhere(personalIds) } },
@@ -1,9 +1,14 @@
import { z } from 'zod';
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 { validate, validatedBody, validatedParams, validatedQuery } from '../../middleware/validate.js';
import { authenticate, getAuthUser, requirePermission, requireAdmin } from '../../middleware/auth.js';
import {
validateBadRequest as validate,
validatedBody,
validatedParams,
validatedQuery,
} from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import {
assignUserGroupsSchema,
@@ -29,17 +34,19 @@ router.get('/me', async (req, res) => {
sendOk(res, await permissionService.myPermissions(user), '获取当前权限成功');
});
router.use(requireAdmin());
/** GET /api/permissions/catalog - 权限键目录(权限页整棵勾选树的唯一来源) */
router.get('/catalog', requirePermission('permission:manage'), async (_req, res) => {
sendOk(res, await permissionService.permissionCatalog(), '获取权限目录成功');
router.get('/catalog', requirePermission('permission:manage'), async (req, res) => {
sendOk(res, await permissionService.permissionCatalog(getAuthUser(req)), '获取权限目录成功');
});
/** GET /api/permissions/groups - 权限组列表 */
router.get('/groups', requirePermission('permission:manage'), async (_req, res) => {
sendOk(res, await permissionService.listGroups(), '获取权限组成功');
router.get('/groups', requirePermission('permission:manage'), async (req, res) => {
sendOk(res, await permissionService.listGroups(getAuthUser(req)), '获取权限组成功');
});
router.get('/scopes', requirePermission('permission:manage'), async (_req, res) =>
router.get('/scopes', requirePermission('permission:manage'), async (req, res) =>
sendOk(res, await permissionService.scopeOptions()),
);
router.get(
@@ -53,14 +60,15 @@ router.get(
}),
async (req, res) => {
const { page, pageSize } = validatedQuery<{ page: number; pageSize: number }>(req);
sendOk(res, await permissionService.listAudits(page, pageSize));
sendOk(res, await permissionService.listAudits(getAuthUser(req), 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))),
async (req, res) =>
sendOk(res, await permissionService.groupMembers(getAuthUser(req), String(req.params.id))),
);
/** POST /api/permissions/groups - 新建权限组 */
@@ -99,10 +107,29 @@ router.delete(
);
/** GET /api/permissions/users - 成员列表(含所属权限组与生效权限数量) */
router.get('/users', requirePermission('permission:manage'), async (_req, res) => {
sendOk(res, await permissionService.listMembers(), '获取成员成功');
router.get('/users', requirePermission('permission:manage'), async (req, res) => {
sendOk(res, await permissionService.listMembers(getAuthUser(req)), '获取成员成功');
});
router.get(
'/users/:id/effective',
validate({ params: idParamSchema, query: z.object({ assignments: z.string().max(16000).optional() }) }),
async (req, res) => {
const query = validatedQuery<{ assignments?: string }>(req);
let preview: AssignUserGroupsInput | undefined;
if (query.assignments !== undefined) {
try {
preview = assignUserGroupsSchema.parse({ assignments: JSON.parse(query.assignments) });
} catch {
throw ApiError.badRequest('分配预览格式不正确');
}
}
sendOk(
res,
await permissionService.effectivePermissions(getAuthUser(req), String(req.params.id), preview),
);
},
);
/** PUT /api/permissions/users/:id/groups - 整体替换某账号的权限组 */
router.put(
'/users/:id/groups',
@@ -22,7 +22,7 @@ const keysSchema = z.array(permissionKeySchema).max(200, '一次最多提交 200
export const createPermissionGroupSchema = z.object({
name: z.string().trim().min(1, '请输入权限组名称').max(40, '权限组名称最多 40 个字'),
description: z.string().trim().max(200, '描述最多 200 个字').optional(),
scope: z.enum(['SELF_CLASS', 'GRADE', 'DIVISION', 'ALL']).optional(),
scope: z.enum(['TEACHING', 'HEAD', 'GRADE', 'DIVISION', 'ALL']).optional(),
scopeValue: z.string().trim().max(200).optional(),
keys: keysSchema.optional(),
});
@@ -36,15 +36,33 @@ export const createPermissionGroupSchema = z.object({
export const updatePermissionGroupSchema = z.object({
name: z.string().trim().min(1, '请输入权限组名称').max(40, '权限组名称最多 40 个字').optional(),
description: z.string().trim().max(200, '描述最多 200 个字').optional(),
scope: z.enum(['SELF_CLASS', 'GRADE', 'DIVISION', 'ALL']).optional(),
scope: z.enum(['TEACHING', 'HEAD', 'GRADE', 'DIVISION', 'ALL']).optional(),
scopeValue: z.string().trim().max(200).optional(),
keys: keysSchema.optional(),
});
/** 给账号分配权限组:整体替换;传空数组 = 回到角色默认模板 */
export const assignUserGroupsSchema = z.object({
groupIds: z.array(z.string().min(1)).max(50, '一次最多分配 50 个权限组'),
});
export const assignUserGroupsSchema = z
.object({
groupIds: z.array(z.string().min(1)).max(50).optional(),
assignments: z
.array(
z
.object({
groupId: z.string().min(1),
scope: z.enum(['TEACHING', 'HEAD', 'GRADE', 'DIVISION', 'ALL']),
scopeValue: z.string().max(200).default(''),
})
.strict(),
)
.max(50)
.optional(),
})
.strict()
.refine(
(v) => (v.assignments !== undefined) !== (v.groupIds !== undefined),
'请提供 assignments 或 groupIds 之一',
);
export type CreatePermissionGroupInput = z.infer<typeof createPermissionGroupSchema>;
export type UpdatePermissionGroupInput = z.infer<typeof updatePermissionGroupSchema>;
@@ -1,165 +1,72 @@
import { disconnectUserSessions } from '../../realtime/bus.js';
import {
PERMISSION_CATEGORIES,
PERMISSION_KEYS,
PERMISSION_META,
PERMISSION_LABELS,
PERMISSION_CATEGORIES,
ROLE_DEFAULT_PERMISSIONS,
parsePermissionScope,
defaultPromotionPlan,
type PermissionKey,
type PermissionScope,
type PermissionGroupDto,
type PermissionMemberDto,
type PermissionCatalogDto,
type EffectivePermissionsDto,
type AssignUserGroupsRequest,
type CreatePermissionGroupRequest,
type MyPermissionsDto,
type PermissionCatalogDto,
type PermissionGroupDto,
type PermissionKey,
type PermissionMemberDto,
type UpdatePermissionGroupRequest,
type UserRole,
type PromotionPlan,
} from '@classhelper/shared';
import { prisma } from '../../lib/db.js';
import { ApiError } from '../../lib/http.js';
import type { TokenPayload } from '../../lib/jwt.js';
import { ensureDefaultGroups } from '../../lib/permissions.js';
import {
parsePermissionScope,
defaultPromotionPlan,
type PermissionScope,
type PromotionPlan,
} from '@classhelper/shared';
import { assertPermission } from '../../lib/permissions.js';
assertAdmin,
invalidateAuthPrincipals,
loadAuthPrincipal,
principalForAssignments,
classIdsFor,
manageClassIdsFor,
actionScopesFor,
type AssignmentRow,
} from '../../lib/authz/index.js';
import { getSetting } from '../../lib/settings.js';
import {
ADMIN_UNREMOVABLE_PERMISSIONS,
ensureDefaultGroups,
loadEffectivePermissions,
} from '../../lib/permissions.js';
/**
* 权限组服务:组 / 键 / 成员三件事的全部读写都收在这里。
*
* 生效权限的计算**不在本文件**,而在 `lib/permissions.ts` 的 `loadEffectivePermissions`
* (鉴权中间件每请求调用一次);本文件只负责"编辑这些组",两者共用同一份 shared 常量。
*/
const GROUP_SELECT = {
id: true,
name: true,
description: true,
builtin: true,
defaultRole: true,
scope: true,
scopeValue: true,
createdAt: true,
updatedAt: true,
items: { select: { key: true } },
_count: { select: { users: true } },
} as const;
type GroupRow = {
id: string;
name: string;
description: string;
builtin: boolean;
defaultRole: string | null;
scope: string;
scopeValue: string;
createdAt: Date;
updatedAt: Date;
items: { key: string }[];
_count: { users: number };
};
const MEMBER_SELECT = {
id: true,
username: true,
name: true,
role: true,
permissionGroups: { select: { groupId: true } },
} as const;
/** 目录顺序(key → 序号):组的键与"我的权限"都按它排序,前端渲染才稳定 */
const KEY_ORDER = new Map<string, number>(PERMISSION_KEYS.map((key, index) => [key, index]));
function knownKey(key: string): key is PermissionKey {
return KEY_ORDER.has(key);
}
function byCatalogOrder(a: string, b: string): number {
return (KEY_ORDER.get(a) ?? Number.MAX_SAFE_INTEGER) - (KEY_ORDER.get(b) ?? Number.MAX_SAFE_INTEGER);
}
/** 去重 + 丢掉已下线的键 + 按目录顺序排序 */
function normalizeKeys(keys: readonly string[] | undefined): PermissionKey[] {
const unique = new Set<PermissionKey>();
for (const key of keys ?? []) if (knownKey(key)) unique.add(key);
return [...unique].sort(byCatalogOrder);
}
function toGroupDto(row: GroupRow): PermissionGroupDto {
return {
id: row.id,
scope: row.scope as PermissionGroupDto['scope'],
scopeValue: row.scopeValue,
scopeLabel: scopeLabel(row),
name: row.name,
description: row.description,
builtin: row.builtin,
defaultRole: row.defaultRole as UserRole | null,
keys: row.items
.map((item) => item.key)
.filter(knownKey)
.sort(byCatalogOrder),
memberCount: row._count.users,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
function scopeLabel(group: { scope: string; scopeValue: string }) {
const include = { items: true, _count: { select: { users: true } } } as const;
type Group = Awaited<ReturnType<typeof prisma.permissionGroup.findMany<{ include: typeof include }>>>[number];
const known = (key: string): key is PermissionKey => (PERMISSION_KEYS as readonly string[]).includes(key);
const keysFor = (group: { items: { key: string }[] }) => group.items.map((i) => i.key).filter(known);
const activeKeys = (group: { items: { key: string }[] }) =>
keysFor(group).filter((key) => PERMISSION_META[key].kind !== 'admin');
function label(scope: string, scopeValue: string) {
try {
return parsePermissionScope(group.scope as PermissionScope, group.scopeValue).scopeLabel;
return parsePermissionScope(scope as PermissionScope, 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));
function dto(group: Group): PermissionGroupDto {
const inactiveKeys = keysFor(group).filter((k) => PERMISSION_META[k].kind === 'admin');
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),
})),
...group,
keys: activeKeys(group),
scope: group.scope as PermissionScope,
scopeLabel: label(group.scope, group.scopeValue),
defaultScope: group.scope as PermissionScope,
defaultScopeValue: group.scopeValue,
defaultScopeLabel: label(group.scope, group.scopeValue),
defaultRole: group.defaultRole === 'TEACHER' ? 'TEACHER' : null,
kind: group.defaultRole ? 'default' : group.builtin ? 'builtin' : 'custom',
inactiveKeys,
warnings: inactiveKeys.length ? ['旧管理员专用键已停用,仅管理员固定拥有'] : [],
memberCount: group._count.users,
createdAt: group.createdAt.toISOString(),
updatedAt: group.updatedAt.toISOString(),
};
}
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(
export function auditData(
actor: TokenPayload,
action: string,
targetType: string,
@@ -177,17 +84,34 @@ function auditData(
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) => {
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((t) => t.stages.map((s) => s.grade));
return {
grades: [...new Set([...classes.map((c) => c.grade), ...planGrades])].sort(),
planGrades,
divisions: plan.tracks.map((t) => ({ name: t.name, grades: t.stages.map((s) => s.grade) })),
};
}
async function validateScope(scope: PermissionScope = 'TEACHING', scopeValue = '') {
try {
return parsePermissionScope(scope, scopeValue, (await scopeOptions()).grades);
} catch (error) {
throw ApiError.badRequest(error instanceof Error ? error.message : String(error));
}
}
function validateKeys(keys: readonly string[]) {
if (keys.some((k) => !known(k) || PERMISSION_META[k].kind === 'admin'))
throw ApiError.badRequest('教师模板不能包含管理员专用键或未知键');
return [...new Set(keys)] as PermissionKey[];
}
export async function createGroup(actor: TokenPayload, input: CreatePermissionGroupRequest) {
assertAdmin(actor);
const scope = await validateScope(input.scope, input.scopeValue);
const keys = validateKeys(input.keys ?? []);
return prisma.$transaction(async (tx) => {
const row = await tx.permissionGroup.create({
data: {
name: input.name.trim(),
@@ -196,35 +120,34 @@ export async function createGroup(
scopeValue: scope.scopeValue,
items: { create: keys.map((key) => ({ key })) },
},
select: GROUP_SELECT,
include,
});
await tx.permissionAudit.create({
data: auditData(actor, 'create', 'group', row.id, null, toGroupDto(row)),
});
return row;
await tx.permissionAudit.create({ data: auditData(actor, 'create', 'group', row.id, null, dto(row)) });
invalidateAuthPrincipals();
return dto(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 } });
export async function updateGroup(actor: TokenPayload, id: string, input: UpdatePermissionGroupRequest) {
assertAdmin(actor);
const current = await prisma.permissionGroup.findUnique({ where: { id }, include });
if (!current || current.defaultRole === 'ADMIN') throw ApiError.notFound('模板不存在');
const scope = await validateScope(
input.scope ?? (current.scope as PermissionScope),
input.scopeValue ?? current.scopeValue,
);
if (current.defaultRole === 'TEACHER' && scope.scope !== 'TEACHING')
throw ApiError.badRequest('教师默认模板固定使用任教班级范围');
// 旧管理员键仅停用;编辑可分配能力时保留它们,不能通过写入口重新授予。
const inactiveKeys = keysFor(current).filter((key) => PERMISSION_META[key].kind === 'admin');
const keys =
input.keys === undefined
? current.items.map((i) => i.key)
: [...new Set([...validateKeys(input.keys), ...inactiveKeys])];
let affected: string[] = [];
const result = await prisma.$transaction(async (tx) => {
await tx.permissionGroupItem.deleteMany({ where: { groupId: id } });
const row = await tx.permissionGroup.update({
where: { id: groupId },
where: { id },
data: {
name: input.name?.trim(),
description: input.description?.trim(),
@@ -232,120 +155,204 @@ export async function updateGroup(
scopeValue: scope.scopeValue,
items: { create: keys.map((key) => ({ key })) },
},
select: GROUP_SELECT,
include,
});
await tx.permissionAudit.create({
data: auditData(actor, 'update', 'group', id, dto(current), dto(row)),
});
const members =
current.defaultRole === 'TEACHER'
? await tx.user.findMany({ where: { role: 'TEACHER' }, select: { id: true } })
: await tx.userPermissionGroup
.findMany({ where: { groupId: id }, select: { userId: true } })
.then((rows) => rows.map((r) => ({ id: r.userId })));
affected = members.map((u) => u.id);
return dto(row);
});
invalidateAuthPrincipals();
disconnectUserSessions(affected);
return result;
}
export async function deleteGroup(actor: TokenPayload, id: string) {
assertAdmin(actor);
const group = await prisma.permissionGroup.findUnique({ where: { id }, include });
if (!group) throw ApiError.notFound('模板不存在');
if (group.builtin || group.defaultRole) throw ApiError.badRequest('内置或默认模板不可删除');
const members = await prisma.userPermissionGroup.findMany({
where: { groupId: id },
select: { userId: true },
});
await prisma.$transaction(async (tx) => {
await tx.permissionGroup.delete({ where: { id } });
await tx.permissionAudit.create({ data: auditData(actor, 'delete', 'group', id, dto(group), null) });
});
invalidateAuthPrincipals();
disconnectUserSessions(members.map((m) => m.userId));
return { id };
}
export async function listMembers(actor: TokenPayload): Promise<PermissionMemberDto[]> {
assertAdmin(actor);
await ensureDefaultGroups();
const defaultGroup = await prisma.permissionGroup.findUniqueOrThrow({
where: { defaultRole: 'TEACHER' },
include: { items: true },
});
const users = await prisma.user.findMany({
where: { role: { in: ['ADMIN', 'TEACHER'] } },
include: { permissionGroups: { include: { group: { include: { items: true } } } } },
orderBy: { name: 'asc' },
});
return Promise.all(
users.map(async (u) => {
const user: TokenPayload = {
sub: u.id,
role: u.role as TokenPayload['role'],
username: u.username,
name: u.name,
classId: null,
};
const auth = await principalForAssignments(user, u.permissionGroups, defaultGroup);
const assignments =
u.role === 'ADMIN'
? []
: u.permissionGroups.map((a) => ({
groupId: a.groupId,
groupName: a.group.name,
scope: a.scope as PermissionScope,
scopeValue: a.scopeValue,
scopeLabel: label(a.scope, a.scopeValue),
}));
return {
id: u.id,
username: u.username,
name: u.name,
role: user.role,
fixed: u.role === 'ADMIN',
assignments,
groupIds: [...new Set(assignments.map((a) => a.groupId))],
permissions: [...auth.permissions],
permissionCount: auth.permissions.size,
scopeLabels: assignments.length
? [...new Set(assignments.map((a) => a.scopeLabel))]
: [u.role === 'ADMIN' ? '固定全部权限' : '默认模板 · 任教班级'],
};
}),
);
}
export async function groupMembers(actor: TokenPayload, id: string) {
assertAdmin(actor);
const group = await prisma.permissionGroup.findUnique({ where: { id } });
if (!group || group.defaultRole === 'ADMIN') throw ApiError.notFound('模板不存在');
return (await listMembers(actor)).filter(
(u) =>
!u.fixed && (u.groupIds.includes(id) || (group.defaultRole === 'TEACHER' && !u.assignments.length)),
);
}
async function assignmentRows(input: AssignUserGroupsRequest): Promise<AssignmentRow[]> {
const ids = input.assignments?.map((a) => a.groupId) ?? input.groupIds ?? [];
const groups = await prisma.permissionGroup.findMany({
where: { id: { in: ids } },
include: { items: true },
});
if (new Set(ids).size !== groups.length || groups.some((g) => g.defaultRole === 'ADMIN'))
throw ApiError.badRequest('模板不存在或已停用');
const assignments =
input.assignments ??
ids.map((groupId) => {
const g = groups.find((g) => g.id === groupId)!;
return { groupId, scope: g.scope as PermissionScope, scopeValue: g.scopeValue };
});
const rows: AssignmentRow[] = [];
for (const assignment of assignments) {
const group = groups.find((g) => g.id === assignment.groupId)!;
const scope = await validateScope(assignment.scope, assignment.scopeValue);
if (group.defaultRole === 'TEACHER' && scope.scope !== 'TEACHING')
throw ApiError.badRequest('教师默认模板只允许任教班级范围');
if (
activeKeys(group).some(
(k) => PERMISSION_META[k].kind === 'class' && !PERMISSION_META[k].allowedScopes.includes(scope.scope),
)
)
throw ApiError.badRequest('分配范围不适用于模板能力');
rows.push({ groupId: group.id, scope: scope.scope, scopeValue: scope.scopeValue, group });
}
return [...new Map(rows.map((a) => [`${a.groupId}:${a.scope}:${a.scopeValue}`, a])).values()];
}
export async function effectivePermissions(
actor: TokenPayload,
id: string,
preview?: AssignUserGroupsRequest,
): Promise<EffectivePermissionsDto> {
assertAdmin(actor);
await ensureDefaultGroups();
const u = await prisma.user.findUnique({
where: { id },
include: { permissionGroups: { include: { group: { include: { items: true } } } } },
});
if (!u) throw ApiError.notFound('账号不存在');
const user: TokenPayload = {
sub: id,
name: u.name,
username: u.username,
role: u.role as TokenPayload['role'],
classId: null,
};
if (preview && u.role === 'ADMIN') throw ApiError.badRequest('管理员固定全部权限,不接受分配');
const auth = await principalForAssignments(
user,
preview ? await assignmentRows(preview) : u.permissionGroups,
);
const classes = await prisma.class.findMany({ select: { id: true, name: true } });
const entries = await Promise.all(
[...auth.permissions].map(async (key) => {
const ids = await classIdsFor(user, key, auth);
return {
key,
scopes: [...new Set((auth.grants.get(key) ?? []).map((g) => label(g.scope, g.scopeValue)))],
classes: classes.filter((c) => ids.includes(c.id)),
classCount: ids.length,
};
}),
);
return {
permissions: [...auth.permissions],
entries,
conflicts: entries
.filter((e) => e.scopes.length > 1)
.map((e) => `${PERMISSION_LABELS[e.key]}:实际合并范围 ${e.scopes.join(' ∪ ')};较窄分配可能被覆盖`),
headOnlyKeys: [...auth.permissions].filter((k) => PERMISSION_META[k].relation === 'head'),
};
}
export async function assignUserGroups(actor: TokenPayload, userId: string, input: AssignUserGroupsRequest) {
assertAdmin(actor);
const u = await prisma.user.findUnique({ where: { id: userId } });
if (!u) throw ApiError.notFound('账号不存在');
if (u.role !== 'TEACHER') throw ApiError.badRequest('管理员固定全部权限,不能分配模板');
const rows = await assignmentRows(input);
const before = (await listMembers(actor)).find((m) => m.id === userId)!;
await prisma.$transaction(async (tx) => {
await tx.userPermissionGroup.deleteMany({ where: { userId } });
if (rows.length)
await tx.userPermissionGroup.createMany({
data: rows.map(({ groupId, scope, scopeValue }) => ({ userId, groupId, scope, scopeValue })),
});
await tx.permissionAudit.create({
data: auditData(
actor,
current.defaultRole ? 'update-default' : 'update',
'group',
groupId,
before,
toGroupDto(row),
'assign',
'user',
userId,
before.assignments,
rows.map(({ groupId, scope, scopeValue }) => ({ groupId, scope, scopeValue })),
),
});
return row;
});
return toGroupDto(updated);
invalidateAuthPrincipals();
disconnectUserSessions([userId]);
return (await listMembers(actor)).find((m) => m.id === userId)!;
}
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) {
export async function listAudits(actor: TokenPayload, page = 1, pageSize = 30) {
assertAdmin(actor);
const [items, total] = await Promise.all([
prisma.permissionAudit.findMany({
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
@@ -356,71 +363,68 @@ export async function listAudits(page = 1, pageSize = 30) {
]);
return { items, total, page, pageSize };
}
/** GET /api/permissions/catalog - 权限键目录(权限页整棵勾选树都按它渲染) */
export async function permissionCatalog(): Promise<PermissionCatalogDto> {
export async function permissionCatalog(actor: TokenPayload): Promise<PermissionCatalogDto> {
assertAdmin(actor);
await ensureDefaultGroups();
const defaults = await prisma.permissionGroup.findMany({
where: { defaultRole: { not: null } },
const group = await prisma.permissionGroup.findUniqueOrThrow({
where: { defaultRole: 'TEACHER' },
include: { items: true },
});
const roles = Object.keys(ROLE_DEFAULT_PERMISSIONS) as UserRole[];
const keysForRole = (role: UserRole): PermissionKey[] => {
const keys =
defaults.find((group) => group.defaultRole === role)?.items.map((item) => item.key) ??
ROLE_DEFAULT_PERMISSIONS[role];
return normalizeKeys(role === 'ADMIN' ? [...keys, ...ADMIN_UNREMOVABLE_PERMISSIONS] : keys);
};
return {
keys: [...PERMISSION_KEYS],
labels: { ...PERMISSION_LABELS },
categories: PERMISSION_CATEGORIES.map((category) => ({
title: category.title,
keys: [...category.keys],
})),
meta: PERMISSION_META,
labels: PERMISSION_LABELS,
categories: PERMISSION_CATEGORIES.map((c) => ({ title: c.title, keys: [...c.keys] })),
defaultRoles: Object.fromEntries(
PERMISSION_KEYS.map((key) => [key, roles.filter((role) => keysForRole(role).includes(key))]),
) as Record<PermissionKey, UserRole[]>,
roleDefaults: Object.fromEntries(roles.map((role) => [role, keysForRole(role)])) as Record<
UserRole,
PermissionKey[]
>,
unremovable: [...ADMIN_UNREMOVABLE_PERMISSIONS],
PERMISSION_KEYS.map((k) => [k, [...PERMISSION_META[k].defaultRoles]]),
) as PermissionCatalogDto['defaultRoles'],
roleDefaults: {
...ROLE_DEFAULT_PERMISSIONS,
ADMIN: [...PERMISSION_KEYS],
TEACHER: activeKeys(group),
CLASS_DEVICE: [],
},
unremovable: [...PERMISSION_KEYS],
};
}
/** GET /api/permissions/groups - 权限组列表(内置组排在最前,其余按创建时间) */
export async function listGroups(): Promise<PermissionGroupDto[]> {
export async function listGroups(actor: TokenPayload): Promise<PermissionGroupDto[]> {
assertAdmin(actor);
await ensureDefaultGroups();
const rows = await prisma.permissionGroup.findMany({
select: GROUP_SELECT,
orderBy: [{ builtin: 'desc' }, { createdAt: 'asc' }],
});
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,
const members = await listMembers(actor);
return (
await prisma.permissionGroup.findMany({
where: { OR: [{ defaultRole: null }, { defaultRole: 'TEACHER' }] },
include,
orderBy: [{ builtin: 'desc' }, { createdAt: 'asc' }],
})
).map((group) => ({
...dto(group),
memberCount: members.filter(
(m) =>
!m.fixed &&
(m.groupIds.includes(group.id) || (group.defaultRole === 'TEACHER' && !m.assignments.length)),
).length,
}));
}
/**
* GET /api/permissions/me - 当前账号的生效权限。
*
* 两端按它决定"入口显不显示"(真正的拦截始终在服务端)。
* 班级端会话恒为空集:它的能力由班级维度断言决定。
*/
export async function myPermissions(user: TokenPayload): Promise<MyPermissionsDto> {
const permissions = await loadEffectivePermissions(user);
const fromGroups =
user.classSession || user.role === 'CLASS_DEVICE'
? false
: (await prisma.userPermissionGroup.count({ where: { userId: user.sub } })) > 0;
export async function myPermissions(user: TokenPayload) {
const auth = await loadAuthPrincipal(user);
const classKeys = [...auth.permissions].filter((key) => PERMISSION_META[key].kind === 'class');
const managementKeys = classKeys.filter((key) =>
['roster:manage', 'schedule:write', 'import:students', 'import:schedule', 'grade:write'].includes(key),
);
const [scopes, management] = await Promise.all([
Promise.all(classKeys.map(async (key) => [key, await classIdsFor(user, key, auth)] as const)),
Promise.all(managementKeys.map(async (key) => [key, await manageClassIdsFor(user, key, auth)] as const)),
]);
return {
role: user.role,
permissions: [...permissions].sort(byCatalogOrder),
fromGroups,
permissions: [...auth.permissions],
fromGroups: auth.fromGroups,
classScopes: Object.fromEntries(scopes),
manageScopes: Object.fromEntries(management),
actionScopes: await actionScopesFor(user, scopes, management),
allScopeKeys: [...auth.grants]
.filter(([, grants]) => grants.some((g) => g.scope === 'ALL'))
.map(([key]) => key),
};
}
@@ -40,8 +40,8 @@ export async function listSchedules(
user: TokenPayload,
options: ListScheduleOptions,
): Promise<ScheduleDto[]> {
if (options.classId) await assertClassAccess(user, options.classId);
const classIds = options.classId ? [options.classId] : await getAccessibleClassIds(user);
if (options.classId) await assertClassAccess(user, options.classId, 'schedule:read');
const classIds = options.classId ? [options.classId] : await getAccessibleClassIds(user, 'schedule:read');
const result: ScheduleDto[] = [];
for (const classId of classIds) {
if (options.week) {
@@ -98,7 +98,7 @@ export async function getActiveScheduleWeek(
) {
const classId = isClassDevice(user) ? requireClassDeviceClassId(user) : options.classId;
if (!classId) throw ApiError.badRequest('请指定班级');
await assertClassAccess(user, classId);
await assertClassAccess(user, classId, 'schedule:read');
const context = await loadTermContext(classId),
week = options.week ?? resolveCurrentWeek(new Date(), context);
if (week > context.maxWeek) throw ApiError.badRequest('周次超过本班教学周数');
@@ -124,7 +124,7 @@ export async function computeClassStatus(classId: string, at = new Date()): Prom
export async function getClassStatus(user: TokenPayload, options: { classId?: string; at?: Date } = {}) {
const classId = isClassDevice(user) ? requireClassDeviceClassId(user) : options.classId;
if (!classId) throw ApiError.badRequest('请指定班级');
await assertClassAccess(user, classId);
await assertClassAccess(user, classId, 'schedule:read');
return computeClassStatus(classId, options.at);
}
@@ -151,8 +151,8 @@ function matches(p: PlanRow, day: number, week: number) {
(p.weekCountDiv === 0 || ((week - 1) % p.weekCountDivTotal) + 1 === p.weekCountDiv)
);
}
async function assertCourse(courseId: string, classId: string) {
if (!(await prisma.course.findFirst({ where: { id: courseId, classId } })))
async function assertCourse(courseId: string, classId: string, db: Tx = prisma) {
if (!(await db.course.findFirst({ where: { id: courseId, classId } })))
throw ApiError.badRequest('课程不属于本班');
}
export async function createSchedule(
@@ -161,10 +161,8 @@ export async function createSchedule(
transaction?: Tx,
identity?: string,
): Promise<ScheduleDto> {
if (!transaction) {
await assertCanManageSchedule(user, input.classId);
await assertCourse(input.courseId, input.classId);
}
await assertCanManageSchedule(user, input.classId, transaction);
await assertCourse(input.courseId, input.classId, transaction);
const cls = await (transaction ?? prisma).class.findUniqueOrThrow({ where: { id: input.classId } }),
max = transaction
? normalizeMaxWeeks(
@@ -282,6 +280,8 @@ export async function updateSchedule(
const rule: Partial<{ weekStart: number; weekEnd: number; weekParity: 'ALL' | 'ODD' | 'EVEN' }> = stored
? JSON.parse(stored.value)
: {};
for (const targetClassId of new Set(entries.map((e) => e.plan.classId)))
await assertCanManageSchedule(user, targetClassId);
const classId = entries[0]!.plan.classId;
await assertCanManageSchedule(user, classId);
if (input.courseId) await assertCourse(input.courseId, classId);
@@ -10,7 +10,7 @@ import { idParamSchema } from '../../lib/schemas.js';
import {
authenticate,
getAuthUser,
requireStaffPermission,
requirePermissionOrClassDevice,
requirePermission,
} from '../../middleware/auth.js';
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
@@ -30,18 +30,18 @@ const router = Router();
router.use(authenticate());
router.get(
'/active-week',
requireStaffPermission('schedule:read'),
requirePermissionOrClassDevice('schedule:read'),
validate({ query: gridQuerySchema }),
async (req, res) =>
sendOk(res, await scheduleService.getActiveScheduleWeek(getAuthUser(req), validatedQuery(req))),
);
router.get(
'/scheduled',
requireStaffPermission('schedule:read'),
requirePermissionOrClassDevice('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);
await assertClassAccess(getAuthUser(req), classId, 'schedule:read');
sendOk(
res,
await prisma.scheduledClassPlan.findMany({
@@ -109,7 +109,7 @@ router.post(
/** GET /api/schedules?classId=&week=&dayOfWeek= - 课表列表 */
router.get(
'/',
requireStaffPermission('schedule:read'),
requirePermissionOrClassDevice('schedule:read'),
validate({ query: listSchedulesQuerySchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -121,7 +121,7 @@ router.get(
/** GET /api/schedules/grid?classId=&week= - 周视图(7 列结构) */
router.get(
'/grid',
requireStaffPermission('schedule:read'),
requirePermissionOrClassDevice('schedule:read'),
validate({ query: gridQuerySchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -145,7 +145,7 @@ router.get(
*/
router.get(
'/current',
requireStaffPermission('schedule:read'),
requirePermissionOrClassDevice('schedule:read'),
validate({ query: classStatusQuerySchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -8,11 +8,11 @@ import {
getAccessibleClassIds,
resolveClassScope,
} from '../../lib/access.js';
import { assertStaffPermission, assertPermission } from '../../lib/permissions.js';
import { assertPermissionOrClassDevice, 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 { hasOrganizationalGrant } from '../../lib/authz/index.js';
import { ApiError } from '../../lib/http.js';
import type { TokenPayload } from '../../lib/jwt.js';
import { toStudentDto, toStudentTransferDto } from '../../lib/mappers.js';
@@ -38,7 +38,7 @@ const withClass = { class: { select: { name: true, grade: true } } } as const;
/** 学生名单(按班级权限过滤) */
export async function listStudents(user: TokenPayload, options: ListStudentsQuery): Promise<StudentDto[]> {
await assertStaffPermission(user, 'roster:read');
await assertPermissionOrClassDevice(user, 'roster:read');
const scope = await resolveClassScope(user, options.classId, 'roster:read');
const students = await prisma.student.findMany({
@@ -219,16 +219,16 @@ export async function listTransfers(
options: ListTransfersQuery,
): Promise<PaginatedResult<StudentTransferDto>> {
await assertPermission(user, 'roster:read');
const hasGlobalRosterScope = isAdmin(user) || (await hasAllScope(user, 'roster:read'));
const hasGlobalRosterScope = isAdmin(user) || (await hasOrganizationalGrant(user, 'roster:read'));
if (options.classId) await assertClassAccess(user, options.classId);
if (options.classId) await assertClassAccess(user, options.classId, 'roster:read');
if (options.studentId) {
const student = await prisma.student.findUnique({
where: { id: options.studentId },
select: { classId: true },
});
if (!student) throw ApiError.notFound('学生不存在');
if (student.classId) await assertClassAccess(user, student.classId);
if (student.classId) await assertClassAccess(user, student.classId, 'roster:read');
else if (!hasGlobalRosterScope) throw ApiError.forbidden('无权查看未分班学生的调班历史');
}
@@ -1,3 +1,4 @@
import { requireAdmin } from '../../middleware/auth.js';
import { Router } from 'express';
import { API_PATHS } from '@classhelper/shared';
import { sendOk } from '../../lib/http.js';
@@ -13,7 +14,7 @@ import {
import * as systemService from './system.service.js';
const router = Router();
router.use(authenticate());
router.use(authenticate(), requireAdmin());
/**
* 系统设置与运维接口(**全部仅 ADMIN**,权限键 `system:manage`)。
@@ -46,7 +47,7 @@ router.put(
validate({ body: updateAutostartSchema }),
async (req, res) => {
const input = validatedBody<UpdateAutostartInput>(req);
sendOk(res, await systemService.updateAutostart(input), '开机自启设置已生效');
sendOk(res, await systemService.updateAutostart(getAuthUser(req), input), '开机自启设置已生效');
},
);
@@ -1,3 +1,5 @@
import { assertAdmin } from '../../lib/authz/index.js';
import type { TokenPayload } from '../../lib/jwt.js';
import { createAutostartController } from './autostart.js';
import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
@@ -10,7 +12,6 @@ import {
} from '@classhelper/shared';
import { env } from '../../config/env.js';
import { prisma } from '../../lib/db.js';
import type { TokenPayload } from '../../lib/jwt.js';
import { logger } from '../../lib/logger.js';
import { SETTING_KEYS, getBooleanSetting, setBooleanSetting } from '../../lib/settings.js';
import { APP_VERSION } from '../../lib/version.js';
@@ -39,6 +40,7 @@ export async function updateSystemSettings(
user: TokenPayload,
input: UpdateSystemSettingsInput,
): Promise<SystemSettingsDto> {
assertAdmin(user);
if (input.developerMode !== undefined) {
await setBooleanSetting(SETTING_KEYS.developerMode, input.developerMode);
logger.info(`开发者模式已${input.developerMode ? '开启' : '关闭'}(操作人=${user.username})`);
@@ -55,7 +57,11 @@ const autostart = createAutostartController({
export async function getAutostartStatus(): Promise<AutostartStatusDto> {
return autostart.read();
}
export async function updateAutostart(input: UpdateAutostartInput): Promise<AutostartStatusDto> {
export async function updateAutostart(
actor: TokenPayload,
input: UpdateAutostartInput,
): Promise<AutostartStatusDto> {
assertAdmin(actor);
return autostart.write(input.enabled);
}
@@ -1,7 +1,13 @@
import { Router } from 'express';
import { sendCreated, sendOk } from '../../lib/http.js';
import { idParamSchema } from '../../lib/schemas.js';
import { authenticate, requirePermission, requireAnyPermission } from '../../middleware/auth.js';
import {
authenticate,
getAuthUser,
requireAdmin,
requirePermission,
requireAnyPermission,
} from '../../middleware/auth.js';
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import {
@@ -27,39 +33,50 @@ router.get(
validate({ query: listTeachersQuerySchema }),
async (req, res) => {
const { keyword } = validatedQuery<{ keyword?: string }>(req);
sendOk(res, await teacherService.listTeachers(keyword), '获取教师列表成功');
sendOk(res, await teacherService.listTeachers(getAuthUser(req), keyword), '获取教师列表成功');
},
);
/** POST /api/teachers - 录入教师账号(需 teacher:manage) */
router.post(
'/',
requireAdmin(),
requirePermission('teacher:manage'),
validate({ body: createTeacherSchema }),
async (req, res) => {
sendCreated(res, await teacherService.createTeacher(req.body as CreateTeacherInput), '教师账号创建成功');
sendCreated(
res,
await teacherService.createTeacher(getAuthUser(req), req.body as CreateTeacherInput),
'教师账号创建成功',
);
},
);
/** PATCH /api/teachers/:id - 编辑教师(姓名 / 用户名 / 角色) */
router.patch(
'/:id',
requireAdmin(),
requirePermission('teacher:manage'),
validate({ params: idParamSchema, body: updateTeacherSchema }),
async (req, res) => {
const { id } = validatedParams<{ id: string }>(req);
sendOk(res, await teacherService.updateTeacher(id, req.body as UpdateTeacherInput), '教师信息更新成功');
sendOk(
res,
await teacherService.updateTeacher(getAuthUser(req), id, req.body as UpdateTeacherInput),
'教师信息更新成功',
);
},
);
/** POST /api/teachers/:id/reset-password - 重置教师密码 */
router.post(
'/:id/reset-password',
requireAdmin(),
requirePermission('teacher:manage'),
validate({ params: idParamSchema, body: resetTeacherPasswordSchema }),
async (req, res) => {
const { id } = validatedParams<{ id: string }>(req);
await teacherService.resetTeacherPassword(id, req.body as ResetTeacherPasswordInput);
await teacherService.resetTeacherPassword(getAuthUser(req), id, req.body as ResetTeacherPasswordInput);
sendOk(res, { id }, '密码已重置');
},
);
@@ -67,11 +84,12 @@ router.post(
/** DELETE /api/teachers/:id - 删除教师账号(有班级职责/课程时拒绝,见 service) */
router.delete(
'/:id',
requireAdmin(),
requirePermission('teacher:manage'),
validate({ params: idParamSchema }),
async (req, res) => {
const { id } = validatedParams<{ id: string }>(req);
await teacherService.deleteTeacher(id);
await teacherService.deleteTeacher(getAuthUser(req), id);
sendOk(res, { id }, '教师账号已删除');
},
);
@@ -1,3 +1,8 @@
import type { Prisma } from '../../generated/prisma/client.js';
import { disconnectUserSessions } from '../../realtime/bus.js';
import { assertAdmin, assertPermission } from '../../lib/authz/index.js';
import type { TokenPayload } from '../../lib/jwt.js';
import { auditData } from '../permissions/permissions.service.js';
import type { UserDto } from '@classhelper/shared';
import { env } from '../../config/env.js';
import { prisma } from '../../lib/db.js';
@@ -17,7 +22,8 @@ import type {
*/
/** 教师列表(教师 + 管理员账号) */
export async function listTeachers(keyword?: string): Promise<UserDto[]> {
export async function listTeachers(actor: TokenPayload, keyword?: string): Promise<UserDto[]> {
await assertPermission(actor, 'teacher:read');
const teachers = await prisma.user.findMany({
where: {
role: { in: ['TEACHER', 'ADMIN'] },
@@ -29,7 +35,8 @@ export async function listTeachers(keyword?: string): Promise<UserDto[]> {
return teachers.map(toUserDto);
}
export async function createTeacher(input: CreateTeacherInput): Promise<UserDto> {
export async function createTeacher(actor: TokenPayload, input: CreateTeacherInput): Promise<UserDto> {
assertAdmin(actor);
const created = await prisma.user.create({
data: {
username: input.username,
@@ -38,6 +45,12 @@ export async function createTeacher(input: CreateTeacherInput): Promise<UserDto>
passwordHash: await hashPassword(input.password ?? env.defaultTeacherPassword),
},
});
await prisma.permissionAudit.create({
data: auditData(actor, 'teacher-create', 'user', created.id, null, {
role: created.role,
name: created.name,
}),
});
return toUserDto(created);
}
@@ -53,29 +66,50 @@ async function findTeacherOrThrow(teacherId: string): Promise<{ id: string; role
return teacher;
}
export async function updateTeacher(teacherId: string, input: UpdateTeacherInput): Promise<UserDto> {
export async function updateTeacher(
actor: TokenPayload,
teacherId: string,
input: UpdateTeacherInput,
): Promise<UserDto> {
assertAdmin(actor);
const teacher = await findTeacherOrThrow(teacherId);
const updated = await prisma.user.update({
where: { id: teacher.id },
data: {
...(input.name ? { name: input.name } : {}),
...(input.username ? { username: input.username } : {}),
...(input.role ? { role: input.role } : {}),
},
const updated = await prisma.$transaction(async (tx) => {
if (teacher.role === 'ADMIN' && input.role && input.role !== 'ADMIN') await protectLastAdmin(tx);
const result = await tx.user.update({
where: { id: teacher.id },
data: {
...(input.name ? { name: input.name } : {}),
...(input.username ? { username: input.username } : {}),
...(input.role ? { role: input.role } : {}),
},
});
await tx.permissionAudit.create({
data: auditData(actor, 'teacher-update', 'user', teacher.id, teacher, {
role: result.role,
name: result.name,
}),
});
return result;
});
disconnectUserSessions([teacher.id]);
return toUserDto(updated);
}
/** 重置教师密码(未指定时用默认初始密码) */
export async function resetTeacherPassword(
actor: TokenPayload,
teacherId: string,
input: ResetTeacherPasswordInput,
): Promise<void> {
assertAdmin(actor);
const teacher = await findTeacherOrThrow(teacherId);
await prisma.user.update({
where: { id: teacher.id },
data: { passwordHash: await hashPassword(input.newPassword ?? env.defaultTeacherPassword) },
});
await prisma.permissionAudit.create({
data: auditData(actor, 'teacher-password', 'user', teacher.id, null, { passwordChanged: true }),
});
}
/**
@@ -101,8 +135,11 @@ export async function resetTeacherPassword(
* 因此只有"没有留下任何班级共享内容"的账号才允许删除(实践中就是刚录入错的那条),
* 否则返回 409 让管理员先转移/清理。
*/
export async function deleteTeacher(userId: string): Promise<void> {
export async function deleteTeacher(actor: TokenPayload, userId: string): Promise<void> {
assertAdmin(actor);
const teacher = await findTeacherOrThrow(userId);
if (teacher.role === 'ADMIN' && (await prisma.user.count({ where: { role: 'ADMIN' } })) <= 1)
throw ApiError.conflict('不能删除最后一个管理员');
const [ownedClasses, courses, homeworks, notifications, coursewares] = await Promise.all([
prisma.class.count({ where: { teacherId: teacher.id } }),
@@ -128,7 +165,21 @@ export async function deleteTeacher(userId: string): Promise<void> {
);
}
if (coursewares > 0) throw ApiError.conflict(`「${teacher.name}」还有 ${coursewares} 个课件,请先清理课件再删除账号`);
if (coursewares > 0)
throw ApiError.conflict(`「${teacher.name}」还有 ${coursewares} 个课件,请先清理课件再删除账号`);
await prisma.user.delete({ where: { id: teacher.id } });
await prisma.$transaction(async (tx) => {
if (teacher.role === 'ADMIN') await protectLastAdmin(tx);
await tx.user.delete({ where: { id: teacher.id } });
await tx.permissionAudit.create({
data: auditData(actor, 'teacher-delete', 'user', teacher.id, teacher, null),
});
});
disconnectUserSessions([teacher.id]);
}
async function protectLastAdmin(tx: Prisma.TransactionClient) {
await tx.user.updateMany({ where: { role: 'ADMIN' }, data: { role: 'ADMIN' } });
if ((await tx.user.count({ where: { role: 'ADMIN' } })) <= 1)
throw ApiError.conflict('不能删除或降级最后一个管理员');
}
@@ -1,3 +1,4 @@
import { refreshClassSubscriptions } from '../../realtime/bus.js';
import { createHash, randomUUID } from 'node:crypto';
import fs from 'node:fs/promises';
import path from 'node:path';
@@ -441,6 +442,7 @@ async function executePromotion(year: number, options: RunOptions) {
for (const track of preview.tracks)
for (const cls of track.classes)
emitToClass(cls.id, SOCKET_EVENTS.classUpdated, { classId: cls.id, action: 'updated' });
refreshClassSubscriptions();
await notifyScheduleChanged();
return { ...(await prisma.promotionRun.findUniqueOrThrow({ where: { year } })), alreadyDone: false };
}
@@ -9,7 +9,13 @@ import {
} from './promotion.service.js';
import { Router } from 'express';
import { ApiError, sendOk } from '../../lib/http.js';
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
import {
authenticate,
getAuthUser,
requireAnyPermission,
requirePermissionOrClassDevice,
requirePermission,
} from '../../middleware/auth.js';
import { validate, validatedBody, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import {
@@ -74,16 +80,21 @@ router.post(
* - **写**:仅管理员 —— 周次是全校口径,改错会让所有班的课表整体错位;
* - `classId` 留空 = 全校默认;某个班有自己的配置时优先用它。
*/
router.get('/', requirePermission('term:read'), validate({ query: termQuerySchema }), async (req, res) => {
const user = getAuthUser(req);
const { classId } = validatedQuery<{ classId?: string }>(req);
sendOk(res, await termService.getTermWeeks(user, classId), '获取学期周次成功');
});
router.get(
'/',
requirePermissionOrClassDevice('term:read'),
validate({ query: termQuerySchema }),
async (req, res) => {
const user = getAuthUser(req);
const { classId } = validatedQuery<{ classId?: string }>(req);
sendOk(res, await termService.getTermWeeks(user, classId), '获取学期周次成功');
},
);
/** PUT /api/term - 保存逐周区间(整表覆盖,仅管理员) */
router.put(
'/',
requirePermission('term:write'),
requireAnyPermission('term:write', 'class:settings'),
validate({ body: updateTermWeeksSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -102,7 +113,7 @@ router.put(
*/
router.post(
'/auto',
requirePermission('term:write'),
requireAnyPermission('term:write', 'class:settings'),
validate({ body: autoTermWeeksSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -121,7 +132,7 @@ router.post(
*/
router.get(
'/holidays',
requirePermission('term:write'),
requireAnyPermission('term:write', 'class:settings'),
validate({ query: holidayQuerySchema }),
async (req, res) => {
const { year } = validatedQuery<{ year?: number }>(req);
@@ -1,3 +1,4 @@
import { assertAdmin, assertHeadOnly } from '../../lib/authz/index.js';
import {
buildAutoTermWeeks,
validateTermWeeks,
@@ -38,9 +39,15 @@ function scopeOf(classId?: string | null): string {
/** 读取某个范围的周次配置 */
export async function getTermWeeks(user: TokenPayload, classId?: string | null): Promise<TermWeeksDto> {
const scope = scopeOf(classId);
if (scope) await assertClassAccess(user, scope);
const scope = user.role === 'CLASS_DEVICE' ? (user.classId ?? user.sub) : scopeOf(classId);
if (user.role === 'CLASS_DEVICE' && classId && classId !== scope)
throw ApiError.forbidden('不能读取其他班级周次');
if (!scope) await assertPermission(user, 'term:read');
if (scope) await assertClassAccess(user, scope, 'term:read');
return readTermWeeks(scope);
}
async function readTermWeeks(scope: string): Promise<TermWeeksDto> {
const [weeks] = await Promise.all([
prisma.termWeek.findMany({ where: { classId: scope }, orderBy: { weekNumber: 'asc' } }),
scope
@@ -75,8 +82,9 @@ export async function updateTermWeeks(
user: TokenPayload,
input: UpdateTermWeeksInput,
): Promise<TermWeeksDto> {
await assertPermission(user, 'term:write');
const scope = scopeOf(input.classId);
if (scope) await assertHeadOnly(user, 'class:settings', scope);
else assertAdmin(user);
const maximum = input.maxTermWeeks ?? (await getMaxTermWeeks());
if (input.maxTermWeeks !== undefined && scope) throw ApiError.badRequest('总周数只能在全校默认范围修改');
@@ -132,7 +140,7 @@ export async function updateTermWeeks(
logger.info(`学期周次已更新:范围=${scope || '全校默认'} 周数=${input.weeks.length} 由 ${user.name} 执行`);
if (input.maxTermWeeks !== undefined) await setSetting(SETTING_KEYS.termMaxWeeks, String(maximum));
await publishTermChange(scope);
return getTermWeeks(user, scope || null);
return readTermWeeks(scope);
}
/**
@@ -142,8 +150,9 @@ export async function updateTermWeeks(
* 比从空白开始一周周填要快得多。
*/
export async function autoTermWeeks(user: TokenPayload, input: AutoTermWeeksInput): Promise<TermWeeksDto> {
await assertPermission(user, 'term:write');
const scope = scopeOf(input.classId);
if (scope) await assertHeadOnly(user, 'class:settings', scope);
else assertAdmin(user);
const context = await loadTermContext(scope || null);
const termStartDate = input.termStartDate || context.termStartDate || env.termStartDate;
const maxWeek = input.maxWeek ?? context.maxWeek;
@@ -1,3 +1,5 @@
import type { TokenPayload } from '../../lib/jwt.js';
import { assertAdmin } from '../../lib/authz/index.js';
import { getReleaseProgress, setReleaseProgress } from './release-progress.js';
import { Transform } from 'node:stream';
import crypto from 'node:crypto';
@@ -54,10 +56,14 @@ function releaseRoot(): string {
}
function releaseDir(version: string): string {
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(version) || version.includes('..'))
throw ApiError.badRequest('版本号不是安全路径段');
return path.join(releaseRoot(), version);
}
function releaseFilePath(version: string, fileName: string): string {
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(fileName) || fileName.includes('..'))
throw ApiError.badRequest('文件名不是安全路径段');
return path.join(releaseDir(version), fileName);
}
@@ -128,9 +134,11 @@ export async function resolveReleaseFile(
/** 登记一份已经在磁盘上的安装包:算 sha256 / 大小后落库(可重复登记,会覆盖) */
export async function registerRelease(
userId: string,
actor: TokenPayload,
input: RegisterClientReleaseInput,
): Promise<ClientReleaseDto> {
assertAdmin(actor);
const userId = actor.sub;
const version = input.version;
// 文件名默认走发布命名约定(与 GitHub Release、官网兜底直链一致)
const known = (await prisma.clientRelease.findUnique({ where: { version } })) as ReleaseRow | null;
@@ -171,11 +179,12 @@ export async function registerRelease(
/** 让服务端从 GitHub 拉一份安装包并登记(教室无外网时由服务端代下) */
export async function fetchReleaseFromGithub(
userId: string,
actor: TokenPayload,
input: FetchClientReleaseInput,
): Promise<ClientReleaseDto> {
if (['downloading', 'verifying'].includes(getReleaseProgress().stage))
throw ApiError.conflict('已有安装包正在下载');
assertAdmin(actor);
const version = input.version;
setReleaseProgress({ version, stage: 'downloading', downloadedBytes: 0, totalBytes: 0, error: '' });
const fileName = clientReleaseFileName(version);
@@ -221,13 +230,14 @@ export async function fetchReleaseFromGithub(
// 落盘后统一走登记:sha256 / 大小 / 落库都在同一处算
setReleaseProgress({ stage: 'verifying' });
const release = await registerRelease(userId, { version, fileName });
const release = await registerRelease(actor, { version, fileName });
setReleaseProgress({ stage: 'ready', downloadedBytes: release.size, totalBytes: release.size });
return release;
}
/** 删除一份缓存的安装包(元数据 + 磁盘文件) */
export async function deleteRelease(version: string): Promise<{ version: string }> {
export async function deleteRelease(actor: TokenPayload, version: string): Promise<{ version: string }> {
assertAdmin(actor);
const row = (await prisma.clientRelease.findUnique({ where: { version } })) as ReleaseRow | null;
if (!row) throw ApiError.notFound('服务端没有这个版本的安装包');
await prisma.clientRelease.delete({ where: { version } });
@@ -1,3 +1,4 @@
import { assertClassDevice, assertPermission, classIdsFor } from '../../lib/authz/index.js';
import { getReleaseProgress, setReleaseProgress } from './release-progress.js';
import { Router } from 'express';
import { sendCreated, sendOk, ApiError } from '../../lib/http.js';
@@ -7,9 +8,10 @@ import {
hasAuthPermission,
requireAnyPermission,
requirePermission,
requireStaffPermission,
requireAdmin,
requirePermissionOrClassDevice,
} from '../../middleware/auth.js';
import { validate, validatedBody, validatedParams } from '../../middleware/validate.js';
import { validateBadRequest as validate, validatedBody, validatedParams } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import * as clientReleases from './client-releases.service.js';
import * as updateService from './update.service.js';
@@ -39,7 +41,7 @@ router.get('/client/progress', requirePermission('client:update'), async (_req,
* 权限:任何已登录用户都能查(信息本身来自公开仓库)。`force=1` 绕过服务端缓存,
* 但**只有管理员生效** —— 否则任意登录用户都能靠刷这个参数把匿名限流打满。
*/
router.get('/check', requireStaffPermission('update:check'), async (req, res) => {
router.get('/check', requirePermissionOrClassDevice('update:check'), async (req, res) => {
const force = req.query.force === '1' && hasAuthPermission(req, 'update:force');
sendOk(res, await updateService.checkForUpdates({ force }), '更新检查完成');
});
@@ -68,13 +70,14 @@ router.get(
*/
router.post(
'/client/fetch',
requireAdmin(),
requirePermission('client:update'),
validate({ body: fetchClientReleaseSchema }),
async (req, res) => {
const user = getAuthUser(req);
const input = validatedBody<FetchClientReleaseInput>(req);
try {
sendCreated(res, await clientReleases.fetchReleaseFromGithub(user.sub, input), '安装包已下载并登记');
sendCreated(res, await clientReleases.fetchReleaseFromGithub(user, input), '安装包已下载并登记');
} catch (error) {
if (
!(error instanceof ApiError && error.status === 409) &&
@@ -97,23 +100,25 @@ router.post(
*/
router.post(
'/client/releases',
requireAdmin(),
requirePermission('client:update'),
validate({ body: registerClientReleaseSchema }),
async (req, res) => {
const user = getAuthUser(req);
const input = validatedBody<RegisterClientReleaseInput>(req);
sendCreated(res, await clientReleases.registerRelease(user.sub, input), '安装包已登记');
sendCreated(res, await clientReleases.registerRelease(user, input), '安装包已登记');
},
);
/** DELETE /api/update/client/releases/:version - 删除缓存(元数据 + 磁盘文件) */
router.delete(
'/client/releases/:version',
requireAdmin(),
requirePermission('client:update'),
validate({ params: clientReleaseVersionParamSchema }),
async (req, res) => {
const { version } = validatedParams<{ version: string }>(req);
sendOk(res, await clientReleases.deleteRelease(version), '安装包已删除');
sendOk(res, await clientReleases.deleteRelease(getAuthUser(req), version), '安装包已删除');
},
);
@@ -128,6 +133,13 @@ router.get(
'/client/download/:version',
validate({ params: clientReleaseVersionParamSchema }),
async (req, res) => {
const user = getAuthUser(req);
if (user.role === 'CLASS_DEVICE') assertClassDevice(user, user.classId ?? user.sub);
else {
await assertPermission(user, 'client:read');
if (user.role !== 'ADMIN' && !(await classIdsFor(user, 'client:read')).length)
throw ApiError.forbidden('无可访问班级');
}
const { version } = validatedParams<{ version: string }>(req);
const found = await clientReleases.resolveReleaseFile(version);
if (!found) throw ApiError.notFound('服务端没有缓存这个版本的安装包');
@@ -13,7 +13,7 @@ export const clientReleaseVersionSchema = z
.trim()
.min(1, '请输入版本号')
.max(40, '版本号最多 40 个字符')
.refine((value) => SAFE_SEGMENT.test(value), {
.refine((value) => SAFE_SEGMENT.test(value) && value !== '.' && value !== '..' && !value.includes('..'), {
message: '版本号只能包含字母、数字、点、下划线与短横线',
});
@@ -30,7 +30,9 @@ export const registerClientReleaseSchema = z.object({
.trim()
.min(1)
.max(120)
.refine((value) => SAFE_SEGMENT.test(value), { message: '文件名只能包含字母、数字、点、下划线与短横线' })
.refine((value) => SAFE_SEGMENT.test(value) && value !== '.' && value !== '..' && !value.includes('..'), {
message: '文件名只能包含字母、数字、点、下划线与短横线',
})
.optional(),
notes: z.string().trim().max(500).optional(),
});
+14 -2
View File
@@ -1,4 +1,4 @@
import { SOCKET_ROOMS } from '@classhelper/shared';
import { SOCKET_ROOMS, SOCKET_EVENT_CHANNELS } from '@classhelper/shared';
/**
* 实时事件总线。
@@ -21,7 +21,19 @@ export function emitToRooms(rooms: string[], event: string, payload: unknown): v
/** 按班级房间广播(class:{classId}) */
export function emitToClass(classId: string, event: string, payload: unknown): void {
emitToRooms([SOCKET_ROOMS.class(classId)], event, payload);
emitToRooms([SOCKET_ROOMS.classChannel(classId, SOCKET_EVENT_CHANNELS[event] ?? 'core')], event, payload);
}
let disconnectUsers: (ids: string[] | null) => void = () => {};
export function registerSessionInvalidator(invalidate: (ids: string[] | null) => void) {
disconnectUsers = invalidate;
}
export function disconnectUserSessions(ids: string[]) {
disconnectUsers(ids);
}
/** 班级集合或任课关系变化后,重连重新解析按键范围。 */
export function refreshClassSubscriptions() {
disconnectUsers(null);
}
/**
+91 -103
View File
@@ -3,36 +3,42 @@ import { Server } from 'socket.io';
import {
SOCKET_EVENTS,
SOCKET_ROOMS,
SOCKET_CHANNELS,
type SocketChannel,
type PermissionKey,
type ClientToServerEvents,
type ServerToClientEvents,
type SocketAuthPayload,
type UserRole,
} from '@classhelper/shared';
import { env } from '../config/env.js';
import { getAccessibleClassIds } from '../lib/access.js';
import { verifyToken } from '../lib/jwt.js';
import { verifyToken, type TokenPayload } from '../lib/jwt.js';
import { loadSessionUser } from '../lib/authz/session.js';
import { classIdsFor, loadAuthPrincipal, classDeviceClassId } from '../lib/authz/index.js';
import { logger } from '../lib/logger.js';
import { registerEmitter, type RealtimeEmitter } from './bus.js';
import { registerEmitter, registerSessionInvalidator } from './bus.js';
interface SocketData {
user: {
id: string;
username: string;
name: string;
role: UserRole;
classId: string | null;
classSession: boolean;
};
user: TokenPayload;
token: string;
}
export type RealtimeServer = Server<
ClientToServerEvents,
ServerToClientEvents,
Record<string, never>,
SocketData
>;
/** 绑定 Socket.IO 到 HTTP 服务,并注册到事件总线 */
const channelKeys: Record<SocketChannel, PermissionKey[]> = {
core: ['class:read', 'schedule:read'],
homework: ['homework:read'],
notification: ['notification:read'],
grade: ['grade:read'],
courseware: ['courseware:read'],
};
async function channelClasses(user: TokenPayload, channel: SocketChannel): Promise<Set<string>> {
const id = classDeviceClassId(user);
if (id) return new Set([id]);
return new Set((await Promise.all(channelKeys[channel].map((key) => classIdsFor(user, key)))).flat());
}
export function initRealtime(httpServer: HttpServer): RealtimeServer {
const io: RealtimeServer = new Server(httpServer, {
cors: { origin: env.corsOrigins, credentials: true },
@@ -40,110 +46,92 @@ export function initRealtime(httpServer: HttpServer): RealtimeServer {
pingInterval: 25000,
pingTimeout: 20000,
});
// 握手鉴权:客户端通过 handshake.auth.token 传入 JWT
io.use((socket, next) => {
try {
const auth = (socket.handshake.auth ?? {}) as Partial<SocketAuthPayload>;
const queryToken =
typeof socket.handshake.query.token === 'string' ? socket.handshake.query.token : null;
const token = auth.token ?? queryToken;
if (!token) {
next(new Error('缺少认证 token'));
return;
void (async () => {
try {
const auth = socket.handshake.auth as Partial<SocketAuthPayload>;
const token =
auth.token ??
(typeof socket.handshake.query.token === 'string' ? socket.handshake.query.token : '');
const user = await loadSessionUser(verifyToken(token));
await loadAuthPrincipal(user);
socket.data = { user, token };
next();
} catch {
next(new Error('认证失败,请重新登录'));
}
const payload = verifyToken(token);
// ClassHelper 班级端:socket.data.user.id 用**班级 id**,据此加入 session:{classId} 房间,
// 「叫人」等定向消息因此能直达教室机器(见 calls.service)。
socket.data.user = {
id: payload.classSession ? (payload.classId ?? payload.sub) : payload.sub,
username: payload.classSession ? (payload.classCode ?? payload.username) : payload.username,
name: payload.name,
role: payload.role,
classId: payload.classId,
classSession: payload.classSession === true,
};
next();
} catch {
next(new Error('认证失败,请重新登录'));
}
})();
});
io.on('connection', (socket) => {
const user = socket.data.user;
void (async () => {
const rooms = [SOCKET_ROOMS.session(user.id), SOCKET_ROOMS.role(user.role)];
try {
const classIds = await getAccessibleClassIds({
sub: user.id,
username: user.username,
name: user.name,
role: user.role,
classId: user.classId,
classSession: user.classSession,
});
for (const classId of classIds) {
await socket.join(SOCKET_ROOMS.class(classId));
rooms.push(SOCKET_ROOMS.class(classId));
}
} catch (error) {
logger.warn(`加入班级房间失败(user=${user.id})`, error);
}
// ClassHelper 班级端归入 students 房间(它代表全班),教师/管理员归入 teachers
const audienceRoom = user.role === 'CLASS_DEVICE' ? SOCKET_ROOMS.students : SOCKET_ROOMS.teachers;
await socket.join(audienceRoom);
rooms.push(audienceRoom);
logger.info(`实时连接建立:${user.name}(${user.role}) sid=${socket.id}`);
socket.emit(SOCKET_EVENTS.connected, { sessionId: user.id, role: user.role, rooms });
})();
// 客户端可显式订阅某个班级(服务端二次校验权限)
socket.on('class:join', (classId, ack) => {
// 手动订阅每次回查账号与权限;不沿用握手时的权限快照。
const ready = (async () => {
const base = [
SOCKET_ROOMS.session(user.sub),
SOCKET_ROOMS.role(user.role),
user.role === 'CLASS_DEVICE' ? SOCKET_ROOMS.students : SOCKET_ROOMS.teachers,
];
await socket.join(base);
for (const channel of SOCKET_CHANNELS)
for (const id of await channelClasses(user, channel))
await socket.join(SOCKET_ROOMS.classChannel(id, channel));
socket.emit(SOCKET_EVENTS.connected, {
sessionId: user.sub,
role: user.role,
rooms: [...socket.rooms].filter((r) => r !== socket.id),
});
})().catch((error) => {
logger.warn('实时订阅失败', error);
socket.disconnect(true);
});
socket.on('class:join', (request, ack) => {
void (async () => {
await ready;
try {
const allowed = await getAccessibleClassIds({
sub: user.id,
username: user.username,
name: user.name,
role: user.role,
classId: user.classId,
classSession: user.classSession,
});
if (!allowed.includes(classId)) {
ack?.({ ok: false, message: '无权订阅该班级' });
const current = await loadSessionUser(verifyToken(socket.data.token));
const classId = typeof request === 'string' ? request : request.classId;
const requested =
typeof request === 'string' ? SOCKET_CHANNELS : (request.channels ?? SOCKET_CHANNELS);
if (!requested.length || requested.some((c) => !SOCKET_CHANNELS.includes(c))) {
ack?.({ ok: false, message: '未知事件通道' });
return;
}
await socket.join(SOCKET_ROOMS.class(classId));
const allowed: SocketChannel[] = [];
for (const channel of requested)
if ((await channelClasses(current, channel)).has(classId)) allowed.push(channel);
// 旧字符串协议只加入有权通道;新协议显式通道有任一无权则全部拒绝。
if (!allowed.length || (typeof request !== 'string' && allowed.length !== requested.length)) {
ack?.({ ok: false, message: '无权订阅该班级或通道' });
return;
}
for (const channel of allowed) await socket.join(SOCKET_ROOMS.classChannel(classId, channel));
ack?.({ ok: true });
} catch {
ack?.({ ok: false, message: '订阅失败' });
}
})();
});
socket.on('class:leave', (classId) => {
void socket.leave(SOCKET_ROOMS.class(classId));
});
socket.on('ping', (ack) => {
ack?.();
});
socket.on('disconnect', (reason) => {
logger.debug(`实时连接断开:${user.name} (${reason})`);
socket.on('class:leave', (request) => {
void (async () => {
await ready;
const classId = typeof request === 'string' ? request : request.classId;
for (const channel of typeof request === 'string'
? SOCKET_CHANNELS
: (request.channels ?? SOCKET_CHANNELS))
await socket.leave(SOCKET_ROOMS.classChannel(classId, channel));
})();
});
socket.on('ping', (ack) => ack?.());
});
registerEmitter((rooms, event, payload) => {
(io.to(rooms) as unknown as { emit(event: string, payload: unknown): void }).emit(event, payload);
});
registerSessionInvalidator((ids) => {
if (ids === null) {
io.in(SOCKET_ROOMS.teachers).disconnectSockets(true);
return;
}
for (const id of ids) io.in(SOCKET_ROOMS.session(id)).disconnectSockets(true);
});
const emit: RealtimeEmitter = (rooms, event, payload) => {
const target = io.to(rooms) as unknown as { emit: (event: string, payload: unknown) => void };
target.emit(event, payload);
logger.debug(`实时推送 ${event} -> ${rooms.join(',')}`);
};
registerEmitter(emit);
logger.info('Socket.IO 实时通道已就绪(房间规则:class:{classId} / session:{id})');
return io;
}