feat(authz): 重写权限模板分配与班级授权边界
拆分能力、分配范围和业务关系,固定管理员能力并收紧组织级写入口。 补齐内容、课表、设备会话和实时通道授权,兼容旧库分配及历史快照。 重写权限分配预览,按实际可操作范围展示维护入口,合并学生入口并独立学年升级页。 增加授权攻击矩阵及静态门禁,更新隔离回归与开发契约。 验证:授权矩阵 23 段通过,Node 24 下 Web 63/63,类型检查、lint 与文档严格构建通过。
This commit is contained in:
1 parent
682818dd4a
commit
66be3a63b1
115 files changed
+4463
-2747
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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('只有班主任或本班任课老师可以发布');
|
||||
}
|
||||
@@ -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 } }))
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user