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

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

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

No files matched your search

@@ -60,9 +60,10 @@ export const useRealtimeStore = defineStore('realtime', () => {
connecting.value = false;
dispatch('__connected__', null);
});
instance.on('disconnect', () => {
instance.on('disconnect', (reason: string) => {
connected.value = false;
dispatch('__disconnected__', null);
if (reason === 'io server disconnect' && socket === instance) instance.connect();
});
instance.on('connect_error', (error: Error) => {
connected.value = false;
@@ -0,0 +1,7 @@
ALTER TABLE "UserPermissionGroup" ADD COLUMN "scope" TEXT NOT NULL DEFAULT 'TEACHING';
ALTER TABLE "UserPermissionGroup" ADD COLUMN "scopeValue" TEXT NOT NULL DEFAULT '';
UPDATE "UserPermissionGroup" SET "scope" = COALESCE((SELECT CASE WHEN g."scope" = 'SELF_CLASS' THEN CASE WHEN g."scopeValue" = 'HEAD' THEN 'HEAD' ELSE 'TEACHING' END ELSE g."scope" END FROM "PermissionGroup" g WHERE g."id" = "groupId"), 'TEACHING'), "scopeValue" = COALESCE((SELECT CASE WHEN g."scope" IN ('GRADE', 'DIVISION') THEN g."scopeValue" ELSE '' END FROM "PermissionGroup" g WHERE g."id" = "groupId"), '');
UPDATE "PermissionGroup" SET "scope" = CASE WHEN "scopeValue" = 'HEAD' THEN 'HEAD' ELSE 'TEACHING' END, "scopeValue" = '' WHERE "scope" = 'SELF_CLASS';
UPDATE "PermissionGroup" SET "scope" = 'TEACHING', "scopeValue" = '' WHERE "defaultRole" = 'TEACHER';
DROP INDEX "UserPermissionGroup_userId_groupId_key";
CREATE UNIQUE INDEX "UserPermissionGroup_userId_groupId_scope_scopeValue_key" ON "UserPermissionGroup"("userId", "groupId", "scope", "scopeValue");
+6 -2
View File
@@ -451,7 +451,8 @@ model Grade {
}
model PermissionGroup {
scope String @default("SELF_CLASS")
/// 新分配的建议范围,不参与已有分配求值。
scope String @default("TEACHING")
scopeValue String @default("")
/// 角色默认组;自定义组为空,改名不影响对应角色。
defaultRole String? @unique
@@ -483,6 +484,9 @@ model PermissionGroupItem {
/// 账号 ←→ 权限组 的分配关系(一个账号可属多个组)
model UserPermissionGroup {
/// 此条分配的生效范围。
scope String @default("TEACHING")
scopeValue String @default("")
id String @id @default(cuid())
userId String
groupId String
@@ -491,7 +495,7 @@ model UserPermissionGroup {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
group PermissionGroup @relation(fields: [groupId], references: [id], onDelete: Cascade)
@@unique([userId, groupId])
@@unique([userId, groupId, scope, scopeValue])
@@index([userId])
@@index([groupId])
}
+516
View File
@@ -0,0 +1,516 @@
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import { io as connect, type Socket } from 'socket.io-client';
import {
PERMISSION_KEYS,
PERMISSION_META,
SOCKET_CHANNELS,
SOCKET_ROOMS,
SOCKET_EVENTS,
parsePermissionScope,
} from '@classhelper/shared';
import { prisma } from '../src/lib/db.js';
import { createApp } from '../src/app.js';
import { signToken, type TokenPayload } from '../src/lib/jwt.js';
import { ensureDefaultGroups } from '../src/lib/permissions.js';
import { classIdsFor } from '../src/lib/authz/index.js';
import { initRealtime } from '../src/realtime/socket.js';
import { emitToClass } from '../src/realtime/bus.js';
import { exportSnapshot, restoreSnapshot } from '../src/lib/snapshot.js';
if (!process.env.DATABASE_URL?.includes('authz-verification'))
throw new Error('只能在授权验收临时数据库运行');
let count = 0;
const check = (name: string) => console.log(`通过 ${++count}:${name}`);
async function account(name: string, role: TokenPayload['role'] = 'TEACHER'): Promise<TokenPayload> {
const row = await prisma.user.create({
data: { username: name, name, role, passwordHash: '隔离夹具,不使用真实账号' },
});
return { sub: row.id, username: name, name, role, classId: null };
}
const admin = await account('authz-admin', 'ADMIN'),
aTeacher = await account('authz-A'),
bTeacher = await account('authz-B'),
unrelated = await account('authz-C'),
lead = await account('authz-grade'),
division = await account('authz-division');
await ensureDefaultGroups();
const cls = async (name: string, grade: string, teacherId: string, index: number) =>
prisma.class.create({
data: {
name,
grade,
teacherId,
classIndex: index,
enrollmentYear: 2026,
code: `AUTHZ${index}`,
passwordHash: '隔离',
},
});
const A = await cls('授权A', '高一', aTeacher.sub, 1),
B = await cls('授权B', '高一', bTeacher.sub, 2),
_C = await cls('授权C', '高二', bTeacher.sub, 3);
const course = async (classId: string, name: string, teacherId: string) =>
prisma.course.create({ data: { classId, name, teacherId } });
const mathB = await course(B.id, '数学', aTeacher.sub),
chineseB = await course(B.id, '语文', bTeacher.sub),
mathA = await course(A.id, '数学', aTeacher.sub);
const studentA = await prisma.student.create({ data: { studentNo: 'AUTHZA', name: '临时A', classId: A.id } }),
studentB = await prisma.student.create({ data: { studentNo: 'AUTHZB', name: '临时B', classId: B.id } });
const device = (c: typeof A): TokenPayload => ({
sub: c.id,
username: c.code,
name: c.name,
role: 'CLASS_DEVICE',
classId: c.id,
classSession: true,
classCode: c.code,
});
const aDevice = device(A),
bDevice = device(B);
const server = createServer(createApp()),
realtime = initRealtime(server);
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const origin = `http://127.0.0.1:${(server.address() as { port: number }).port}`,
base = origin + '/api';
async function request(url: string, actor: TokenPayload, method = 'GET', body?: unknown, expected = 200) {
const res = await fetch(base + url, {
method,
headers: { authorization: `Bearer ${signToken(actor)}`, 'content-type': 'application/json' },
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
const json = await res.json();
assert.equal(res.status, expected, `${method} ${url} ${JSON.stringify(json)}`);
return json.data;
}
async function group(name: string, keys: string[], scope = 'TEACHING', scopeValue = '') {
return prisma.permissionGroup.create({
data: { name, scope, scopeValue, items: { create: keys.map((key) => ({ key })) } },
});
}
async function assign(actor: TokenPayload, rows: { groupId: string; scope: string; scopeValue?: string }[]) {
await prisma.userPermissionGroup.deleteMany({ where: { userId: actor.sub } });
if (rows.length)
await prisma.userPermissionGroup.createMany({
data: rows.map((r) => ({ userId: actor.sub, ...r, scopeValue: r.scopeValue ?? '' })),
});
}
const sockets: Socket[] = [];
async function socket(actor: TokenPayload) {
const s = connect(origin, {
auth: { token: signToken(actor) },
transports: ['websocket'],
reconnection: false,
});
sockets.push(s);
const info = await new Promise<{ rooms: string[] }>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('实时连接超时')), 5000);
s.once('connected', (data) => {
clearTimeout(timer);
resolve(data);
});
s.once('connect_error', reject);
});
return { s, rooms: info.rooms };
}
try {
// 旧残留管理员键全部装备给科任,管理员能力仍永久无效。
const old = await group('旧残留全能力', [...PERMISSION_KEYS], 'ALL');
await assign(aTeacher, [{ groupId: old.id, scope: 'ALL' }]);
const mine = await request('/permissions/me', aTeacher);
assert(
!mine.permissions.some((key: keyof typeof PERMISSION_META) => PERMISSION_META[key].kind === 'admin'),
);
assert.equal((await request('/permissions/me', admin)).permissions.length, PERMISSION_KEYS.length);
check('管理员全集;旧组残留管理员键对教师无效');
for (const [url, method, body] of [
[`/classes/${B.id}/class-account`, 'PATCH', { password: 'authz-test' }],
[`/classes/${B.id}/settings`, 'PATCH', { termWeeks: 12 }],
[`/classes/${B.id}/head-teacher`, 'PATCH', { teacherId: aTeacher.sub }],
[
`/classes/${B.id}/subject-teachers`,
'PUT',
{ assignments: [{ subjectName: '语文', teacherId: aTeacher.sub }] },
],
['/classes', 'POST', { grade: '高一', enrollmentYear: 2029, classIndex: 99 }],
['/teachers', 'POST', { username: 'intruder', name: '攻击', role: 'ADMIN' }],
[`/teachers/${admin.sub}`, 'PATCH', { role: 'TEACHER' }],
[`/teachers/${admin.sub}/reset-password`, 'POST', {}],
[`/teachers/${admin.sub}`, 'DELETE', undefined],
['/permissions/groups', 'POST', { name: '越权', keys: [] }],
['/system/settings', 'PUT', {}],
['/database/status', 'GET', undefined],
['/term', 'PUT', { weeks: [] }],
['/term/promotion', 'GET', undefined],
[
'/imports/table/commit',
'POST',
{ kind: 'classTeachers', fileName: 'authz.csv', contentBase64: '', mapping: {}, mode: 'append' },
],
] as const)
await request(url, aTeacher, method, body, 403);
assert.equal((await request(`/classes/${B.id}`, aTeacher)).code, undefined);
check('科任不能接管班级账号、学期设置、人员关系或组织写入口,班级码不泄漏');
await request(`/classes/${A.id}/class-account`, aTeacher, 'PATCH', { code: 'AUTHZA1' });
await request(`/classes/${A.id}/settings`, aTeacher, 'PATCH', { termWeeks: 12 });
await request(`/classes/${B.id}/class-account`, admin, 'PATCH', { code: 'AUTHZB1' });
check('本班班主任可改账号与学期设置;管理员可改其他班');
await request(`/teachers/${admin.sub}`, admin, 'PATCH', { role: 'TEACHER' }, 409);
await request(`/teachers/${admin.sub}`, admin, 'DELETE', undefined, 409);
check('最后一个管理员不可降级或删除');
const helper = await request(
'/teachers',
admin,
'POST',
{ username: 'authz-new', name: '新增教师', password: 'authz-pass' },
201,
);
await request(`/teachers/${helper.id}/reset-password`, admin, 'POST', { newPassword: 'authz-pass-2' });
await request(`/teachers/${helper.id}`, admin, 'DELETE');
check('管理员账号写入口正常且产生审计');
const readA = await group(
'A的关联读取',
[
'class:read',
'homework:read',
'notification:read',
'grade:read',
'dashboard:read',
'schedule:read',
'roster:read',
],
'HEAD',
);
const otherAll = await group('无关全校键', ['client:read'], 'ALL');
await assign(aTeacher, [
{ groupId: readA.id, scope: 'HEAD' },
{ groupId: otherAll.id, scope: 'ALL' },
]);
for (const url of [
`/grades/ranking?classId=${B.id}&examName=测试`,
`/grades/exams?classId=${B.id}`,
`/grades/collective?classId=${B.id}`,
`/homeworks?classId=${B.id}`,
`/notifications?classId=${B.id}`,
])
await request(url, aTeacher, 'GET', undefined, 403);
assert.deepEqual(
(await request('/classes', aTeacher)).map((c: { id: string }) => c.id),
[A.id],
);
const dashboard = await request('/dashboard/summary', aTeacher);
assert.equal(dashboard.classCount, 1);
assert.equal((await classIdsFor({ ...aTeacher }, undefined as never)).length, 0);
check('按键独立计算,设备全校读不能扩大内容读取;无键范围为空');
await assign(unrelated, [{ groupId: otherAll.id, scope: 'ALL' }]);
for (const url of ['/homeworks', '/notifications', '/dashboard/summary', `/grades/exams?classId=${B.id}`])
await request(url, unrelated, 'GET', undefined, 403);
const onlyDashboard = await group('仅看板', ['dashboard:read'], 'ALL');
await assign(unrelated, [{ groupId: onlyDashboard.id, scope: 'ALL' }]);
const hidden = await request('/dashboard/summary', unrelated);
assert.equal(hidden.gradeCount, 0);
assert.equal(hidden.studentCount, 0);
assert.equal(hidden.sections.grades, false);
check('宽范围无关能力不授予读权限,看板各段独立受控');
await assign(aTeacher, [{ groupId: old.id, scope: 'ALL' }]);
const hw = await prisma.homework.create({
data: {
classId: B.id,
courseId: chineseB.id,
title: '同事语文',
content: '隔离测试',
createdBy: bTeacher.sub,
},
});
const grade = await prisma.grade.create({
data: { classId: B.id, courseId: chineseB.id, studentId: studentB.id, examName: '授权考试', score: 80 },
});
await request(`/homeworks/${hw.id}`, aTeacher, 'PATCH', { courseId: mathB.id }, 403);
await request(`/grades/${grade.id}`, aTeacher, 'PATCH', { courseId: mathB.id }, 403);
await request(
`/homeworks/${hw.id}/status`,
aTeacher,
'PATCH',
{ completed: true, studentId: studentB.id },
403,
);
await request(`/homeworks/${hw.id}/submissions`, aTeacher, 'GET', undefined, 403);
await request(`/homeworks/${hw.id}/submissions`, aTeacher, 'PATCH', { notSubmittedStudentIds: [] }, 403);
await request(`/grades/exams`, aTeacher, 'PUT', { classId: B.id, name: '授权考试', countInTotal: true });
// 年级/全校授权可配置考试口径;仅任教范围的科任不能。
await assign(aTeacher, [{ groupId: old.id, scope: 'TEACHING' }]);
await request(
`/grades/exams`,
aTeacher,
'PUT',
{ classId: B.id, name: '授权考试', countInTotal: false },
403,
);
await request(`/classes/${B.id}/student-grade-query`, aTeacher, 'PATCH', { enabled: false }, 403);
check('原/目标科目双校验;未交名单与完成状态需要科目关系;科任不能改考试配置');
const ownHw = await request(
'/homeworks',
aTeacher,
'POST',
{ classId: B.id, courseId: mathB.id, title: '本人数学', content: '隔离测试' },
201,
);
await request(`/homeworks/${ownHw.id}/status`, bDevice, 'PATCH', { completed: true });
check('科任可发布本科作业,班级端可维护本班作业');
const layoutBody = {
classId: A.id,
name: '本班时间表',
points: [{ name: '第一节', startTime: '08:00', endTime: '08:40', timeType: 'LESSON' }],
};
const layout = await request('/time-layouts', aTeacher, 'POST', layoutBody, 201);
const foreign = await request(
'/time-layouts',
admin,
'POST',
{ ...layoutBody, classId: B.id, name: '别班时间表' },
201,
);
for (const method of ['PUT', 'DELETE'])
await request(
`/time-layouts/${foreign.id}`,
aTeacher,
method,
method === 'PUT' ? { ...layoutBody, classId: A.id } : undefined,
403,
);
await request(`/time-layouts/${foreign.id}/clone`, aTeacher, 'POST', { name: '偷复制' }, 403);
await request('/time-layouts', aTeacher, 'POST', { ...layoutBody, classId: null }, 403);
await request('/term', aTeacher, 'PUT', { classId: B.id, weeks: [] }, 403);
await request('/term', aTeacher, 'PUT', { classId: A.id, weeks: [] });
await request(`/time-layouts/${layout.id}`, aTeacher, 'DELETE');
check('时间表按存储班级校验;本班可写、跨班与全局拒绝;班级周次要求班主任');
const csv = `学号,姓名,班级\nAUTHZSAFE,首行,${A.name}\nAUTHZBAD,末行,${B.name}`;
await request(
'/imports/table/commit',
aTeacher,
'POST',
{
kind: 'students',
fileName: 'authz.csv',
contentBase64: Buffer.from('\ufeff' + csv).toString('base64'),
mapping: { studentNo: '学号', name: '姓名', className: '班级' },
mode: 'append',
},
403,
);
assert.equal(await prisma.student.count({ where: { studentNo: 'AUTHZSAFE' } }), 0);
check('跨班学生导入预检全部范围,403 不留下首行数据');
const defaults = await prisma.permissionGroup.findUniqueOrThrow({ where: { defaultRole: 'TEACHER' } });
await request(`/permissions/groups/${defaults.id}`, admin, 'PATCH', { scope: 'ALL' }, 400);
await request(`/permissions/groups/${defaults.id}`, admin, 'PATCH', { keys: ['teacher:manage'] }, 400);
await request(
'/permissions/groups',
admin,
'POST',
{ name: '非法年级', scope: 'GRADE', scopeValue: '不存在', keys: ['class:read'] },
400,
);
await request('/permissions/groups', admin, 'POST', { name: '非法能力', keys: ['permission:manage'] }, 400);
const legacyTemplate = await group('保留旧停用键', ['class:read', 'class:manage']);
const updatedLegacyTemplate = await request(`/permissions/groups/${legacyTemplate.id}`, admin, 'PATCH', {
keys: ['class:read', 'grade:read'],
});
assert(updatedLegacyTemplate.inactiveKeys.includes('class:manage'));
assert.equal(
await prisma.permissionGroupItem.count({ where: { groupId: legacyTemplate.id, key: 'class:manage' } }),
1,
);
await assign(unrelated, [{ groupId: legacyTemplate.id, scope: 'ALL' }]);
assert(!(await request('/permissions/me', unrelated)).permissions.includes('class:manage'));
check('默认模板固定 TEACHING;不可保存管理员能力或未知年级');
const scoped = await request(
'/permissions/groups',
admin,
'POST',
{ name: '分配级范围', scope: 'HEAD', keys: ['class:read', 'grade:read', 'class:account'] },
201,
);
await request(`/permissions/users/${lead.sub}/groups`, admin, 'PUT', {
assignments: [{ groupId: scoped.id, scope: 'GRADE', scopeValue: '高一' }],
});
await request(`/permissions/users/${division.sub}/groups`, admin, 'PUT', {
assignments: [{ groupId: scoped.id, scope: 'DIVISION', scopeValue: '高二,高一' }],
});
assert.equal((await request('/classes', lead)).length, 2);
assert.equal((await request('/classes', division)).length, 3);
const effective = await request(`/permissions/users/${lead.sub}/effective`, admin);
assert.equal(effective.entries.find((e: { key: string }) => e.key === 'class:account').classCount, 0);
const leadPermissions = await request('/permissions/me', lead);
assert.deepEqual(leadPermissions.classScopes['grade:read'].sort(), [A.id, B.id].sort());
await request(`/grades/ranking?classId=${B.id}&examName=授权考试`, lead);
await request(`/permissions/groups/${scoped.id}`, admin, 'PATCH', { scope: 'ALL' });
assert.equal((await request('/classes', lead)).length, 2);
check('模板建议范围变更不影响已有分配;级长/部长范围与班主任关系独立');
const query = encodeURIComponent(
JSON.stringify([
{ groupId: scoped.id, scope: 'HEAD', scopeValue: '' },
{ groupId: scoped.id, scope: 'GRADE', scopeValue: '高二' },
]),
);
const merged = await request(`/permissions/users/${aTeacher.sub}/effective?assignments=${query}`, admin);
assert.equal(merged.entries.find((e: { key: string }) => e.key === 'class:read').classCount, 2);
assert(merged.conflicts.length > 0);
await request(`/permissions/users/${admin.sub}/groups`, admin, 'PUT', { assignments: [] }, 400);
check('同模板多范围正确合并并预览冲突,管理员不可分配');
const beforeDefault = await request('/permissions/me', bTeacher);
await request(`/permissions/users/${bTeacher.sub}/groups`, admin, 'PUT', { assignments: [] });
assert.equal((await request('/permissions/me', bTeacher)).fromGroups, false);
assert(beforeDefault.permissions.length > 0);
check('空分配恢复默认,无隐式自动预选转换');
await request(`/classes/${B.id}/student-grade-query`, admin, 'PATCH', { enabled: false });
for (const url of [
`/grades/ranking?classId=${B.id}&examName=授权考试`,
`/grades/exams?classId=${B.id}`,
`/grades/student/${studentB.studentNo}`,
'/grades/my',
])
await request(url, bDevice, 'GET', undefined, 403);
assert.equal((await request(`/grades/collective?classId=${B.id}`, bDevice)).mode, 'collective');
await request(`/grades/collective?classId=${B.id}`, aDevice, 'GET', undefined, 403);
check('班级端关闭查询后拒绝个人/排名/原始列表,保留本班集体聚合');
const hb = {
deviceKey: 'authz-device-b',
name: '临时设备',
clientVersion: '1.0.0',
platform: 'win32',
agentProtocol: 2,
};
const d = await request('/clients/heartbeat', bDevice, 'POST', hb);
await request('/clients/heartbeat', aTeacher, 'POST', hb, 403);
await request('/clients/heartbeat', aDevice, 'POST', hb, 403);
const command = await prisma.clientCommand.create({
data: { deviceId: d.deviceId, kind: 'lock', createdBy: admin.sub, payload: '隔离假执行' },
});
for (const actor of [aTeacher, aDevice])
await request(
`/clients/commands/${command.id}/ack`,
actor,
'POST',
{ deviceKey: hb.deviceKey, ok: true },
403,
);
await request(`/clients/commands/${command.id}/ack`, bDevice, 'POST', {
deviceKey: hb.deviceKey,
ok: true,
result: '未执行任何系统操作',
});
await request(
'/clients/update-progress',
aTeacher,
'POST',
{
deviceKey: hb.deviceKey,
progress: {
version: '1.1.0',
stage: 'downloading',
downloadedBytes: 0,
totalBytes: 1,
percent: 0,
error: '',
},
},
403,
);
check('心跳/进度/回执只接受本班班级端,教师知道 deviceKey 仍不能冒用');
await request('/update/client/releases', admin, 'POST', { version: '..' }, 400);
await request('/update/client/download/1.0.0', unrelated, 'GET', undefined, 403);
check('更新路径拒绝 ..,下载要求班级端或设备读能力及范围');
const readOnly = await group('只读作业实时', ['homework:read'], 'HEAD');
await assign(aTeacher, [{ groupId: readOnly.id, scope: 'TEACHING' }]);
const previousSubjectSocket = await socket(aTeacher);
assert(previousSubjectSocket.rooms.includes(SOCKET_ROOMS.classChannel(B.id, 'homework')));
const disconnected = new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('任课导入后旧订阅未断开')), 5000);
previousSubjectSocket.s.once('disconnect', () => {
clearTimeout(timer);
resolve();
});
});
await request('/imports/table/commit', admin, 'POST', {
kind: 'classTeachers',
fileName: 'relations.csv',
mapping: { className: '班级', subjectName: '科目', teacherNo: '工号', teacherName: '姓名', role: '角色' },
contentBase64: Buffer.from(
`\ufeff班级,科目,工号,姓名,角色\n${B.name},数学,${bTeacher.username},临时教师,科任`,
).toString('base64'),
mode: 'upsert',
});
await disconnected;
const updatedSubjectSocket = await socket(aTeacher);
assert(!updatedSubjectSocket.rooms.includes(SOCKET_ROOMS.classChannel(B.id, 'homework')));
check('任课导入变更后旧房间立即失效,重连按现行关系订阅');
await assign(aTeacher, [
{ groupId: readOnly.id, scope: 'HEAD' },
{ groupId: otherAll.id, scope: 'ALL' },
]);
const { s, rooms } = await socket(aTeacher),
deviceSocket = await socket(aDevice);
assert(rooms.includes(SOCKET_ROOMS.classChannel(A.id, 'homework')));
assert(!rooms.includes(SOCKET_ROOMS.classChannel(B.id, 'homework')));
assert(!rooms.includes(SOCKET_ROOMS.classChannel(A.id, 'grade')));
assert(SOCKET_CHANNELS.every((ch) => deviceSocket.rooms.includes(SOCKET_ROOMS.classChannel(A.id, ch))));
const join = await new Promise<{ ok: boolean }>((resolve) =>
s.emit('class:join', { classId: B.id, channels: ['homework'] }, resolve),
);
assert.equal(join.ok, false);
const seen: string[] = [];
s.on(SOCKET_EVENTS.gradeUpdated, () => seen.push('grade'));
s.on(SOCKET_EVENTS.homeworkNew, () => seen.push('homework'));
emitToClass(A.id, SOCKET_EVENTS.gradeUpdated, { classId: A.id });
emitToClass(B.id, SOCKET_EVENTS.homeworkNew, { id: 'foreign' });
emitToClass(A.id, SOCKET_EVENTS.homeworkNew, { id: 'own' });
await new Promise((resolve) => setTimeout(resolve, 100));
assert.deepEqual(seen, ['homework']);
check('实时按读域与范围订阅,跨班 join 和跨域事件均拒绝');
const stale = signToken(admin);
await prisma.user.update({ where: { id: admin.sub }, data: { role: 'TEACHER' } });
const staleSocket = connect(origin, {
auth: { token: stale },
transports: ['websocket'],
reconnection: false,
});
sockets.push(staleSocket);
const staleInfo = await new Promise<{ role: string }>((resolve) => staleSocket.once('connected', resolve));
assert.equal(staleInfo.role, 'TEACHER');
await prisma.user.update({ where: { id: admin.sub }, data: { role: 'ADMIN' } });
check('Socket 与 HTTP 共用数据库身份加载,旧管理员 JWT 不保留角色');
const snapshot = await exportSnapshot(prisma, 'sqlite');
const legacy = structuredClone(snapshot);
for (const g of legacy.data.PermissionGroup)
if (g.id === readOnly.id) {
g.scope = 'SELF_CLASS';
g.scopeValue = 'HEAD';
}
for (const a of legacy.data.UserPermissionGroup) {
delete a.scope;
delete a.scopeValue;
}
await restoreSnapshot(prisma, 'sqlite', legacy);
assert.equal(
(
await prisma.userPermissionGroup.findFirstOrThrow({
where: { userId: aTeacher.sub, groupId: readOnly.id },
})
).scope,
'HEAD',
);
assert.equal(
(await prisma.permissionGroup.findUniqueOrThrow({ where: { defaultRole: 'TEACHER' } })).scope,
'TEACHING',
);
assert.throws(() => parsePermissionScope('SELF_CLASS' as never, ''));
check('旧快照按模板回填分配、默认模板归一化、运行时拒绝旧范围枚举');
check(`授权攻击矩阵完成,夹具 ${studentA.id} / ${mathA.id} 均位于临时库`);
} finally {
for (const s of sockets) s.disconnect();
await new Promise<void>((resolve) => realtime.close(() => resolve()));
server.closeAllConnections();
if (server.listening) await new Promise<void>((resolve) => server.close(() => resolve()));
await prisma.$disconnect();
}
+16 -11
View File
@@ -255,7 +255,9 @@ try {
emitted
.filter((e) => e.event === SOCKET_EVENTS.coursewareChanged)
.every((e) =>
e.rooms.every((room) => [classes[0]!.id, classes[1]!.id].some((id) => room === `class:${id}`)),
e.rooms.every((room) =>
[classes[0]!.id, classes[1]!.id].some((id) => room === `class:${id}:courseware`),
),
),
);
await new Promise<void>((resolve) => server.close(() => resolve()));
@@ -357,24 +359,27 @@ try {
state = await prisma.clientDevice.findUniqueOrThrow({ where: { id: device.id } });
check('取消目标后忽略旧任务进度', state.updateProgress === '');
const teacherDeviceKey = 'courseware-teacher-device';
await request('/clients/heartbeat', tokens[0], { deviceKey: teacherDeviceKey, clientVersion: '1.0.0' });
let teacherDevice = await prisma.clientDevice.findUniqueOrThrow({ where: { deviceKey: teacherDeviceKey } });
check('教师电脑设备不绑定班级', teacherDevice.classId === null);
await prisma.clientDevice.update({
where: { id: teacherDevice.id },
data: { updateTargetVersion: '2.0.0' },
check(
'教师会话不能注册设备心跳',
(await request('/clients/heartbeat', tokens[0], { deviceKey: teacherDeviceKey, clientVersion: '1.0.0' }))
.status === 403,
);
const teacherDevice = await prisma.clientDevice.create({
data: { deviceKey: teacherDeviceKey, updateTargetVersion: '2.0.0' },
});
check(
'教师电脑可上报自己的更新进度',
'旧未绑定设备也不能由教师冒报进度',
(
await request('/clients/update-progress', tokens[0], {
deviceKey: teacherDeviceKey,
progress: { ...updateProgress, version: '2.0.0' },
})
).status === 200,
).status === 403,
);
check(
'拒绝请求不改变旧设备进度',
(await prisma.clientDevice.findUniqueOrThrow({ where: { id: teacherDevice.id } })).updateProgress === '',
);
teacherDevice = await prisma.clientDevice.findUniqueOrThrow({ where: { deviceKey: teacherDeviceKey } });
check('教师电脑更新进度已记录', JSON.parse(teacherDevice.updateProgress).stage === 'downloading');
const snapshot = await exportSnapshot(prisma, 'sqlite');
check(
'备份包含课件、可见范围、推送和回执',
+5 -15
View File
@@ -1563,28 +1563,18 @@ async function main() {
`含 homework:write=${teacher2ResetPermissions.includes('homework:write')}`,
);
// 管理员可以分配权限组,但始终保留两个管理入口
const assignAdmin = await api(`/permissions/users/${adminMember?.id}/groups`, {
method: 'PUT',
token: adminToken,
body: { groupIds: permissionGroupId ? [permissionGroupId] : [] },
body: { assignments: [] },
});
const fixedAdmin = await api('/permissions/me', { token: adminToken });
record(
'管理员可以分配权限组(PUT /users/:id/groups → 200)',
Boolean(adminMember) &&
assignAdmin.status === 200 &&
Array.isArray(assignAdmin.payload?.data?.permissions) &&
assignAdmin.payload.data.permissions.includes('permission:manage') &&
assignAdmin.payload.data.permissions.includes('system:manage'),
`status=${assignAdmin.status} 响应=${assignAdmin.payload?.message ?? ''}`,
'管理员固定全集并拒绝分配模板',
assignAdmin.status === 400 && fixedAdmin.payload?.data?.permissions.includes('teacher:manage'),
`status=${assignAdmin.status}`,
);
await api(`/permissions/users/${adminMember?.id}/groups`, {
method: 'PUT',
token: adminToken,
body: { groupIds: [] },
});
// 内置权限组不可删除:先找一个 builtin 的(**演示库当前没有内置组**,此时只报事实、不算失败)
const groupsList = await api('/permissions/groups', { token: adminToken });
const builtinGroup = (groupsList.payload?.data ?? []).find((item) => item.builtin === true) ?? null;
+31 -42
View File
@@ -6,7 +6,7 @@ import { PERMISSION_KEYS, defaultPromotionPlan, parsePermissionScope } from '@cl
import { prisma } from '../src/lib/db.js';
import { createApp } from '../src/app.js';
import { signToken, type TokenPayload } from '../src/lib/jwt.js';
import { ensureDefaultGroups, loadEffectivePermissions } from '../src/lib/permissions.js';
import { ensureDefaultGroups, loadAuthPrincipal } from '../src/lib/permissions.js';
import { registerEmitter } from '../src/realtime/bus.js';
import * as students from '../src/modules/students/students.service.js';
import * as classes from '../src/modules/classes/classes.service.js';
@@ -53,7 +53,7 @@ const a = await cls('TESTA', '高一', 2025, 1),
c = await cls('TESTC', '高二', 2024, 1, admin.sub);
const events: { classId: string; event: string }[] = [];
registerEmitter((rooms, event) => {
for (const room of rooms) events.push({ classId: room.replace('class:', ''), event });
for (const room of rooms) events.push({ classId: room.split(':')[1], event });
});
const server = createServer(createApp());
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
@@ -91,24 +91,22 @@ try {
assert.equal((PERMISSION_KEYS as readonly string[]).includes('promotion:manage'), true),
);
const delegate = await account('临时教务授权', 'TEACHER');
const governance = await permissions.createGroup(admin, {
name: '教务授权',
scope: 'ALL',
keys: ['class:read', 'class:manage', 'archive:manage', 'promotion:manage'],
});
await permissions.assignUserGroups(admin, delegate.sub, { groupIds: [governance.id] });
const delegatedClass = await request(
'/classes',
delegate,
'POST',
{ grade: '高一', enrollmentYear: 2028, classIndex: 99 },
201,
await denied(
permissions.createGroup(admin, {
name: '不可下放组织能力',
keys: ['class:manage', 'archive:manage', 'promotion:manage'],
}),
400,
);
await request(`/classes/${delegatedClass.id}`, delegate, 'PATCH', { termWeeks: 10 });
await request(`/classes/${delegatedClass.id}`, delegate, 'DELETE');
await request('/archives', delegate);
await request('/term/promotion', delegate);
check('教师持对应全校功能键可管理班级、读取归档与升级配置', () => {});
for (const url of ['/classes', '/archives', '/term/promotion'])
await request(
url,
delegate,
url === '/classes' ? 'POST' : 'GET',
url === '/classes' ? {} : undefined,
403,
);
check('组织管理能力不能下放给教师,路由永久拒绝', () => {});
const allRoster = await permissions.createGroup(admin, {
name: '全校名单',
scope: 'ALL',
@@ -132,50 +130,41 @@ try {
scope: 'ALL',
keys: ['class:read'],
});
const ownWrite = await permissions.createGroup(admin, { name: '关联班级维护', keys: ['class:manage'] });
const ownWrite = await permissions.createGroup(admin, { name: '关联班级维护', keys: ['class:settings'] });
await permissions.assignUserGroups(admin, delegate.sub, { groupIds: [readAll.id, ownWrite.id] });
const foreignDetail = await request(`/classes/${a.id}`, delegate);
assert.equal(foreignDetail.students.length, 0, '班级读取键不能代替名单读取键');
await request(`/classes/${a.id}`, delegate, 'PATCH', { termWeeks: 11 }, 403);
await request(`/classes/${a.id}/settings`, delegate, 'PATCH', { termWeeks: 11 }, 403);
check('全校读取组不能借给写操作范围,班级详情不泄漏未授权名单', () => {});
const defaults = await prisma.permissionGroup.findUniqueOrThrow({ where: { defaultRole: 'TEACHER' } });
await permissions.assignUserGroups(admin, teacher.sub, { groupIds: [] });
assert((await loadEffectivePermissions(teacher)).has('roster:manage'));
assert((await loadAuthPrincipal(teacher)).permissions.has('roster:manage'));
const additional = await permissions.createGroup(admin, {
name: '追加升级权限',
name: '追加设备读权限',
scope: 'ALL',
keys: ['promotion:manage'],
keys: ['client:read'],
});
await permissions.assignUserGroups(admin, teacher.sub, { groupIds: [defaults.id, additional.id] });
check('无组回落、显式默认组与多组并集', () => {});
assert((await loadEffectivePermissions(teacher)).has('promotion:manage'));
assert((await loadAuthPrincipal(teacher)).permissions.has('client:read'));
assert(!(await loadAuthPrincipal(teacher)).permissions.has('promotion:manage'));
await denied(
permissions.assignUserGroups(admin, teacher.sub, { groupIds: ['default-permission-admin'] }),
403,
400,
);
const assistant = await permissions.createGroup(admin, {
name: '权限协助',
keys: ['permission:manage', 'roster:read'],
});
await permissions.assignUserGroups(admin, manager.sub, { groupIds: [assistant.id] });
await denied(permissions.createGroup(manager, { name: '越权', keys: ['system:manage'] }), 403);
await denied(
permissions.createGroup(manager, { name: '越范围', scope: 'ALL', keys: ['roster:read'] }),
403,
);
await denied(permissions.updateGroup(manager, defaults.id, { keys: [] }), 403);
await denied(permissions.assignUserGroups(manager, manager.sub, { groupIds: [additional.id] }), 403);
check('跨角色默认组、越键、越范围和默认组编辑均拒绝', () => {});
check('分配并集、管理员默认组忽略、非管理员权限管理均拒绝', () => {});
await denied(permissions.createGroup(admin, { name: '空范围', scope: 'GRADE' }), 400);
await denied(permissions.createGroup(admin, { name: '未知范围', scope: 'GRADE', scopeValue: '未知' }), 400);
check('范围统一归一化并拒绝空值和未知年级', () =>
assert.equal(parsePermissionScope('DIVISION', '高二,高一、 高一').scopeValue, '高一,高二'),
);
const memberList = await permissions.listMembers();
const memberList = await permissions.listMembers(admin);
check('成员含真实权限与范围,组成员包含默认回落账号', () =>
assert(memberList.find((m) => m.id === teacher.sub)?.permissions.includes('promotion:manage')),
assert(memberList.find((m) => m.id === teacher.sub)?.permissions.includes('client:read')),
);
const audits = await permissions.listAudits();
const audits = await permissions.listAudits(admin);
check('审计记录操作人与变更前后快照', () =>
assert(audits.items.every((audit) => audit.actorId && audit.actorName && audit.afterJson)),
);
@@ -197,7 +186,7 @@ try {
}
}
try {
await permissions.listMembers();
await permissions.listMembers(admin);
const small = queryCount;
queryCount = 0;
await prisma.user.createMany({
@@ -208,7 +197,7 @@ try {
passwordHash: '临时',
})),
});
await permissions.listMembers();
await permissions.listMembers(admin);
check('成员增加后权限查询次数保持不变', () => assert.equal(queryCount, small));
} finally {
restoreMethods.forEach((restore) => restore());
+11 -2
View File
@@ -114,8 +114,17 @@ for (const [userId, id, scopeValue] of [
[head.id, 'builtin-head', 'HEAD'],
[teacher.id, 'builtin-teacher', ''],
] as const) {
await prisma.permissionGroup.update({ where: { id }, data: { scopeValue } });
await prisma.userPermissionGroup.create({ data: { userId, groupId: id } });
const scope =
id === 'builtin-grade'
? 'GRADE'
: id === 'builtin-division'
? 'DIVISION'
: id === 'builtin-head'
? 'HEAD'
: 'TEACHING';
await prisma.userPermissionGroup.create({
data: { userId, groupId: id, scope, scopeValue: ['GRADE', 'DIVISION'].includes(scope) ? scopeValue : '' },
});
}
const server = createServer(createApp());
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
+74 -324
View File
@@ -1,379 +1,129 @@
import type { PermissionKey as ContractPermissionKey } from '@classhelper/shared';
import {
canManageGrades,
canManageRoster,
canManageSchedule,
canManageSubjectContent,
canPublishNotification,
resolveClassRole,
type ClassRole,
} from '@classhelper/shared';
import { teacherScopeWhere } from './permission-scope.js';
import type { Prisma } from '../generated/prisma/client.js';
import type { PermissionKey, ClassRole } from '@classhelper/shared';
import { resolveClassRole } from '@classhelper/shared';
import { prisma } from './db.js';
import { ApiError } from './http.js';
import { assertPermission, currentRequestPermission } from './permissions.js';
import { hasAllScope } from './permission-scope.js';
import type { TokenPayload } from './jwt.js';
/**
* 权限(RBAC)规则。
*
* | 角色 | 范围 | 能做什么 |
* | ---- | ---- | -------- |
* | ADMIN | 全局 | 班级/教师/学生名单/调班/归档/全部科目的作业与成绩 |
* | HEAD 班主任 | 本班 | 本班学生名单、课表、本班汇总;**作业与成绩仍按科目收口** |
* | SUBJECT 科任 | 班级 + 科目 | 只能发布/修改/删除**自己任教科目**的作业与成绩 |
* | CLASS_DEVICE | 绑定班级 | ClassHelper 班级端:代本班读写个人数据、按开关查成绩明细 |
* | 学生 | 无账号 | 仅作为 `Student` 名单记录,不登录、无权限 |
*
* 三条铁律(都是这次重构的落点):
* 1. **任课关系的唯一来源是 `Course`**(班级 + 科目 + 教师),没有第二张关系表;
* 2. **班主任不自动拥有本班所有科目的作业成绩编辑权** —— 只有同时是该科科任老师时才有;
* 3. **所有权限都在后端判**,前端只做展示控制。
*
* 判定逻辑与前端共用 `@classhelper/shared/permissions`,避免前后端口径漂移。
*/
export function isAdmin(user: TokenPayload): boolean {
return user.role === 'ADMIN';
}
export function isTeacher(user: TokenPayload): boolean {
return user.role === 'TEACHER';
}
/** 教师与管理员(staff):能以自己的身份操作班级内容 */
export function isStaff(user: TokenPayload): boolean {
return user.role === 'ADMIN' || user.role === 'TEACHER';
}
/**
* 是否是 ClassHelper 班级端(教室机器上那台绑定到某个班的客户端)。
* 它代全班读写个人数据(作业完成、通知已读),也能按开关查本班学生成绩明细。
*/
export function isClassDevice(user: TokenPayload): boolean {
return user.role === 'CLASS_DEVICE';
}
/** 班级端代录内容归属班主任账号,班级主体不能写入 User 外键。 */
import {
assertAdmin,
assertPermission,
assertClassRead,
assertManage,
classIdsFor,
classWhere,
classDeviceClassId,
isHead,
hasOrganizationalGrant,
} from './authz/index.js';
export { classDeviceClassId } from './authz/relations.js';
export const isAdmin = (user: TokenPayload) => user.role === 'ADMIN';
export const isTeacher = (user: TokenPayload) => user.role === 'TEACHER';
export const isStaff = (user: TokenPayload) => user.role === 'ADMIN' || user.role === 'TEACHER';
export const isClassDevice = (user: TokenPayload) => user.role === 'CLASS_DEVICE';
export async function resolveContentCreator(user: TokenPayload, classId: string) {
if (!isClassDevice(user)) return user.sub;
const record = await prisma.class.findUnique({ where: { id: classId }, select: { teacherId: true } });
if (!record?.teacherId) throw ApiError.conflict('请先为班级配置班主任,再在班级端发布内容');
if (!record?.teacherId) throw ApiError.conflict('请先为班级配置班主任');
return record.teacherId;
}
/** ClassHelper 班级端绑定的班级 id;不是班级端会话时返回 null */
export function classDeviceClassId(user: TokenPayload): string | null {
return isClassDevice(user) ? (user.classId ?? user.sub) : null;
export function requireClassDeviceClassId(user: TokenPayload): string {
const id = classDeviceClassId(user);
if (!id) throw ApiError.forbidden('当前会话未绑定班级');
return id;
}
/**
* 班级访问的判定条件:班主任,**或在该班有任课关系**(`Course.teacherId`)。
*
* 这里就是「科任老师」的判定源 —— 以前查的是没有科目的 `ClassTeacher` 协作表,
* 现在查 `Course`,于是"能不能进这个班"与"能不能改这一科的作业成绩"用的是同一份事实。
*/
/** 当前用户可访问的全部班级 id */
export async function getAccessibleClassIds(
user: TokenPayload,
permission?: ContractPermissionKey,
): Promise<string[]> {
if (isAdmin(user)) {
const classes = await prisma.class.findMany({ select: { id: true } });
return classes.map((item) => item.id);
}
if (isTeacher(user)) {
const classes = await prisma.class.findMany({
where: await teacherScopeWhere(user, permission),
select: { id: true },
});
return classes.map((item) => item.id);
}
const classId = classDeviceClassId(user);
return classId ? [classId] : [];
}
export const getAccessibleClassIds = classIdsFor;
export async function canAccessClass(
user: TokenPayload,
classId: string,
permission?: ContractPermissionKey,
key: PermissionKey,
): Promise<boolean> {
if (isAdmin(user)) return true;
if (isTeacher(user)) {
const count = await prisma.class.count({
where: { AND: [{ id: classId }, await teacherScopeWhere(user, permission)] },
});
return count > 0;
}
return classDeviceClassId(user) === classId;
if (isClassDevice(user)) return classDeviceClassId(user) === classId;
return !!(await prisma.class.count({ where: { AND: [{ id: classId }, await classWhere(user, key)] } }));
}
/** 读权限断言 */
/** 兼容窄封装,键必须由调用点显式提供。 */
export async function assertClassAccess(
user: TokenPayload,
classId: string,
permission?: ContractPermissionKey,
key: PermissionKey,
): Promise<void> {
const exists = await prisma.class.findUnique({ where: { id: classId }, select: { id: true } });
if (!exists) throw ApiError.notFound('班级不存在');
if (!(await canAccessClass(user, classId, permission))) throw ApiError.forbidden('无权访问该班级的数据');
await assertClassRead(user, key, classId, { allowClassDevice: true });
}
/** 写权限断言:无账号的学生不在会话里,因此这里只放行 staff 与 ClassHelper 班级端 */
export async function assertClassWritable(user: TokenPayload, classId: string): Promise<void> {
if (!isStaff(user) && !isClassDevice(user)) throw ApiError.forbidden('当前身份没有管理权限');
await assertClassAccess(user, classId);
}
/* ---------------------------------------------------------------- 班级内角色 */
/** 解析当前用户在某班级里的角色(ADMIN / HEAD / SUBJECT / CLASS_DEVICE / NONE) */
export async function resolveUserClassRole(user: TokenPayload, classId: string): Promise<ClassRole> {
if (user.role === 'ADMIN') return 'ADMIN';
if (user.role === 'CLASS_DEVICE') return (await canAccessClass(user, classId)) ? 'CLASS_DEVICE' : 'NONE';
const record = await prisma.class.findUnique({
where: { id: classId },
select: {
teacherId: true,
courses: { where: { teacherId: user.sub }, select: { id: true } },
},
});
if (!record) return 'NONE';
if (isAdmin(user)) return 'ADMIN';
if (isClassDevice(user)) return classDeviceClassId(user) === classId ? 'CLASS_DEVICE' : 'NONE';
return resolveClassRole({
role: user.role,
isHeadTeacher: record.teacherId === user.sub,
teachesSubjects: record.courses.length > 0,
isHeadTeacher: await isHead(user, classId),
teachesSubjects: !!(await prisma.course.count({ where: { classId, teacherId: user.sub } })),
});
}
/** 班级本身(增删改)与人员分配:仅管理员 */
export async function assertCanManageClasses(user: TokenPayload, classId?: string): Promise<void> {
await assertPermission(user, 'class:manage');
if (classId) await assertClassInScope(user, classId, 'class:manage');
else if (!(await hasAllScope(user, 'class:manage'))) throw ApiError.forbidden('创建或删除班级需要全校范围');
if (
classId &&
(await prisma.class.findUnique({ where: { id: classId }, select: { archivedYearId: true } }))
?.archivedYearId
)
throw ApiError.conflict('该班级已归档,请先撤销归档');
export async function assertCanManageClasses(user: TokenPayload, classId?: string) {
assertAdmin(user);
if (classId && (await prisma.class.findUnique({ where: { id: classId } }))?.archivedYearId)
throw ApiError.conflict('该班级已归档');
}
export async function assertGlobalPermission(user: TokenPayload, key: ContractPermissionKey) {
export const assertCanAssignTeachers = assertCanManageClasses;
export async function assertGlobalPermission(user: TokenPayload, key: PermissionKey) {
assertAdmin(user);
await assertPermission(user, key);
if (!(await hasAllScope(user, key))) throw ApiError.forbidden('此操作影响全校,需要对应功能的全校范围');
}
/** 班主任 / 各科科任老师的分配:仅管理员 */
export async function assertCanAssignTeachers(user: TokenPayload, classId?: string): Promise<void> {
await assertCanManageClasses(user, classId);
export async function assertClassInScope(user: TokenPayload, classId: string, key: PermissionKey) {
await assertClassRead(user, key, classId);
}
export async function assertClassInScope(user: TokenPayload, classId: string, key: ContractPermissionKey) {
await assertPermission(user, key);
if (isAdmin(user)) return;
const count = await prisma.class.count({
where: { AND: [{ id: classId }, await teacherScopeWhere(user, key)] },
});
if (!count) throw ApiError.forbidden('目标班级不在此功能的授权范围内');
}
export async function assertClassesInScope(user: TokenPayload, ids: string[], key: ContractPermissionKey) {
export async function assertClassesInScope(user: TokenPayload, ids: string[], key: PermissionKey) {
for (const id of new Set(ids)) await assertClassInScope(user, id, key);
}
/**
* 学生名单(增删学生 / 导入名单 / 调班):管理员,或**本班班主任**。
*
* @param classId 目标班级;不传表示"全局名单操作"(例如未分班的学生),此时仅管理员
*/
export async function assertCanManageRoster(user: TokenPayload, classId?: string): Promise<void> {
await assertPermission(user, 'roster:manage');
export async function assertCanManageRoster(
user: TokenPayload,
classId?: string,
db: Prisma.TransactionClient = prisma,
) {
if (!classId) {
if (!(await hasAllScope(user, 'roster:manage')))
throw ApiError.forbidden('未分班学生操作需要全校名单范围');
await assertPermission(user, 'roster:manage');
if (!(await hasOrganizationalGrant(user, 'roster:manage')))
throw ApiError.forbidden('未分班学生需要全校名单范围');
return;
}
await assertClassInScope(user, classId, 'roster:manage');
if (await hasAllScope(user, 'roster:manage')) return;
const classRole = await resolveUserClassRole(user, classId);
if (!canManageRoster(classRole)) {
throw ApiError.forbidden(
classRole === 'SUBJECT'
? '科任老师不能管理学生名单,请联系班主任或管理员'
: '只能管理自己担任班主任的班级的学生名单',
);
}
await assertManage(user, 'roster:manage', classId, db);
}
/** 课表管理(含时间配置导入):管理员或本班班主任 */
export async function assertCanManageSchedule(user: TokenPayload, classId: string): Promise<void> {
await assertClassAccess(user, classId);
const classRole = await resolveUserClassRole(user, classId);
if (!canManageSchedule(classRole)) {
throw ApiError.forbidden(
classRole === 'SUBJECT' || classRole === 'CLASS_DEVICE'
? '科任老师与班级端不能管理课表,请联系班主任或管理员'
: '无权管理该班级的课表',
);
}
}
/** 导入需同时覆盖导入能力和对应写能力,范围不能从另一项能力借用。 */
export async function assertCanImportSchedule(
export async function assertCanManageSchedule(
user: TokenPayload,
classId: string,
writeKey: ContractPermissionKey = 'schedule:write',
): Promise<void> {
await assertClassInScope(user, classId, 'import:schedule');
await assertClassInScope(user, classId, writeKey);
db: Prisma.TransactionClient = prisma,
) {
await assertManage(user, 'schedule:write', classId, db);
}
export async function assertCanImportSchedule(user: TokenPayload, classId: string) {
await assertManage(user, 'import:schedule', classId);
await assertCanManageSchedule(user, classId);
}
/** 通知 / 叫人:管理员、班主任、科任老师、ClassHelper 班级端(面向全班,不分科目) */
export async function assertCanPublishNotification(user: TokenPayload, classId: string): Promise<void> {
await assertClassAccess(user, classId);
const classRole = await resolveUserClassRole(user, classId);
if (!canPublishNotification(classRole)) {
throw ApiError.forbidden('当前账号没有发布通知或叫人的权限');
}
}
/**
* 成绩录入 / 修改 / 导入的**班级维度**准入:管理员,或本班班主任。
*
* @param classId 目标成绩所属班级;不传时按"全局"判定(仅管理员可用,例如空白模板下载)
*
* 这只是第一道门:具体某一条成绩能不能动,还要过 `assertCanManageSubjectContent` 的**科目**那道门。
*/
export async function assertCanManageGrades(user: TokenPayload, classId?: string): Promise<void> {
if (!classId) {
if (!isAdmin(user)) throw ApiError.forbidden('该操作需要指定班级,只有管理员可以全局执行');
return;
}
await assertClassAccess(user, classId);
const classRole = await resolveUserClassRole(user, classId);
if (canManageGrades(classRole)) return;
throw ApiError.forbidden(
classRole === 'SUBJECT' || classRole === 'CLASS_DEVICE'
? '科任老师与班级端不能代录全班成绩,请在自己的科目里录入'
: '只能录入自己担任班主任的班级的成绩',
);
}
/**
* **科目维度的写权限**(作业与成绩共用的那道细门)—— 本次重构的核心规则。
*
* | 情形 | 谁可以写 |
* | ---- | -------- |
* | 管理员 | 全部 |
* | ClassHelper 班级端 | 仅当 `allowClassDevice`(作业允许、成绩不允许) |
* | 挂了科目(courseId 有值) | **该科的任课老师**(`Course.teacherId`) |
* | 没挂科目(courseId 为空) | 本班**班主任** |
*
* 于是:班主任若不教这一科,就改不了这一科的作业与成绩 —— 这正是需求
* 「班主任不自动拥有所有科目作业成绩编辑权,只有同时是该科科任老师时才有该科权限」。
*
* @param courseId 目标内容挂在哪门课(科目)上;null/undefined 表示"不指定科目"
* @param action 给用户看的动作名,例如「布置作业」「录入成绩」
* @param options.allowClassDevice 是否允许教室机器代写(作业为 true,成绩为 false)
*/
export async function assertCanManageSubjectContent(
user: TokenPayload,
classId: string,
courseId: string | null | undefined,
action: string,
options: { allowClassDevice?: boolean } = {},
): Promise<void> {
await assertClassAccess(user, classId);
const classRole = await resolveUserClassRole(user, classId);
const allowClassDevice = options.allowClassDevice === true;
// 管理员不受科目限制,提前返回省掉一次课程查询
if (classRole === 'ADMIN') return;
if (classRole === 'CLASS_DEVICE') {
if (allowClassDevice) return;
throw ApiError.forbidden(`ClassHelper 班级端不能${action},请由任课老师在 Web 端操作`);
}
let isSubjectTeacherOfCourse = false;
let courseName = '';
if (courseId) {
const course = await prisma.course.findUnique({
where: { id: courseId },
select: { classId: true, teacherId: true, name: true },
});
if (!course) throw ApiError.badRequest('课程不存在');
if (course.classId !== classId) throw ApiError.badRequest('该课程不属于所选班级');
courseName = course.name;
isSubjectTeacherOfCourse = course.teacherId === user.sub;
}
const allowed = canManageSubjectContent({
classRole,
hasSubject: Boolean(courseId),
isSubjectTeacherOfCourse,
allowClassDevice,
});
if (allowed) return;
throw ApiError.forbidden(
courseId
? `「${courseName}」不是你任教的科目,不能${action}该科目的内容`
: `未指定科目的内容只有班主任或管理员可以${action}`,
);
}
/** 学生的主班级(ClassHelper 班级端会话);未绑定班级时抛 403 */
export function requireClassDeviceClassId(user: TokenPayload): string {
const classId = classDeviceClassId(user);
if (!classId) throw ApiError.forbidden('当前会话未绑定班级,请联系管理员');
return classId;
}
/**
* 查询范围解析:
* - 传入 classId:校验后只查该班级
* - 未传:教师只能查自己可访问的班级;管理员不加限制
*/
export type ClassScope = { mode: 'all' } | { mode: 'in'; classIds: string[] };
export async function resolveClassScope(
user: TokenPayload,
requestedClassId?: string,
permission?: ContractPermissionKey,
requestedClassId: string | undefined,
key: PermissionKey,
): Promise<ClassScope> {
if (requestedClassId) {
await assertClassAccess(user, requestedClassId, permission);
await assertClassAccess(user, requestedClassId, key);
return { mode: 'in', classIds: [requestedClassId] };
}
if (isAdmin(user)) return { mode: 'all' };
const scopePermission = permission ?? currentRequestPermission(user);
if (isTeacher(user) && scopePermission && (await hasAllScope(user, scopePermission)))
return { mode: 'all' };
const classIds = await getAccessibleClassIds(user, permission);
// 没有任何班级时不抛错,返回空集合让列表接口给出空数组,避免新教师首次登录报错
return { mode: 'in', classIds };
if (isClassDevice(user)) return { mode: 'in', classIds: [requireClassDeviceClassId(user)] };
await assertPermission(user, key);
if (Object.keys(await classWhere(user, key)).length === 0) return { mode: 'all' };
// 其余范围显式枚举班级,避免空集合被下游当成全量。
return { mode: 'in', classIds: await classIdsFor(user, key) };
}
/** 把 ClassScope 转换成 Prisma 的 where 片段(子表:classId 字段) */
export function classScopeWhere(scope: ClassScope): { classId?: { in: string[] } } {
return scope.mode === 'all' ? {} : { classId: { in: scope.classIds } };
}
/** Class 表自身的范围过滤:注意这里过滤的是主键 id,而不是子表的 classId */
export function classScopeIdFilter(scope: ClassScope): { id?: { in: string[] } } {
return scope.mode === 'all' ? {} : { id: { in: scope.classIds } };
}
/** 单班级场景:班级端强制取自己绑定的班级,其他角色沿用请求参数 */
export function resolveEffectiveClassId(user: TokenPayload, requestedClassId?: string): string | undefined {
if (isClassDevice(user)) return requireClassDeviceClassId(user);
if (isClassDevice(user)) {
const id = requireClassDeviceClassId(user);
if (requestedClassId && requestedClassId !== id) throw ApiError.forbidden('不能访问其他班级');
return id;
}
return requestedClassId;
}
+104
View File
@@ -0,0 +1,104 @@
import type { Prisma } from '../../generated/prisma/client.js';
import { PERMISSION_LABELS, type PermissionKey } from '@classhelper/shared';
import { prisma } from '../db.js';
import { ApiError } from '../http.js';
import type { TokenPayload } from '../jwt.js';
import { loadAuthPrincipal } from './principal.js';
import { assertClassInScope, hasOrganizationalGrant } from './scope.js';
import { classDeviceClassId, isHead, isTeaching } from './relations.js';
export function assertAdmin(user: TokenPayload): void {
if (user.role !== 'ADMIN' || user.classSession) throw ApiError.forbidden('此操作仅限管理员');
}
export async function assertPermission(user: TokenPayload, key: PermissionKey): Promise<void> {
if (!(await loadAuthPrincipal(user)).permissions.has(key))
throw ApiError.forbidden(`缺少「${PERMISSION_LABELS[key]}」权限`);
}
export async function assertAnyPermission(user: TokenPayload, keys: PermissionKey[]): Promise<void> {
const auth = await loadAuthPrincipal(user);
if (!keys.some((key) => auth.permissions.has(key))) throw ApiError.forbidden('缺少此操作所需权限');
}
export function assertClassDevice(user: TokenPayload, classId: string): void {
if (classDeviceClassId(user) !== classId) throw ApiError.forbidden('仅限绑定本班的班级端会话');
}
export async function assertPermissionOrClassDevice(user: TokenPayload, key: PermissionKey): Promise<void> {
if (user.role !== 'CLASS_DEVICE') await assertPermission(user, key);
}
export async function assertClassRead(
user: TokenPayload,
key: PermissionKey,
classId: string,
options: { allowClassDevice?: boolean; db?: Prisma.TransactionClient } = {},
): Promise<void> {
if (user.role === 'CLASS_DEVICE' && options.allowClassDevice) {
assertClassDevice(user, classId);
return;
}
await assertPermission(user, key);
await assertClassInScope(user, key, classId, options.db);
}
export async function assertHeadOnly(
user: TokenPayload,
key: PermissionKey,
classId: string,
db: Prisma.TransactionClient = prisma,
): Promise<void> {
await assertClassRead(user, key, classId, { db });
if (user.role !== 'ADMIN' && !(await isHead(user, classId, db)))
throw ApiError.forbidden('此操作仅限本班班主任或管理员');
}
export async function assertManage(
user: TokenPayload,
key: PermissionKey,
classId: string,
db: Prisma.TransactionClient = prisma,
): Promise<void> {
await assertClassRead(user, key, classId, { db });
if (
user.role !== 'ADMIN' &&
!(await isHead(user, classId, db)) &&
!(await hasOrganizationalGrant(user, key, classId, db))
)
throw ApiError.forbidden('此操作需要班主任关系或该功能的年级、部、全校范围');
}
export async function assertContentWrite(
user: TokenPayload,
key: PermissionKey,
classId: string,
courseId: string | null | undefined,
action: string,
options: { allowClassDevice?: boolean; db?: Prisma.TransactionClient } = {},
): Promise<void> {
await assertClassRead(user, key, classId, options);
if (courseId) {
const course = await (options.db ?? prisma).course.findUnique({ where: { id: courseId } });
if (!course || course.classId !== classId) throw ApiError.badRequest('课程不属于目标班级');
if (
user.role === 'ADMIN' ||
(user.role === 'CLASS_DEVICE' && options.allowClassDevice) ||
course.teacherId === user.sub
)
return;
} else if (
user.role === 'ADMIN' ||
(user.role === 'CLASS_DEVICE' && options.allowClassDevice) ||
(await isHead(user, classId, options.db))
)
return;
throw ApiError.forbidden(`只能${action}本人任教科目或班主任班未指定科目的内容`);
}
export async function assertPublish(
user: TokenPayload,
key: PermissionKey,
classId: string,
options: { allowClassDevice?: boolean; db?: Prisma.TransactionClient } = {},
): Promise<void> {
await assertClassRead(user, key, classId, options);
if (
user.role === 'ADMIN' ||
(user.role === 'CLASS_DEVICE' && options.allowClassDevice) ||
(await isTeaching(user, classId, options.db))
)
return;
throw ApiError.forbidden('只有班主任或本班任课老师可以发布');
}
+4
View File
@@ -0,0 +1,4 @@
export * from './principal.js';
export * from './scope.js';
export * from './relations.js';
export * from './assert.js';
@@ -0,0 +1,99 @@
import {
PERMISSION_KEYS,
PERMISSION_META,
parsePermissionScope,
type PermissionKey,
type PermissionScope,
} from '@classhelper/shared';
import { prisma } from '../db.js';
import type { TokenPayload } from '../jwt.js';
import { ensureDefaultGroups } from '../permissions.js';
export interface Grant {
scope: PermissionScope;
scopeValue: string;
groupId: string;
}
export interface AuthPrincipal {
permissions: ReadonlySet<PermissionKey>;
grants: ReadonlyMap<PermissionKey, readonly Grant[]>;
fromGroups: boolean;
}
export interface AssignmentRow {
groupId: string;
scope: string;
scopeValue: string;
group: { defaultRole: string | null; items: { key: string }[] };
}
let requests = new WeakMap<TokenPayload, Promise<AuthPrincipal>>();
export function invalidateAuthPrincipals() {
requests = new WeakMap();
}
/** 缓存仅附着于一个请求的主体;没有可变的“当前权限键”。 */
export function loadAuthPrincipal(user: TokenPayload): Promise<AuthPrincipal> {
let principal = requests.get(user);
if (!principal) {
principal = load(user);
requests.set(user, principal);
}
return principal;
}
async function load(user: TokenPayload): Promise<AuthPrincipal> {
if (user.role !== 'TEACHER') return principalForAssignments(user, []);
await ensureDefaultGroups();
const assignments = await prisma.userPermissionGroup.findMany({
where: { userId: user.sub },
include: { group: { include: { items: true } } },
});
return principalForAssignments(user, assignments);
}
/** 分配预览与运行时共用求值,不写数据库。 */
export async function principalForAssignments(
user: TokenPayload,
assignments: AssignmentRow[],
defaultGroup?: AssignmentRow['group'] & { id: string },
): Promise<AuthPrincipal> {
const permissions = new Set<PermissionKey>();
const grants = new Map<PermissionKey, Grant[]>();
if (user.role === 'CLASS_DEVICE' || user.classSession) return { permissions, grants, fromGroups: false };
if (user.role === 'ADMIN') {
for (const key of PERMISSION_KEYS) {
permissions.add(key);
grants.set(key, [{ scope: 'ALL', scopeValue: '', groupId: '' }]);
}
return { permissions, grants, fromGroups: false };
}
const fromGroups = assignments.length > 0;
if (!fromGroups) {
const group =
defaultGroup ??
(await prisma.permissionGroup.findUnique({
where: { defaultRole: 'TEACHER' },
include: { items: true },
}));
assignments = group ? [{ groupId: group.id, scope: 'TEACHING', scopeValue: '', group }] : [];
}
for (const assignment of assignments) {
if (assignment.group.defaultRole === 'ADMIN') continue;
for (const { key: raw } of assignment.group.items) {
if (!(PERMISSION_KEYS as readonly string[]).includes(raw)) continue;
const key = raw as PermissionKey;
if (PERMISSION_META[key].kind === 'admin') continue;
permissions.add(key);
try {
const parsed = parsePermissionScope(assignment.scope as PermissionScope, assignment.scopeValue);
grants.set(key, [
...(grants.get(key) ?? []),
{ scope: parsed.scope, scopeValue: parsed.scopeValue, groupId: assignment.groupId },
]);
} catch {
/* 非法旧分配不授予班级。 */
}
}
}
return { permissions, grants, fromGroups };
}
@@ -0,0 +1,24 @@
import type { Prisma } from '../../generated/prisma/client.js';
import { prisma } from '../db.js';
import type { TokenPayload } from '../jwt.js';
export function classDeviceClassId(user: TokenPayload): string | null {
return user.role === 'CLASS_DEVICE' && user.classSession ? (user.classId ?? user.sub) : null;
}
export async function isHead(
user: TokenPayload,
classId: string,
db: Prisma.TransactionClient = prisma,
): Promise<boolean> {
return !!(await db.class.count({ where: { id: classId, teacherId: user.sub } }));
}
export async function isTeaching(
user: TokenPayload,
classId: string,
db: Prisma.TransactionClient = prisma,
): Promise<boolean> {
return (
(await isHead(user, classId, db)) ||
!!(await db.course.count({ where: { classId, teacherId: user.sub } }))
);
}
+124
View File
@@ -0,0 +1,124 @@
import { PERMISSION_META, parsePermissionScope, type PermissionKey } from '@classhelper/shared';
import type { Prisma } from '../../generated/prisma/client.js';
import { prisma } from '../db.js';
import { ApiError } from '../http.js';
import type { TokenPayload } from '../jwt.js';
import { loadAuthPrincipal, type AuthPrincipal } from './principal.js';
/** 返回 Class 表过滤条件;无键、无授权、组织只读键均不产生班级集合。 */
export async function classWhere(
user: TokenPayload,
key: PermissionKey,
principal?: AuthPrincipal,
): Promise<Prisma.ClassWhereInput> {
if (!key) return { id: { in: [] } };
const auth = principal ?? (await loadAuthPrincipal(user));
if (!auth.permissions.has(key)) return { id: { in: [] } };
if (user.role === 'ADMIN') return {};
if (PERMISSION_META[key].kind !== 'class') return { id: { in: [] } };
const alternatives: Prisma.ClassWhereInput[] = [];
for (const grant of auth.grants.get(key) ?? []) {
if (grant.scope === 'ALL') return PERMISSION_META[key].relation === 'head' ? { teacherId: user.sub } : {};
if (grant.scope === 'HEAD') alternatives.push({ teacherId: user.sub });
else if (grant.scope === 'TEACHING')
alternatives.push({ OR: [{ teacherId: user.sub }, { courses: { some: { teacherId: user.sub } } }] });
else alternatives.push({ grade: { in: parsePermissionScope(grant.scope, grant.scopeValue).grades } });
}
// 班主任专属键仍须满足分配范围与关系的交集。
if (PERMISSION_META[key].relation === 'head') {
return { AND: [{ OR: alternatives }, { teacherId: user.sub }] };
}
return { OR: alternatives };
}
export async function classIdsFor(
user: TokenPayload,
key: PermissionKey,
principal?: AuthPrincipal,
db: Prisma.TransactionClient = prisma,
): Promise<string[]> {
const rows = await db.class.findMany({
where: await classWhere(user, key, principal),
select: { id: true },
});
return rows.map((row) => row.id);
}
/** 名单、日课表、导入及考试配置的班级管理范围,供界面准确显示操作入口。 */
export async function manageClassIdsFor(
user: TokenPayload,
key: PermissionKey,
principal: AuthPrincipal,
): Promise<string[]> {
if (user.role === 'ADMIN') return classIdsFor(user, key, principal);
const grants = (principal.grants.get(key) ?? []).filter((g) =>
['GRADE', 'DIVISION', 'ALL'].includes(g.scope),
);
const broad = await classWhere(user, key, { ...principal, grants: new Map([[key, grants]]) });
const rows = await prisma.class.findMany({
where: { AND: [await classWhere(user, key, principal), { OR: [{ teacherId: user.sub }, broad] }] },
select: { id: true },
});
return rows.map((row) => row.id);
}
/** 每项输入都携带权限键;关系一次读取,输出仍逐键与各自授权范围求交。 */
export async function actionScopesFor(
user: TokenPayload,
scopes: ReadonlyArray<readonly [PermissionKey, readonly string[]]>,
management: ReadonlyArray<readonly [PermissionKey, readonly string[]]>,
): Promise<Partial<Record<PermissionKey, readonly string[]>>> {
const result = { ...Object.fromEntries(scopes), ...Object.fromEntries(management) };
const related = scopes.filter(([key]) =>
['subject', 'publish', 'creator'].includes(PERMISSION_META[key].relation),
);
if (user.role === 'ADMIN' || !related.length) return result;
const rows = await prisma.class.findMany({
where: { id: { in: [...new Set(related.flatMap(([, ids]) => [...ids]))] } },
select: {
id: true,
teacherId: true,
courses: { where: { teacherId: user.sub }, select: { id: true }, take: 1 },
notifications: { where: { createdBy: user.sub }, select: { id: true }, take: 1 },
},
});
const relations = new Map(rows.map((row) => [row.id, row]));
for (const [key, ids] of related) {
const allowed = ids.filter((id) => {
const row = relations.get(id);
return (
row &&
(row.teacherId === user.sub ||
(PERMISSION_META[key].relation === 'creator'
? row.notifications.length > 0
: row.courses.length > 0))
);
});
// 成绩页还包含考试口径/查询开关,显式组织范围可维护这些配置。
result[key] =
key === 'grade:write'
? [...new Set([...allowed, ...(management.find(([k]) => k === key)?.[1] ?? [])])]
: allowed;
}
return result;
}
export async function assertClassInScope(
user: TokenPayload,
key: PermissionKey,
classId: string,
db: Prisma.TransactionClient = prisma,
): Promise<void> {
if (!(await db.class.count({ where: { AND: [{ id: classId }, await classWhere(user, key)] } })))
throw ApiError.forbidden('目标班级不在此功能的授权范围内');
}
/** 管理动作只有显式年级/部/全校分配可越过班主任关系。 */
export async function hasOrganizationalGrant(
user: TokenPayload,
key: PermissionKey,
classId?: string,
db: Prisma.TransactionClient = prisma,
): Promise<boolean> {
if (user.role === 'ADMIN') return true;
const auth = await loadAuthPrincipal(user);
const grants = (auth.grants.get(key) ?? []).filter((g) => ['GRADE', 'DIVISION', 'ALL'].includes(g.scope));
if (!classId) return grants.some((g) => g.scope === 'ALL');
return (await classIdsFor(user, key, { ...auth, grants: new Map([[key, grants]]) }, db)).includes(classId);
}
+23
View File
@@ -0,0 +1,23 @@
import type { UserRole } from '@classhelper/shared';
import { prisma } from '../db.js';
import { ApiError } from '../http.js';
import type { TokenPayload } from '../jwt.js';
export async function loadSessionUser(payload: TokenPayload): Promise<TokenPayload> {
if (payload.classSession && payload.role === 'CLASS_DEVICE') {
const cls = await prisma.class.findUnique({ where: { id: payload.classId ?? payload.sub } });
if (!cls || cls.archivedYearId) throw ApiError.unauthorized('班级不存在或已归档');
return {
sub: cls.id,
username: cls.code,
name: cls.name,
role: 'CLASS_DEVICE',
classId: cls.id,
classSession: true,
classCode: cls.code,
};
}
if (payload.role === 'CLASS_DEVICE') throw ApiError.unauthorized('无效班级会话');
const row = await prisma.user.findUnique({ where: { id: payload.sub } });
if (!row || !['ADMIN', 'TEACHER'].includes(row.role)) throw ApiError.unauthorized('账号不存在');
return { sub: row.id, username: row.username, name: row.name, role: row.role as UserRole, classId: null };
}
@@ -1,52 +0,0 @@
import { prisma } from './db.js';
import type { TokenPayload } from './jwt.js';
import type { Prisma } from '../generated/prisma/client.js';
import { parsePermissionScope, type PermissionScope, type PermissionKey } from '@classhelper/shared';
import { currentRequestPermission } from './permissions.js';
/** 范围与功能键正交;显式分组覆盖角色默认,不在范围内一律不放行。 */
export async function teacherScopeWhere(
user: TokenPayload,
permission?: PermissionKey,
): Promise<Prisma.ClassWhereInput> {
permission ??= currentRequestPermission(user);
const assignments = await prisma.userPermissionGroup.findMany({
where: { userId: user.sub },
include: { group: { include: { items: true } } },
});
const own = { OR: [{ teacherId: user.sub }, { courses: { some: { teacherId: user.sub } } }] };
const groups = assignments.length
? assignments.map(({ group }) => group)
: await prisma.permissionGroup.findMany({ where: { defaultRole: user.role }, include: { items: true } });
const alternatives: Prisma.ClassWhereInput[] = [];
for (const group of groups) {
if (permission && !group.items.some((item) => item.key === permission)) continue;
try {
const parsed = parsePermissionScope(group.scope as PermissionScope, group.scopeValue);
if (parsed.scope === 'ALL') return {};
if (parsed.scope === 'SELF_CLASS')
alternatives.push(parsed.scopeValue === 'HEAD' ? { teacherId: user.sub } : own);
else alternatives.push({ grade: { in: parsed.grades } });
} catch {
/* 旧岗位组未配置或范围非法时不授予任何班级。 */
}
}
return { OR: alternatives };
}
export async function hasAllScope(user: TokenPayload, permission: PermissionKey) {
if (user.role === 'ADMIN') return true;
const links = await prisma.userPermissionGroup.findMany({
where: { userId: user.sub },
select: { groupId: true },
});
return (
(await prisma.permissionGroup.count({
where: {
...(links.length ? { id: { in: links.map((link) => link.groupId) } } : { defaultRole: user.role }),
scope: 'ALL',
items: { some: { key: permission } },
},
})) > 0
);
}
+11 -90
View File
@@ -1,94 +1,11 @@
import {
BUILTIN_TEACHER_GROUPS,
PERMISSION_KEYS,
PERMISSION_LABELS,
ROLE_DEFAULT_PERMISSIONS,
defaultPermissionsForRole,
type PermissionKey,
} from '@classhelper/shared';
import { prisma } from './db.js';
import type { TokenPayload } from './jwt.js';
import { ApiError } from './http.js';
// 仅复用当前请求对象上的权限,绝不跨请求缓存。
const requestPermissions = new WeakMap<TokenPayload, ReadonlySet<PermissionKey>>();
const requestPermission = new WeakMap<TokenPayload, PermissionKey>();
export function bindRequestPermission(user: TokenPayload, key: PermissionKey) {
requestPermission.set(user, key);
}
export function currentRequestPermission(user: TokenPayload) {
return requestPermission.get(user);
}
export function bindRequestPermissions(user: TokenPayload, permissions: ReadonlySet<PermissionKey>) {
requestPermissions.set(user, permissions);
}
export async function hasEffectivePermission(user: TokenPayload, key: PermissionKey) {
return (requestPermissions.get(user) ?? (await loadEffectivePermissions(user))).has(key);
}
export async function assertPermission(user: TokenPayload, key: PermissionKey): Promise<void> {
if (!(await hasEffectivePermission(user, key)))
throw ApiError.forbidden(`缺少「${PERMISSION_LABELS[key]}」权限`);
bindRequestPermission(user, key);
}
export async function assertAnyPermission(user: TokenPayload, keys: PermissionKey[]): Promise<void> {
const permissions = requestPermissions.get(user) ?? (await loadEffectivePermissions(user));
if (!keys.some((key) => permissions.has(key))) throw ApiError.forbidden('缺少该操作所需权限');
const matched = keys.find((key) => permissions.has(key));
if (matched) bindRequestPermission(user, matched);
}
export async function assertStaffPermission(user: TokenPayload, key: PermissionKey): Promise<void> {
if (user.role !== 'CLASS_DEVICE') await assertPermission(user, key);
}
/**
* 生效权限集合的**唯一加载入口**(权限组功能的全部运行时逻辑都收在这里)。
*
* 语义(与 shared 的 `PERMISSION_DEFAULT_ROLES` 一一对应):
* - `ADMIN`:按权限组计算,另外**永远**保留 {@link ADMIN_UNREMOVABLE_PERMISSIONS},避免管理员
* 把自己(或最后一个管理员)锁在权限页之外;
* - `CLASS_DEVICE`(ClassHelper 班级端):**空集,且不受权限组影响** —— 班级端的会话主体是
* 班级而非账号,它的能力由班级维度断言(`canManageSubjectContent(..., { allowClassDevice })`
* 等)决定,给某个班级端"分配权限组"没有意义;
* - 其他账号:**有分组**时取这些组的键并集(因此可以比默认更少 ⇒ 也能用来收回权限);
* **没有任何分组**时使用数据库中可编辑的角色默认组(逐条镜像权限组上线前各路由的 `requireRole`)。
*
* 每次请求实时查询(不缓存):权限改动**立即生效**,代价是每请求多一条 `findMany`
* (账号规模很小,这个代价换"改了立刻生效"是值的)。
*/
export async function loadEffectivePermissions(user: TokenPayload): Promise<Set<PermissionKey>> {
const permissions = new Set<PermissionKey>();
if (user.classSession || user.role === 'CLASS_DEVICE') return permissions;
await ensureDefaultGroups();
const assignments = await prisma.userPermissionGroup.findMany({
where: { userId: user.sub },
select: { group: { select: { items: { select: { key: true } } } } },
});
if (assignments.length === 0) {
const group = await prisma.permissionGroup.findUnique({
where: { defaultRole: user.role },
include: { items: true },
});
for (const key of group ? group.items.map((item) => item.key) : defaultPermissionsForRole(user.role)) {
if ((PERMISSION_KEYS as readonly string[]).includes(key)) permissions.add(key as PermissionKey);
}
if (user.role === 'ADMIN') for (const key of ADMIN_UNREMOVABLE_PERMISSIONS) permissions.add(key);
return permissions;
}
const known = new Set<string>(PERMISSION_KEYS);
for (const assignment of assignments) {
for (const item of assignment.group.items) {
// 库里可能留着已下线的键(旧版本写入的),这里静默忽略,避免整账号权限异常
if (known.has(item.key)) permissions.add(item.key as PermissionKey);
}
}
if (user.role === 'ADMIN') for (const key of ADMIN_UNREMOVABLE_PERMISSIONS) permissions.add(key);
return permissions;
}
/** 首次升级创建默认组;后续启动保留管理员的编辑结果(包括空权限组)。 */
let defaultGroupsReady: Promise<void> | null = null;
@@ -114,10 +31,10 @@ async function createDefaultGroups(): Promise<void> {
},
});
}
for (const role of ['ADMIN', 'TEACHER'] as const) {
for (const role of ['TEACHER'] as const) {
if (await prisma.permissionGroup.findUnique({ where: { defaultRole: role }, select: { id: true } }))
continue;
const baseName = `${role === 'ADMIN' ? '管理员' : '教师'}默认权限`;
const baseName = '教师默认权限';
let name = baseName;
let suffix = 1;
// 旧库可能已有同名自定义组,保留它并为角色默认组选一个不冲突的名称。
@@ -132,18 +49,22 @@ async function createDefaultGroups(): Promise<void> {
defaultRole: role,
name,
builtin: true,
scope: role === 'ADMIN' ? 'ALL' : 'SELF_CLASS',
description: '未分配其他权限组时自动生效;可调整权限。管理员始终保留权限与系统管理入口。',
scope: 'TEACHING',
description: '未显式分配时使用此模板与固定任教班级范围。',
items: { create: ROLE_DEFAULT_PERMISSIONS[role].map((key) => ({ key })) },
},
});
}
}
/** 管理员不可被剥夺的权限键(防锁死) */
export const ADMIN_UNREMOVABLE_PERMISSIONS: readonly PermissionKey[] = ['permission:manage', 'system:manage'];
/** 把权限键翻译成中文提示(403 文案与权限页共用) */
export function permissionLabel(key: PermissionKey): string {
return PERMISSION_LABELS[key];
}
export { loadAuthPrincipal } from './authz/principal.js';
export { assertPermission, assertAnyPermission, assertPermissionOrClassDevice } from './authz/assert.js';
import { loadAuthPrincipal } from './authz/principal.js';
export async function hasEffectivePermission(user: TokenPayload, key: PermissionKey) {
return (await loadAuthPrincipal(user)).permissions.has(key);
}
+23
View File
@@ -1,3 +1,6 @@
import { refreshClassSubscriptions } from '../realtime/bus.js';
import { invalidateAuthPrincipals } from './authz/principal.js';
import { migratePermissionScope } from '@classhelper/shared';
import type { PrismaClient } from '../generated/prisma/client.js';
import type { DatabaseProvider } from './db.js';
@@ -183,6 +186,24 @@ export async function restoreSnapshot(
}
for (const table of SNAPSHOT_TABLES) {
const rows = (snapshot.data[table] ?? []).map((row) => {
if (table === 'PermissionGroup') {
const mapped = migratePermissionScope(
String(row.scope ?? 'TEACHING'),
String(row.scopeValue ?? ''),
);
return {
...row,
...mapped,
...(row.defaultRole === 'TEACHER' ? { scope: 'TEACHING', scopeValue: '' } : {}),
};
}
if (table === 'UserPermissionGroup' && row.scope === undefined) {
const group = snapshot.data.PermissionGroup.find((g) => g.id === row.groupId);
return {
...row,
...migratePermissionScope(String(group?.scope ?? 'TEACHING'), String(group?.scopeValue ?? '')),
};
}
if (table === 'StudentClassTransfer') {
const { mode, ...rest } = row;
return {
@@ -209,6 +230,8 @@ export async function restoreSnapshot(
} finally {
await setForeignKeys(prisma, provider, true).catch(() => undefined);
}
invalidateAuthPrincipals();
refreshClassSubscriptions();
return { counts };
}
+12 -7
View File
@@ -1,3 +1,4 @@
import type { Prisma } from '../generated/prisma/client.js';
import { randomUUID } from 'node:crypto';
import { currentAcademicYearStart, SOCKET_EVENTS, type StudentLifecycleKind } from '@classhelper/shared';
import { prisma } from './db.js';
@@ -31,17 +32,20 @@ export async function changeStudentLifecycle(
status?: 'active' | 'inactive';
};
},
transaction?: Prisma.TransactionClient,
) {
const batchId = randomUUID();
const ids = [...new Set(input.studentIds)];
const existing = ids.length ? await prisma.student.findMany({ where: { id: { in: ids } } }) : [];
const existing = ids.length
? await (transaction ?? prisma).student.findMany({ where: { id: { in: ids } } })
: [];
if (existing.length !== ids.length) throw ApiError.notFound('部分学生不存在,请刷新后重试');
for (const cls of new Set(existing.map((student) => student.classId)))
await assertCanManageRoster(user, cls ?? undefined);
if (input.toClassId) await assertCanManageRoster(user, input.toClassId);
else if (!existing.length) await assertCanManageRoster(user);
await assertCanManageRoster(user, cls ?? undefined, transaction);
if (input.toClassId) await assertCanManageRoster(user, input.toClassId, transaction);
else if (!existing.length) await assertCanManageRoster(user, undefined, transaction);
const changedClasses: (string | null)[] = [];
const result = await prisma.$transaction(async (tx) => {
const work = async (tx: Prisma.TransactionClient) => {
const target = input.toClassId ? await tx.class.findUnique({ where: { id: input.toClassId } }) : null;
if (input.toClassId && !target) throw ApiError.badRequest('目标班级不存在');
if (target?.archivedYearId) throw ApiError.conflict('目标班级已归档,不能调入');
@@ -160,7 +164,8 @@ export async function changeStudentLifecycle(
transfers: records.map(toStudentTransferDto),
studentId: students[0].id,
};
});
notifyStudentClasses(changedClasses);
};
const result = transaction ? await work(transaction) : await prisma.$transaction(work);
if (!transaction) notifyStudentClasses(changedClasses);
return result;
}
+8 -49
View File
@@ -1,13 +1,9 @@
import type { Request, RequestHandler } from 'express';
import type { PermissionKey, UserRole } from '@classhelper/shared';
import { PERMISSION_LABELS } from '@classhelper/shared';
import { prisma } from '../lib/db.js';
import { ApiError } from '../lib/http.js';
import {
bindRequestPermission,
bindRequestPermissions,
loadEffectivePermissions,
} from '../lib/permissions.js';
import { loadAuthPrincipal } from '../lib/authz/index.js';
import { loadSessionUser } from '../lib/authz/session.js';
import { extractBearerToken, verifyToken, type TokenPayload } from '../lib/jwt.js';
/**
@@ -25,44 +21,8 @@ export function authenticate(): RequestHandler {
const payload = verifyToken(token);
// ClassHelper 班级端:主体是**班级**而不是某个账号,回查 Class 表
if (payload.classSession) {
const record = await prisma.class.findUnique({
where: { id: payload.classId ?? payload.sub },
select: { id: true, name: true, code: true },
});
if (!record) throw ApiError.unauthorized('班级不存在或已被删除');
const user: TokenPayload = {
sub: record.id,
username: record.code,
name: record.name,
role: 'CLASS_DEVICE',
classId: record.id,
classSession: true,
classCode: record.code,
};
req.auth = { token, user, permissions: await loadEffectivePermissions(user) };
next();
return;
}
// 账号(教师 / 管理员):`User` 是纯账号表,学生不在里面
const user = await prisma.user.findUnique({
where: { id: payload.sub },
select: { id: true, username: true, name: true, role: true },
});
if (!user) throw ApiError.unauthorized('账号不存在或已被删除');
const authUser: TokenPayload = {
sub: user.id,
username: user.username,
name: user.name,
role: user.role as UserRole,
classId: null,
};
req.auth = { token, user: authUser, permissions: await loadEffectivePermissions(authUser) };
bindRequestPermissions(authUser, req.auth.permissions!);
const user = await loadSessionUser(payload);
req.auth = { token, user, permissions: (await loadAuthPrincipal(user)).permissions };
next();
} catch (error) {
next(error);
@@ -89,7 +49,7 @@ export function requireRole(...roles: UserRole[]): RequestHandler {
/**
* 权限守卫:要求当前登录主体拥有**全部**列出的权限键。
*
* 权限集合来自 {@link loadEffectivePermissions}(权限组并集,或角色默认模板)。
* 权限集合来自 {@link loadAuthPrincipal}(管理员全集,或教师模板与分配)。
* 提示文案用中文权限名,用户看到的是"缺少「导入成绩」权限"而不是一堆键名。
*/
export function requirePermission(...keys: PermissionKey[]): RequestHandler {
@@ -105,13 +65,12 @@ export function requirePermission(...keys: PermissionKey[]): RequestHandler {
next(ApiError.forbidden(`当前账号缺少${labels}权限,请联系管理员在「权限组」里分配`));
return;
}
if (keys[0]) bindRequestPermission(req.auth.user, keys[0]);
next();
};
}
/** 班级端按班级范围开放读取;教师/管理员仍按可编辑的功能权限校验。 */
export function requireStaffPermission(...keys: PermissionKey[]): RequestHandler {
export function requirePermissionOrClassDevice(...keys: PermissionKey[]): RequestHandler {
const guard = requirePermission(...keys);
return (req, res, next) => {
if (req.auth?.user.role === 'CLASS_DEVICE') return next();
@@ -139,8 +98,6 @@ export function requireAnyPermission(...keys: PermissionKey[]): RequestHandler {
);
return;
}
const matched = keys.find((key) => permissions.has(key));
if (matched) bindRequestPermission(req.auth.user, matched);
next();
};
}
@@ -161,3 +118,5 @@ export function getAuthPermissions(req: Request): ReadonlySet<PermissionKey> {
export function hasAuthPermission(req: Request, key: PermissionKey): boolean {
return (req.auth?.permissions ?? new Set<PermissionKey>()).has(key);
}
export const requireAdmin = (): RequestHandler => requireRole('ADMIN');
+14 -2
View File
@@ -1,5 +1,6 @@
import type { Request, RequestHandler } from 'express';
import type { ZodType } from 'zod';
import { ZodError, type ZodType } from 'zod';
import { ApiError } from '../lib/http.js';
interface ValidationTargets<B, Q, P> {
body?: ZodType<B>;
@@ -14,6 +15,7 @@ interface ValidationTargets<B, Q, P> {
*/
export function validate<B = unknown, Q = unknown, P = unknown>(
targets: ValidationTargets<B, Q, P>,
errorStatus: 400 | 422 = 422,
): RequestHandler {
return (req, _res, next) => {
try {
@@ -22,11 +24,21 @@ export function validate<B = unknown, Q = unknown, P = unknown>(
if (targets.params) req.validatedParams = targets.params.parse(req.params) as unknown;
next();
} catch (error) {
next(error);
next(
errorStatus === 400 && error instanceof ZodError
? ApiError.badRequest('请求参数不合法', error.issues)
: error,
);
}
};
}
export function validateBadRequest<B = unknown, Q = unknown, P = unknown>(
targets: ValidationTargets<B, Q, P>,
): RequestHandler {
return validate(targets, 400);
}
/** 读取已校验的 query(类型由调用方用 zod schema 保证) */
/** 取出校验后的请求体(与 validatedQuery/validatedParams 同一套约定) */
export function validatedBody<T>(req: Request): T {
+26 -7
View File
@@ -1,3 +1,5 @@
import type { PermissionKey } from '@classhelper/shared';
import { assertAdmin, assertPermission, classIdsFor } from '../../lib/authz/index.js';
import { activeStudentWhere } from '../../lib/students.js';
import {
dayKeyLocal,
@@ -84,6 +86,7 @@ export async function updateAiSettings(
user: TokenPayload,
input: UpdateAiSettingsInput,
): Promise<AiSettingsDto> {
assertAdmin(user);
if (input.enabled !== undefined) await setSetting(SETTING_KEYS.aiEnabled, input.enabled ? 'true' : 'false');
if (input.provider !== undefined) await setSetting(SETTING_KEYS.aiProvider, input.provider);
if (input.baseUrl !== undefined) await setSetting(SETTING_KEYS.aiBaseUrl, input.baseUrl.trim());
@@ -105,17 +108,33 @@ interface AssistantContext {
}
async function resolveContext(user: TokenPayload, classId?: string): Promise<AssistantContext> {
if (classId) await assertClassAccess(user, classId);
const classIds = await getAccessibleClassIds(user);
if (classId) await assertClassAccess(user, classId, 'ai:use');
await assertPermission(user, 'ai:use');
const candidates = new Set(
(
await Promise.all(
['notification:publish', 'homework:write', 'call:create'].map((key) =>
classIdsFor(user, key as PermissionKey),
),
)
).flat(),
);
const classIds = (await getAccessibleClassIds(user, 'ai:use')).filter((id) => candidates.has(id));
const classes = await prisma.class.findMany({
where: { id: { in: classIds } },
where: {
id: { in: classIds },
...(user.role !== 'ADMIN'
? { OR: [{ teacherId: user.sub }, { courses: { some: { teacherId: user.sub } } }] }
: {}),
},
select: { id: true, name: true },
orderBy: { name: 'asc' },
take: 80,
});
const courses = classId
? await prisma.course.findMany({ where: { classId }, select: { id: true, name: true } })
: [];
const courses =
classId && classIds.includes(classId)
? await prisma.course.findMany({ where: { classId }, select: { id: true, name: true } })
: [];
return { classes, courses };
}
@@ -345,7 +364,7 @@ export async function interpret(user: TokenPayload, input: AiInterpretInput): Pr
}
if (typeof params.classId === 'string') {
await assertClassAccess(user, params.classId);
await assertClassAccess(user, params.classId, 'ai:use');
if (typeof params.courseId === 'string') {
const course = await prisma.course.findFirst({
where: { id: params.courseId, classId: params.classId },
@@ -1,3 +1,4 @@
import { requireAdmin } from '../../middleware/auth.js';
import { restoreStudentSchema } from '../students/students.schemas.js';
import { listTransferred, restoreTransferred } from './transferred.service.js';
import { Router } from 'express';
@@ -15,7 +16,7 @@ import {
import * as archiveService from './archives.service.js';
const router = Router();
router.use(authenticate());
router.use(authenticate(), requireAdmin());
router.get('/transferred', requirePermission('archive:manage'), async (req, res) =>
sendOk(res, await listTransferred(getAuthUser(req))),
);
@@ -1,6 +1,6 @@
import { Router } from 'express';
import { sendCreated } from '../../lib/http.js';
import { authenticate, getAuthUser, requireStaffPermission } from '../../middleware/auth.js';
import { authenticate, getAuthUser, requirePermissionOrClassDevice } from '../../middleware/auth.js';
import { validate } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import { createCallSchema, type CreateCallInput } from './calls.schemas.js';
@@ -15,7 +15,7 @@ router.use(authenticate());
*/
router.post(
'/',
requireStaffPermission('call:create'),
requirePermissionOrClassDevice('call:create'),
validate({ body: createCallSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -1,7 +1,7 @@
import { assertStaffPermission } from '../../lib/permissions.js';
import { assertPublish } from '../../lib/authz/index.js';
import { assertPermissionOrClassDevice } from '../../lib/permissions.js';
import { resolveContentCreator } from '../../lib/access.js';
import { SOCKET_EVENTS, buildCallTitle, dayKeyLocal, type NotificationDto } from '@classhelper/shared';
import { assertCanPublishNotification } from '../../lib/access.js';
import { isActiveStudent } from '../../lib/students.js';
import { prisma } from '../../lib/db.js';
import { ApiError } from '../../lib/http.js';
@@ -29,8 +29,8 @@ const creatorSelect = { select: { id: true, name: true, username: true } } as co
* 全班都看得到在叫谁,这与需求「教室机器代全班」一致。
*/
export async function createCall(user: TokenPayload, input: CreateCallInput): Promise<NotificationDto> {
await assertStaffPermission(user, 'call:create');
await assertCanPublishNotification(user, input.classId);
await assertPermissionOrClassDevice(user, 'call:create');
await assertPublish(user, 'call:create', input.classId, { allowClassDevice: true });
const student = await prisma.student.findUnique({
where: { id: input.studentId },
@@ -9,8 +9,9 @@ import { idParamSchema } from '../../lib/schemas.js';
import {
authenticate,
getAuthUser,
requireStaffPermission,
requirePermissionOrClassDevice,
requirePermission,
requireAnyPermission,
} from '../../middleware/auth.js';
import { validate, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
@@ -21,7 +22,7 @@ const layouts = Router();
layouts.use(authenticate());
layouts.get(
'/',
requireStaffPermission('schedule:read'),
requirePermissionOrClassDevice('schedule:read'),
validate({ query: schemas.scopeQuery }),
async (req, res) =>
sendOk(
@@ -31,26 +32,27 @@ layouts.get(
);
layouts.post(
'/',
requirePermission('timetable:manage'),
requireAnyPermission('timetable:manage', 'schedule:write'),
validate({ body: schemas.layoutSchema }),
async (req, res) => sendCreated(res, await service.saveLayout(req.body)),
async (req, res) => sendCreated(res, await service.saveLayout(getAuthUser(req), req.body)),
);
layouts.put(
'/:id',
requirePermission('timetable:manage'),
requireAnyPermission('timetable:manage', 'schedule:write'),
validate({ params: idParamSchema, body: schemas.layoutSchema }),
async (req, res) => sendOk(res, await service.saveLayout(req.body, String(req.params.id))),
async (req, res) =>
sendOk(res, await service.saveLayout(getAuthUser(req), req.body, String(req.params.id))),
);
layouts.post(
'/:id/clone',
requirePermission('timetable:manage'),
requireAnyPermission('timetable:manage', 'schedule:write'),
validate({ params: idParamSchema, body: schemas.cloneSchema }),
async (req, res) => {
const current = (await service.listLayouts(getAuthUser(req))).find((p) => p.id === req.params.id);
if (!current) throw ApiError.notFound('时间表不存在');
sendCreated(
res,
await service.saveLayout({
await service.saveLayout(getAuthUser(req), {
...current,
name: req.body.name || `${current.name} 副本`,
points: current.points.map((p) => ({ ...p, id: undefined, timeType: p.timeType as 'LESSON' })),
@@ -60,7 +62,7 @@ layouts.post(
);
layouts.post(
'/:id/import',
requirePermission('timetable:manage'),
requireAnyPermission('timetable:manage', 'schedule:write'),
validate({ params: idParamSchema, body: schemas.importLayoutSchema }),
async (req, res) => {
const all = await service.listLayouts(getAuthUser(req));
@@ -70,6 +72,7 @@ layouts.post(
sendOk(
res,
await service.saveLayout(
getAuthUser(req),
{
...target,
points: source.points.map((p) => ({ ...p, id: undefined, timeType: p.timeType as 'LESSON' })),
@@ -81,21 +84,21 @@ layouts.post(
);
layouts.post(
'/:id/points/:pointId/apply-default',
requirePermission('timetable:manage'),
requireAnyPermission('timetable:manage', 'schedule:write'),
validate({ params: z.object({ id: z.string().min(1), pointId: z.string().min(1) }) }),
async (req, res) =>
sendOk(res, await service.applyDefaultCourse(String(req.params.id), String(req.params.pointId))),
sendOk(
res,
await service.applyDefaultCourse(getAuthUser(req), String(req.params.id), String(req.params.pointId)),
),
);
layouts.delete(
'/:id',
requirePermission('timetable:manage'),
requireAnyPermission('timetable:manage', 'schedule:write'),
validate({ params: idParamSchema }),
async (req, res) => {
const id = String(req.params.id);
if (await prisma.classPlan.count({ where: { timeLayoutId: id } }))
throw ApiError.conflict('仍有课表使用该时间表');
await prisma.timeLayout.delete({ where: { id } });
await service.notifyScheduleChanged();
await service.deleteLayout(getAuthUser(req), id);
sendOk(res, { id });
},
);
@@ -104,7 +107,7 @@ const plans = Router();
plans.use(authenticate());
plans.get(
'/profile',
requireStaffPermission('schedule:read'),
requirePermissionOrClassDevice('schedule:read'),
validate({ query: z.object({ classId: z.string().min(1) }) }),
async (req, res) =>
sendOk(res, await exportProfile(getAuthUser(req), validatedQuery<{ classId: string }>(req).classId)),
@@ -127,7 +130,7 @@ plans.post(
);
plans.get(
'/',
requireStaffPermission('schedule:read'),
requirePermissionOrClassDevice('schedule:read'),
validate({ query: schemas.scopeQuery }),
async (req, res) => {
const q = validatedQuery<{ classId?: string; week?: number }>(req);
@@ -175,7 +178,7 @@ plans.post(
);
plans.get(
'/:id/export',
requireStaffPermission('schedule:read'),
requirePermissionOrClassDevice('schedule:read'),
validate({ params: idParamSchema }),
async (req, res) => {
const p = await service.requirePlan(getAuthUser(req), String(req.params.id), false);
@@ -207,22 +210,22 @@ const groups = Router();
groups.use(authenticate());
groups.get(
'/selection',
requireStaffPermission('schedule:read'),
requirePermissionOrClassDevice('schedule:read'),
validate({ query: z.object({ classId: z.string().min(1) }) }),
async (req, res) => {
const { classId } = validatedQuery<{ classId: string }>(req);
await assertClassAccess(getAuthUser(req), classId);
await assertClassAccess(getAuthUser(req), classId, 'schedule:read');
const value = await prisma.appSetting.findUnique({ where: { key: `schedule.group.${classId}` } });
sendOk(res, value ? JSON.parse(value.value) : {});
},
);
groups.get(
'/',
requireStaffPermission('schedule:read'),
requirePermissionOrClassDevice('schedule:read'),
validate({ query: z.object({ classId: z.string().min(1) }) }),
async (req, res) => {
const { classId } = validatedQuery<{ classId: string }>(req);
await assertClassAccess(getAuthUser(req), classId);
await assertClassAccess(getAuthUser(req), classId, 'schedule:read');
sendOk(res, await prisma.classPlanGroup.findMany({ where: { classId } }));
},
);
@@ -1,3 +1,4 @@
import { assertAdmin, assertHeadOnly, assertPermissionOrClassDevice } from '../../lib/authz/index.js';
import { SOCKET_EVENTS } from '@classhelper/shared';
import { prisma } from '../../lib/db.js';
import { assertCanManageSchedule, assertClassAccess, getAccessibleClassIds } from '../../lib/access.js';
@@ -24,20 +25,24 @@ export async function notifyScheduleChanged(classId?: string | null) {
for (const id of ids) emitToClass(id, SOCKET_EVENTS.scheduleUpdated, { classId: id, action: 'updated' });
}
export async function listLayouts(user: TokenPayload, classId?: string) {
if (classId) await assertClassAccess(user, classId);
const ids = classId ? [classId] : await getAccessibleClassIds(user);
await assertPermissionOrClassDevice(user, 'schedule:read');
if (classId) await assertClassAccess(user, classId, 'schedule:read');
const ids = classId ? [classId] : await getAccessibleClassIds(user, 'schedule:read');
const rows = await prisma.timeLayout.findMany({
where: { OR: [{ classId: null }, { classId: { in: ids } }] },
include: { points: { orderBy: { position: 'asc' } } },
});
return Promise.all(rows.map((row) => decorateLayout(row)));
}
export async function saveLayout(input: z.infer<typeof layoutSchema>, id?: string) {
export async function saveLayout(user: TokenPayload, input: z.infer<typeof layoutSchema>, id?: string) {
const existing = id
? await prisma.timeLayout.findUnique({ where: { id }, select: { classId: true } })
: null;
if (id && !existing) throw ApiError.notFound('时间表不存在');
const layoutClassId = existing ? existing.classId : (input.classId ?? null);
await assertLayoutWrite(user, layoutClassId, id);
if (existing && input.classId !== undefined && input.classId !== existing.classId)
throw ApiError.forbidden('不能移动时间表到其他班级');
// 默认课程属于具体班级:全校时间表不保留默认课程,非上课时间点也不保留。
const points = input.points.map((point) => ({
...point,
@@ -83,12 +88,13 @@ export async function saveLayout(input: z.infer<typeof layoutSchema>, id?: strin
* 把时间点的默认课程写入所有引用该时间表的日课表(ClassIsland 的「覆盖现有课程」)。
* 只处理班级时间表:全校时间表的默认课程没有归属班级。
*/
export async function applyDefaultCourse(layoutId: string, pointId: string) {
export async function applyDefaultCourse(user: TokenPayload, layoutId: string, pointId: string) {
const layout = await prisma.timeLayout.findUnique({
where: { id: layoutId },
include: { points: true },
});
if (!layout) throw ApiError.notFound('时间表不存在');
await assertLayoutWrite(user, layout.classId, layout.id);
if (!layout.classId) throw ApiError.badRequest('全校时间表不能覆盖默认课程');
const point = layout.points.find((p) => p.id === pointId);
if (!point || point.timeType !== 'LESSON') throw ApiError.badRequest('时间点不是上课时间点');
@@ -121,10 +127,11 @@ export async function applyDefaultCourse(layoutId: string, pointId: string) {
return { plans: plans.length };
}
export async function listPlans(user: TokenPayload, classId?: string, week?: number) {
if (classId) await assertClassAccess(user, classId);
await assertPermissionOrClassDevice(user, 'schedule:read');
if (classId) await assertClassAccess(user, classId, 'schedule:read');
return prisma.classPlan.findMany({
where: {
classId: { in: classId ? [classId] : await getAccessibleClassIds(user) },
classId: { in: classId ? [classId] : await getAccessibleClassIds(user, 'schedule:read') },
...(week ? { weekStart: { lte: week }, weekEnd: { gte: week } } : {}),
},
include: planInclude,
@@ -134,7 +141,8 @@ export async function listPlans(user: TokenPayload, classId?: string, week?: num
export async function requirePlan(user: TokenPayload, id: string, write = true) {
const p = await prisma.classPlan.findUnique({ where: { id }, include: planInclude });
if (!p) throw ApiError.notFound('课表不存在');
await (write ? assertCanManageSchedule : assertClassAccess)(user, p.classId);
if (write) await assertCanManageSchedule(user, p.classId);
else await assertClassAccess(user, p.classId, 'schedule:read');
return p;
}
export async function savePlan(user: TokenPayload, input: z.infer<typeof planSchema>, id?: string) {
@@ -285,3 +293,24 @@ export async function createOverlay(
await notifyScheduleChanged(source.classId);
return result;
}
export async function assertLayoutWrite(user: TokenPayload, classId: string | null, layoutId?: string) {
if (classId) await assertHeadOnly(user, 'schedule:write', classId);
else assertAdmin(user);
if (layoutId) {
const refs = await prisma.classPlan.findMany({
where: { timeLayoutId: layoutId },
select: { classId: true },
});
for (const id of new Set(refs.map((p) => p.classId))) await assertHeadOnly(user, 'schedule:write', id);
}
}
export async function deleteLayout(user: TokenPayload, id: string) {
const layout = await prisma.timeLayout.findUnique({ where: { id } });
if (!layout) throw ApiError.notFound('时间表不存在');
await assertLayoutWrite(user, layout.classId, id);
if (await prisma.classPlan.count({ where: { timeLayoutId: id } }))
throw ApiError.conflict('仍有课表使用该时间表');
await prisma.timeLayout.delete({ where: { id } });
await notifyScheduleChanged(layout.classId);
}
@@ -4,7 +4,7 @@ import type { TokenPayload } from '../../lib/jwt.js';
/** 按时间点外键生成完整档案,空课、停用课程与非上课时间点均保留。 */
export async function exportProfile(user: TokenPayload, classId: string) {
await assertClassAccess(user, classId);
await assertClassAccess(user, classId, 'schedule:read');
const [cls, layouts, plans, subjects, groups, scheduled, setting] = await Promise.all([
prisma.class.findUniqueOrThrow({ where: { id: classId } }),
prisma.timeLayout.findMany({
@@ -4,8 +4,9 @@ import { idParamSchema } from '../../lib/schemas.js';
import {
authenticate,
getAuthUser,
requireStaffPermission,
requirePermissionOrClassDevice,
requirePermission,
requireAdmin,
} from '../../middleware/auth.js';
import { validate, validatedBody, validatedParams, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
@@ -19,6 +20,8 @@ import {
promoteClassesSchema,
updateClassAccountSchema,
updateClassSchema,
updateClassSettingsSchema,
type UpdateClassSettingsInput,
updateStudentGradeQuerySchema,
type AddStudentInput,
type AssignSubjectTeachersInput,
@@ -42,7 +45,7 @@ router.use(authenticate());
*/
router.get(
'/',
requireStaffPermission('class:read'),
requirePermissionOrClassDevice('class:read'),
validate({ query: listClassesQuerySchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -59,6 +62,7 @@ router.get(
*/
router.post(
'/promote',
requireAdmin(),
requirePermission('promotion:manage'),
validate({ body: promoteClassesSchema }),
async (req, res) => {
@@ -71,7 +75,7 @@ router.post(
/** GET /api/classes/:id - 班级详情(学生名单 + 课程 + 班主任 + 各科任课老师) */
router.get(
'/:id',
requireStaffPermission('class:read'),
requirePermissionOrClassDevice('class:read'),
validate({ params: idParamSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -83,6 +87,7 @@ router.get(
/** POST /api/classes - 创建班级(称呼由入学年份 + 班号生成,如「2026级1班」) */
router.post(
'/',
requireAdmin(),
requirePermission('class:manage'),
validate({ body: createClassSchema }),
async (req, res) => {
@@ -94,6 +99,7 @@ router.post(
/** PATCH /api/classes/:id - 编辑班级(年级 / 届别 / 教学周数),按功能权限及范围校验 */
router.patch(
'/:id',
requireAdmin(),
requirePermission('class:manage'),
(req, _res, next) => {
if (req.body && 'grade' in req.body) return next(ApiError.badRequest('年级请通过学年升级入口修改'));
@@ -107,9 +113,25 @@ router.patch(
},
);
router.patch(
'/:id/settings',
requirePermission('class:settings'),
validate({ params: idParamSchema, body: updateClassSettingsSchema }),
async (req, res) => {
sendOk(
res,
await classService.updateClassSettings(
getAuthUser(req),
String(req.params.id),
validatedBody<UpdateClassSettingsInput>(req),
),
);
},
);
/** DELETE /api/classes/:id - 删除班级 */
router.delete(
'/:id',
requireAdmin(),
requirePermission('class:manage'),
validate({ params: idParamSchema }),
async (req, res) => {
@@ -126,7 +148,7 @@ router.delete(
*/
router.patch(
'/:id/class-account',
requirePermission('class:manage'),
requirePermission('class:account'),
validate({ params: idParamSchema, body: updateClassAccountSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -145,6 +167,7 @@ router.patch(
*/
router.patch(
'/:id/head-teacher',
requireAdmin(),
requirePermission('class:manage'),
validate({ params: idParamSchema, body: assignHeadTeacherSchema }),
async (req, res) => {
@@ -164,6 +187,7 @@ router.patch(
*/
router.put(
'/:id/subject-teachers',
requireAdmin(),
requirePermission('class:manage'),
validate({ params: idParamSchema, body: assignSubjectTeachersSchema }),
async (req, res) => {
@@ -183,7 +207,7 @@ router.put(
*/
router.get(
'/:id/student-grade-query',
requireStaffPermission('class:read'),
requirePermissionOrClassDevice('class:read'),
validate({ params: idParamSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -210,7 +234,7 @@ router.patch(
/** GET /api/classes/:id/students - 班级学生名单 */
router.get(
'/:id/students',
requireStaffPermission('roster:read'),
requirePermissionOrClassDevice('roster:read'),
validate({ params: idParamSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -41,12 +41,15 @@ export const createClassSchema = z.object({
* `enrollmentYear` / `classIndex` 是给"有名无实的旧数据"补届别用的,改了会重算班级称呼。
*/
export const updateClassSchema = z.strictObject({
enrollmentYear: enrollmentYearSchema.optional(),
classIndex: classIndexSchema.optional(),
});
export const updateClassSettingsSchema = z.strictObject({
/** 本班学期开始日期(第 1 教学周的周一);空串 = 回落到全局配置 */
termStartDate: z
.union([z.string().trim().refine(isDayKey, { message: '日期格式应为 YYYY-MM-DD' }), z.literal('')])
.nullish(),
enrollmentYear: enrollmentYearSchema.optional(),
classIndex: classIndexSchema.optional(),
termWeeks: z.coerce.number().int().min(1).max(60).optional(),
});
@@ -125,3 +128,5 @@ export type UpdateClassAccountInput = z.infer<typeof updateClassAccountSchema>;
export type UpdateStudentGradeQueryInput = z.infer<typeof updateStudentGradeQuerySchema>;
export type AddStudentInput = z.infer<typeof addStudentSchema>;
export type AssignSubjectTeachersInput = z.infer<typeof assignSubjectTeachersSchema>;
export type UpdateClassSettingsInput = z.infer<typeof updateClassSettingsSchema>;
@@ -1,3 +1,6 @@
import { disconnectUserSessions, refreshClassSubscriptions } from '../../realtime/bus.js';
import { assertHeadOnly, assertManage } from '../../lib/authz/index.js';
import { auditData } from '../permissions/permissions.service.js';
import type { ClassDetailDto, ClassDto, StudentDto, SubjectTeacherDto } from '@classhelper/shared';
import { SOCKET_EVENTS, formatClassName } from '@classhelper/shared';
import { env } from '../../config/env.js';
@@ -23,12 +26,17 @@ import type {
AssignSubjectTeachersInput,
CreateClassInput,
UpdateClassInput,
UpdateClassSettingsInput,
UpdateStudentGradeQueryInput,
} from './classes.schemas.js';
import { loadPromotionPlan } from '../term/promotion.service.js';
import { assertClassInScope, canAccessClass } from '../../lib/access.js';
import { changeStudentLifecycle } from '../../lib/student-lifecycle.js';
import { assertStaffPermission, assertPermission, hasEffectivePermission } from '../../lib/permissions.js';
import {
assertPermissionOrClassDevice,
assertPermission,
hasEffectivePermission,
} from '../../lib/permissions.js';
import { activeStudentWhere } from '../../lib/students.js';
import { getMaxTermWeeks } from '../../lib/term.js';
import { logger } from '../../lib/logger.js';
@@ -79,7 +87,7 @@ function toClassDtoWithAccount(
* 混在常规列表里会让老师误以为那届还在上课。需要看归档班级请用 `/api/archives`。
*/
export async function listClasses(user: TokenPayload, keyword?: string): Promise<ClassDto[]> {
await assertStaffPermission(user, 'class:read');
await assertPermissionOrClassDevice(user, 'class:read');
const scope = await resolveClassScope(user, undefined, 'class:read');
// Class 表按主键 id 过滤(classScopeWhere 针对的是带 classId 字段的子表)
const where = {
@@ -102,8 +110,8 @@ export async function listClasses(user: TokenPayload, keyword?: string): Promise
/** 班级详情:学生名单 + 课程 + 班主任 + 各科任课老师 */
export async function getClassDetail(user: TokenPayload, classId: string): Promise<ClassDetailDto> {
await assertStaffPermission(user, 'class:read');
await assertClassAccess(user, classId);
await assertPermissionOrClassDevice(user, 'class:read');
await assertClassAccess(user, classId, 'class:read');
const item = await prisma.class.findUnique({
where: { id: classId },
@@ -147,7 +155,7 @@ export async function listSubjectTeachers(
user: TokenPayload,
classId: string,
): Promise<{ headTeacher: SubjectTeacherDto | null; subjectTeachers: SubjectTeacherDto[] }> {
await assertClassAccess(user, classId);
await assertClassAccess(user, classId, 'class:read');
const item = await prisma.class.findUnique({
where: { id: classId },
include: {
@@ -206,6 +214,13 @@ export async function createClass(user: TokenPayload, input: CreateClassInput):
},
});
await prisma.permissionAudit.create({
data: auditData(user, 'class-create', 'class', created.id, null, {
name: created.name,
teacherId: created.teacherId,
}),
});
refreshClassSubscriptions();
return toClassDtoWithAccount(user, created);
}
@@ -222,6 +237,10 @@ export async function updateHeadTeacher(
teacherId: string,
): Promise<ClassDto> {
await assertCanAssignTeachers(user, classId);
const before = await prisma.class.findUniqueOrThrow({
where: { id: classId },
select: { teacherId: true },
});
const targetId = await requireTeacher(teacherId, '指定的班主任不存在或角色不是教师');
const updated = await prisma.class.update({
@@ -233,6 +252,10 @@ export async function updateHeadTeacher(
},
});
await prisma.permissionAudit.create({
data: auditData(user, 'class-head', 'class', classId, before, { teacherId: targetId }),
});
disconnectUserSessions([before.teacherId, targetId].filter((id): id is string => !!id));
emitToClass(classId, SOCKET_EVENTS.classUpdated, { classId, action: 'head-teacher-changed' });
return toClassDtoWithAccount(user, updated);
}
@@ -256,6 +279,10 @@ export async function assignSubjectTeachers(
const targetClass = await prisma.class.findUnique({ where: { id: classId }, select: { id: true } });
if (!targetClass) throw ApiError.notFound('班级不存在');
const before = await prisma.course.findMany({
where: { classId },
select: { id: true, name: true, teacherId: true },
});
for (const assignment of input.assignments) {
const teacherId = assignment.teacherId
? await requireTeacher(assignment.teacherId, `「${assignment.subjectName}」的任课老师不存在或不是教师`)
@@ -290,6 +317,17 @@ export async function assignSubjectTeachers(
where: { id: classId },
include: { courses: { orderBy: { name: 'asc' }, include: { teacher: teacherBriefSelect } } },
});
await prisma.permissionAudit.create({
data: auditData(
user,
'class-subjects',
'class',
classId,
before,
refreshed.courses.map((c) => ({ id: c.id, name: c.name, teacherId: c.teacherId })),
),
});
disconnectUserSessions([...new Set([...before, ...refreshed.courses].map((c) => c.teacherId))]);
return refreshed.courses.map((course) => ({
courseId: course.id,
subjectName: course.name,
@@ -304,7 +342,8 @@ export async function updateClassAccount(
classId: string,
input: { code?: string; password?: string },
): Promise<ClassDto> {
await assertCanManageClasses(user, classId);
await assertHeadOnly(user, 'class:account', classId);
const before = await prisma.class.findUniqueOrThrow({ where: { id: classId }, select: { code: true } });
if (
'termWeeks' in input &&
typeof input.termWeeks === 'number' &&
@@ -322,6 +361,12 @@ export async function updateClassAccount(
},
});
if (!updated) throw ApiError.notFound('班级不存在');
await prisma.permissionAudit.create({
data: auditData(user, 'class-account', 'class', classId, before, {
code: updated.code,
passwordChanged: input.password !== undefined,
}),
});
return toClassDtoWithAccount(user, updated);
}
@@ -340,19 +385,12 @@ export async function updateClass(
if (!current) throw ApiError.notFound('班级不存在');
if ('grade' in input) throw ApiError.badRequest('修改年级请使用学年升级入口');
if (input.termWeeks !== undefined && input.termWeeks > (await getMaxTermWeeks()))
throw ApiError.badRequest('班级周数不能超过全校上限');
const enrollmentYear = input.enrollmentYear ?? current.enrollmentYear;
const classIndex = input.classIndex ?? current.classIndex;
const updated = await prisma.class.update({
where: { id: classId },
data: {
...(input.termWeeks !== undefined ? { termWeeks: input.termWeeks } : {}),
// 学期开始日期:空串/null 都表示"回落到全局配置"(如 .env 的 TERM_START_DATE)
...(input.termStartDate !== undefined
? { termStartDate: input.termStartDate ? input.termStartDate : null }
: {}),
...(input.enrollmentYear !== undefined ? { enrollmentYear } : {}),
...(input.classIndex !== undefined ? { classIndex } : {}),
// 届别齐了就把称呼重算一遍:班级称呼的唯一来源是「入学年份 + 班号」
@@ -364,10 +402,45 @@ export async function updateClass(
},
});
await prisma.permissionAudit.create({
data: auditData(user, 'class-update', 'class', classId, current, {
enrollmentYear: updated.enrollmentYear,
classIndex: updated.classIndex,
name: updated.name,
}),
});
emitToClass(classId, SOCKET_EVENTS.classUpdated, { classId, action: 'updated' });
return toClassDto(updated);
}
export async function updateClassSettings(
user: TokenPayload,
classId: string,
input: UpdateClassSettingsInput,
): Promise<ClassDto> {
await assertHeadOnly(user, 'class:settings', classId);
if (input.termWeeks !== undefined && input.termWeeks > (await getMaxTermWeeks()))
throw ApiError.badRequest('班级周数不能超过全校上限');
const before = await prisma.class.findUniqueOrThrow({
where: { id: classId },
select: { termWeeks: true, termStartDate: true },
});
const updated = await prisma.class.update({
where: { id: classId },
data: {
termWeeks: input.termWeeks,
...(input.termStartDate !== undefined ? { termStartDate: input.termStartDate || null } : {}),
},
});
await prisma.permissionAudit.create({
data: auditData(user, 'class-settings', 'class', classId, before, {
termWeeks: updated.termWeeks,
termStartDate: updated.termStartDate,
}),
});
emitToClass(classId, SOCKET_EVENTS.classUpdated, { classId, action: 'updated' });
return toClassDto(updated);
}
/**
* 学年升级(按升级权限与班级范围):把若干班级的年级改掉,**不归档、数据沿用**。
*
@@ -405,6 +478,7 @@ export async function promoteClasses(
}
});
for (const classId of ids) emitToClass(classId, SOCKET_EVENTS.classUpdated, { classId, action: 'updated' });
refreshClassSubscriptions();
return { promoted: ids.length };
}
@@ -414,13 +488,21 @@ export async function deleteClass(user: TokenPayload, classId: string): Promise<
if (await prisma.student.count({ where: { classId } }))
throw ApiError.conflict('班级仍有学生,请先通过名单生命周期入口移出或归档,不能级联清除归属');
emitToClass(classId, SOCKET_EVENTS.classUpdated, { classId, action: 'deleted' });
const before = await prisma.class.findUniqueOrThrow({
where: { id: classId },
select: { id: true, name: true, grade: true },
});
await prisma.class.delete({ where: { id: classId } });
refreshClassSubscriptions();
await prisma.permissionAudit.create({
data: auditData(user, 'class-delete', 'class', classId, before, null),
});
}
/** 班级学生名单 */
export async function listClassStudents(user: TokenPayload, classId: string): Promise<StudentDto[]> {
await assertStaffPermission(user, 'roster:read');
await assertClassAccess(user, classId);
await assertPermissionOrClassDevice(user, 'roster:read');
await assertClassAccess(user, classId, 'roster:read');
const students = await prisma.student.findMany({
where: { classId, ...activeStudentWhere() },
include: { class: { select: { name: true, grade: true } } },
@@ -472,7 +554,7 @@ export async function getStudentGradeQuery(
user: TokenPayload,
classId: string,
): Promise<{ studentGradeQueryEnabled: boolean }> {
await assertClassAccess(user, classId);
await assertClassAccess(user, classId, 'class:read');
const record = await prisma.class.findUnique({
where: { id: classId },
select: { studentGradeQueryEnabled: true },
@@ -493,7 +575,7 @@ export async function updateStudentGradeQuery(
classId: string,
input: UpdateStudentGradeQueryInput,
): Promise<{ studentGradeQueryEnabled: boolean }> {
await assertClassAccess(user, classId);
await assertManage(user, 'grade:write', classId);
if (!isStaff(user)) {
throw ApiError.forbidden('成绩查询开关由教师/管理员在 Web 端设置,班级端只读');
}
@@ -2,7 +2,7 @@ import { Router } from 'express';
import type { ClientUpdateProgressDto } from '@classhelper/shared';
import { sendCreated, sendOk } from '../../lib/http.js';
import { idParamSchema } from '../../lib/schemas.js';
import { authenticate, getAuthUser, requireAnyPermission, requirePermission } from '../../middleware/auth.js';
import { authenticate, getAuthUser, requireRole, requirePermission } from '../../middleware/auth.js';
import { validate, validatedBody, validatedParams, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import {
@@ -22,14 +22,19 @@ import * as clientService from './clients.service.js';
const router = Router();
router.use(authenticate());
router.post('/update-progress', validate({ body: clientProgressSchema }), async (req, res) => {
const input = validatedBody<{
deviceKey: string;
progress: ClientUpdateProgressDto;
}>(req);
await clientService.reportUpdateProgress(getAuthUser(req), input.deviceKey, input.progress);
sendOk(res, null);
});
router.post(
'/update-progress',
requireRole('CLASS_DEVICE'),
validate({ body: clientProgressSchema }),
async (req, res) => {
const input = validatedBody<{
deviceKey: string;
progress: ClientUpdateProgressDto;
}>(req);
await clientService.reportUpdateProgress(getAuthUser(req), input.deviceKey, input.progress);
sendOk(res, null);
},
);
/**
* POST /api/clients/heartbeat - 客户端每 30 秒上报一次
@@ -37,22 +42,28 @@ router.post('/update-progress', validate({ body: clientProgressSchema }), async
* **不挂权限键**:上报方是教室里的班级端会话(角色 CLASS_DEVICE,权限集为空)。
* 返回值里同时带回"要不要更新 + 安装包信息 + 一条待执行命令",客户端一次请求全拿到。
*/
router.post('/heartbeat', validate({ body: clientHeartbeatSchema }), async (req, res) => {
const user = getAuthUser(req);
const input = validatedBody<ClientHeartbeatInput>(req);
const result = await clientService.heartbeat(user, input, req.ip ?? '');
// 1.3.x 旧客户端误读顶层字段,提供兼容别名让它们仍能接收修复版更新。
sendOk(res, result, '心跳已上报', 200, input.agentProtocol === 2 ? {} : result);
});
router.post(
'/heartbeat',
requireRole('CLASS_DEVICE'),
validate({ body: clientHeartbeatSchema }),
async (req, res) => {
const user = getAuthUser(req);
const input = validatedBody<ClientHeartbeatInput>(req);
const result = await clientService.heartbeat(user, input, req.ip ?? '');
// 1.3.x 旧客户端误读顶层字段,提供兼容别名让它们仍能接收修复版更新。
sendOk(res, result, '心跳已上报', 200, input.agentProtocol === 2 ? {} : result);
},
);
/** POST /api/clients/commands/:id/ack - 执行回执(同样不挂权限键,靠 deviceKey 归属校验) */
router.post(
'/commands/:id/ack',
requireRole('CLASS_DEVICE'),
validate({ params: idParamSchema, body: clientCommandAckSchema }),
async (req, res) => {
const { id } = validatedParams<{ id: string }>(req);
const input = validatedBody<ClientCommandAckInput>(req);
sendOk(res, await clientService.ackCommand(id, input), '回执已记录');
sendOk(res, await clientService.ackCommand(getAuthUser(req), id, input), '回执已记录');
},
);
@@ -79,7 +90,7 @@ router.post(
/** GET /api/clients/devices?classId= - Web 端设备列表(版本 / 在线 / 待执行命令) */
router.get(
'/devices',
requireAnyPermission('client:read', 'client:manage', 'remote:control', 'client:update-push'),
requirePermission('client:read'),
validate({ query: listClientDevicesQuerySchema }),
async (req, res) => {
const query = validatedQuery<ListClientDevicesQuery>(req);
@@ -94,7 +105,7 @@ router.get(
/** POST /api/clients/updates - 统一下发客户端更新 */
router.post(
'/updates',
requirePermission('client:update'),
requirePermission('client:update-push'),
validate({ body: pushClientUpdateSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -1,3 +1,4 @@
import { assertClassDevice } from '../../lib/authz/index.js';
import {
CLIENT_COMMAND_EXPIRE_MS,
CLIENT_COMMAND_LABELS,
@@ -136,6 +137,11 @@ export async function heartbeat(
input: ClientHeartbeatInput,
ip: string,
): Promise<ClientHeartbeatResponse> {
const boundClassId = resolveClassId(user);
assertClassDevice(user, boundClassId ?? '');
const previousDevice = await prisma.clientDevice.findUnique({ where: { deviceKey: input.deviceKey } });
if (previousDevice && previousDevice.classId !== boundClassId)
throw ApiError.forbidden('设备不属于当前班级');
const now = new Date();
const heartbeatAt = {
classId: resolveClassId(user),
@@ -149,11 +155,17 @@ export async function heartbeat(
lastIp: ip,
};
const device = (await prisma.clientDevice.upsert({
where: { deviceKey: input.deviceKey },
create: { deviceKey: input.deviceKey, ...heartbeatAt },
update: heartbeatAt,
})) as unknown as DeviceRow;
const device = (await prisma.clientDevice
.upsert({
where: { deviceKey: input.deviceKey, classId: boundClassId },
create: { deviceKey: input.deviceKey, ...heartbeatAt },
update: heartbeatAt,
})
.catch(async (error: unknown) => {
const current = await prisma.clientDevice.findUnique({ where: { deviceKey: input.deviceKey } });
if (current && current.classId !== boundClassId) throw ApiError.forbidden('设备不属于当前班级');
throw error;
})) as unknown as DeviceRow;
const targetVersion = device.updateTargetVersion.trim();
if (targetVersion && input.clientVersion && compareVersions(input.clientVersion, targetVersion) >= 0) {
@@ -204,7 +216,7 @@ export async function reportUpdateProgress(
progress: ClientUpdateProgressDto,
) {
const device = await prisma.clientDevice.findUnique({ where: { deviceKey } });
// 教师/管理员电脑不绑定班级(会话与设备两侧都是 null),同样允许上报自己的进度。
assertClassDevice(user, device?.classId ?? '');
if (!device || device.classId !== resolveClassId(user)) throw ApiError.forbidden('设备不属于当前班级');
// 已取消或更换目标的迟到进度不得覆盖当前任务。
if (progress.version !== device.updateTargetVersion || progress.stage === 'completed') return;
@@ -240,14 +252,16 @@ function readUpdateProgress(raw: string): ClientUpdateProgressDto | null {
/** POST /api/clients/commands/:id/ack - 客户端执行完远控命令后的回执 */
export async function ackCommand(
user: TokenPayload,
commandId: string,
input: ClientCommandAckRequest,
): Promise<ClientCommandDto> {
const command = (await prisma.clientCommand.findUnique({
where: { id: commandId },
include: { device: { select: { deviceKey: true, name: true } } },
})) as (CommandRow & { device: { deviceKey: string; name: string } }) | null;
include: { device: { select: { deviceKey: true, name: true, classId: true } } },
})) as (CommandRow & { device: { deviceKey: string; name: string; classId: string | null } }) | null;
if (!command) throw ApiError.notFound('命令不存在');
assertClassDevice(user, command.device.classId ?? '');
if (command.device.deviceKey !== input.deviceKey) {
throw ApiError.forbidden('这条命令不是发给这台电脑的');
@@ -271,7 +285,7 @@ export async function listDevices(
user: TokenPayload,
options: { classId?: string } = {},
): Promise<ClientDeviceDto[]> {
const scope = await resolveClassScope(user, options.classId);
const scope = await resolveClassScope(user, options.classId, 'client:read');
const rows = (await prisma.clientDevice.findMany({
where: classScopeWhere(scope),
include: { class: { select: { id: true, name: true } } },
@@ -394,7 +408,7 @@ export async function createCommands(
/** 更新推送的目标集合:优先设备,其次班级,最后"全部" */
async function resolveUpdateTargets(user: TokenPayload, input: PushClientUpdateInput): Promise<DeviceRow[]> {
const scope = classScopeWhere(await resolveClassScope(user, undefined, 'client:update'));
const scope = classScopeWhere(await resolveClassScope(user, undefined, 'client:update-push'));
const deviceIds = [...new Set(input.deviceIds ?? [])];
if (deviceIds.length > 0) {
const rows = (await prisma.clientDevice.findMany({
@@ -4,7 +4,7 @@ import { idParamSchema } from '../../lib/schemas.js';
import {
authenticate,
getAuthUser,
requireStaffPermission,
requirePermissionOrClassDevice,
requirePermission,
} from '../../middleware/auth.js';
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
@@ -24,7 +24,7 @@ router.use(authenticate());
/** GET /api/courses?classId= - 课程列表 */
router.get(
'/',
requireStaffPermission('schedule:read'),
requirePermissionOrClassDevice('schedule:read'),
validate({ query: listCoursesQuerySchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -1,3 +1,4 @@
import { refreshClassSubscriptions } from '../../realtime/bus.js';
import type { CourseDto } from '@classhelper/shared';
import { assertCanManageSchedule, classScopeWhere, resolveClassScope } from '../../lib/access.js';
import { prisma } from '../../lib/db.js';
@@ -9,7 +10,7 @@ import type { CreateCourseInput, UpdateCourseInput } from './courses.schemas.js'
const teacherSelect = { select: { id: true, name: true, username: true } } as const;
export async function listCourses(user: TokenPayload, classId?: string): Promise<CourseDto[]> {
const scope = await resolveClassScope(user, classId);
const scope = await resolveClassScope(user, classId, 'schedule:read');
const courses = await prisma.course.findMany({
where: classScopeWhere(scope),
include: { teacher: teacherSelect },
@@ -26,7 +27,7 @@ export async function createCourse(user: TokenPayload, input: CreateCourseInput)
*
* 这条不是"友好提示",是**权限护栏**:`course.create` 会把创建者写成这门课的任课老师,
* 于是只要能再建一门同名的「数学」,创建者就凭空成了「数学」的老师 ——
* 后面那道 `assertCanManageSubjectContent` 的科目门随即形同虚设
* 后面那道 `` 的科目门随即形同虚设
* (可以布置该科作业、录入该科成绩、删该科内容)。这就是"非该科科任老师
* 能布置其他科作业"的入口。
*
@@ -82,4 +83,5 @@ export async function deleteCourse(user: TokenPayload, courseId: string): Promis
if (!course) throw ApiError.notFound('课程不存在');
await assertCanManageSchedule(user, course.classId);
await prisma.course.delete({ where: { id: courseId } });
refreshClassSubscriptions();
}
@@ -6,7 +6,7 @@ import {
getAuthUser,
requireRole,
requirePermission,
requireStaffPermission,
requirePermissionOrClassDevice,
} from '../../middleware/auth.js';
import { validate, validatedBody, validatedQuery, validatedParams } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
@@ -22,7 +22,7 @@ import * as service from './courseware.service.js';
const router = Router();
router.use(authenticate());
const staff = [requireRole('ADMIN', 'TEACHER'), requirePermission('courseware:write')];
router.get('/', requireStaffPermission('courseware:read'), async (req, res) =>
router.get('/', requirePermissionOrClassDevice('courseware:read'), async (req, res) =>
sendOk(res, await service.listCourseware(getAuthUser(req))),
);
router.get('/classes', ...staff, async (req, res) =>
@@ -83,10 +83,14 @@ router.post('/upload', ...staff, validate({ query: coursewareUploadQuery }), asy
);
router.get(
'/:id/download',
requireStaffPermission('courseware:read'),
requirePermissionOrClassDevice('courseware:read'),
validate({ params: idParamSchema }),
async (req, res, next) => {
const row = await service.readable(getAuthUser(req), validatedParams<{ id: string }>(req).id);
const row = await service.readable(
getAuthUser(req),
validatedParams<{ id: string }>(req).id,
'courseware:read',
);
res.setHeader('Cache-Control', 'private, no-store');
res.setHeader('Content-Type', 'application/octet-stream');
res.download(row.storagePath, row.name, { root: service.coursewareRoot, dotfiles: 'allow' }, (error) => {
@@ -127,7 +131,7 @@ router.delete('/:id', ...staff, validate({ params: idParamSchema }), async (req,
});
router.get(
'/:id',
requireStaffPermission('courseware:read'),
requirePermissionOrClassDevice('courseware:read'),
validate({ params: idParamSchema }),
async (req, res) =>
sendOk(res, await service.getCourseware(getAuthUser(req), validatedParams<{ id: string }>(req).id)),
@@ -1,3 +1,9 @@
import {
assertContentWrite,
assertPermission,
assertPermissionOrClassDevice,
classWhere,
} from '../../lib/authz/index.js';
import fs from 'node:fs';
import fsp from 'node:fs/promises';
import path from 'node:path';
@@ -17,7 +23,7 @@ import {
import { env } from '../../config/env.js';
import { prisma } from '../../lib/db.js';
import { ApiError } from '../../lib/http.js';
import { assertCanManageSubjectContent, requireClassDeviceClassId } from '../../lib/access.js';
import { requireClassDeviceClassId } from '../../lib/access.js';
import type { TokenPayload } from '../../lib/jwt.js';
import type { Prisma } from '../../generated/prisma/client.js';
import { emitToClass } from '../../realtime/bus.js';
@@ -47,13 +53,20 @@ function dto(row: RecordWithRelations): CoursewareDto {
};
}
export async function teachingClasses(user: TokenPayload) {
await assertPermission(user, 'courseware:write');
return prisma.class.findMany({
where: { archivedYearId: null, courses: { some: { teacherId: user.sub } } },
where: {
AND: [
{ archivedYearId: null, courses: { some: { teacherId: user.sub } } },
await classWhere(user, 'courseware:write'),
],
},
select: { id: true, name: true },
orderBy: { name: 'asc' },
});
}
async function validateClasses(user: TokenPayload, ids: string[]) {
await assertPermission(user, 'courseware:write');
const classIds = [...new Set(ids)];
const allowed = new Set((await teachingClasses(user)).map((item) => item.id));
if (classIds.some((id) => !allowed.has(id))) throw ApiError.forbidden('只能选择自己任教的在读班级');
@@ -62,11 +75,12 @@ async function validateClasses(user: TokenPayload, ids: string[]) {
where: { classId, teacherId: user.sub },
select: { id: true },
});
await assertCanManageSubjectContent(user, classId, course?.id, '发布课件');
await assertContentWrite(user, 'courseware:write', classId, course?.id, '发布课件');
}
return classIds;
}
export async function readable(user: TokenPayload, id: string) {
export async function readable(user: TokenPayload, id: string, key: 'courseware:read' | 'courseware:write') {
await assertPermissionOrClassDevice(user, key);
const row = await prisma.courseware.findUnique({ where: { id }, include });
if (!row) throw ApiError.notFound('课件不存在');
if (user.role === 'CLASS_DEVICE') {
@@ -77,17 +91,19 @@ export async function readable(user: TokenPayload, id: string) {
return row;
}
async function owned(user: TokenPayload, id: string) {
const row = await readable(user, id);
await assertPermission(user, 'courseware:write');
const row = await readable(user, id, 'courseware:write');
if (row.ownerId !== user.sub) throw ApiError.forbidden('只能修改自己的课件');
return row;
}
export async function getCourseware(user: TokenPayload, id: string) {
const item = dto(await readable(user, id));
const item = dto(await readable(user, id, 'courseware:read'));
if (user.role === 'CLASS_DEVICE')
item.classes = item.classes.filter((c) => c.id === requireClassDeviceClassId(user));
return item;
}
export async function listCourseware(user: TokenPayload) {
await assertPermissionOrClassDevice(user, 'courseware:read');
const classId = user.role === 'CLASS_DEVICE' ? requireClassDeviceClassId(user) : null;
const rows = await prisma.courseware.findMany({
where: classId
@@ -177,6 +193,7 @@ async function assertFolder(user: TokenPayload, folderPath: string) {
throw ApiError.badRequest('请选择自己创建的文件夹');
}
export async function listFolders(user: TokenPayload) {
await assertPermission(user, 'courseware:write');
const rows = await prisma.coursewareFolder.findMany({
where: { ownerId: user.sub },
orderBy: { path: 'asc' },
@@ -184,6 +201,7 @@ export async function listFolders(user: TokenPayload) {
return rows.map((row) => ({ id: row.id, path: row.path }));
}
export async function createFolder(user: TokenPayload, folderPath: string) {
await assertPermission(user, 'courseware:write');
const row = await prisma.coursewareFolder.upsert({
where: { ownerId_path: { ownerId: user.sub, path: folderPath } },
create: { ownerId: user.sub, path: folderPath },
@@ -259,7 +277,7 @@ export async function acknowledgeTransfer(user: TokenPayload, pushId: string, de
const device = await deviceFor(user, deviceKey);
const push = await prisma.coursewarePush.findFirst({ where: { id: pushId, classId: device.classId } });
if (!push) throw ApiError.notFound('传输任务不存在');
await readable(user, push.coursewareId);
await readable(user, push.coursewareId, 'courseware:read');
await prisma.coursewareReceipt.upsert({
where: { pushId_deviceId: { pushId, deviceId: device.id } },
create: { pushId, deviceId: device.id },
@@ -1,7 +1,7 @@
import { Router } from 'express';
import { sendOk } from '../../lib/http.js';
import { resolveCurrentWeek, loadTermContext } from '../../lib/term.js';
import { authenticate, getAuthUser, requireStaffPermission } from '../../middleware/auth.js';
import { authenticate, getAuthUser, requirePermissionOrClassDevice } from '../../middleware/auth.js';
import { defineModule } from '../module.types.js';
import * as dashboardService from './dashboard.service.js';
@@ -9,7 +9,7 @@ const router = Router();
router.use(authenticate());
/** GET /api/dashboard/summary - 首页汇总(按角色权限收敛数据范围) */
router.get('/summary', requireStaffPermission('dashboard:read'), async (req, res) => {
router.get('/summary', requirePermissionOrClassDevice('dashboard:read'), async (req, res) => {
const user = getAuthUser(req);
sendOk(res, await dashboardService.getDashboardSummary(user), '获取仪表盘数据成功');
});
@@ -21,7 +21,7 @@ router.get('/summary', requireStaffPermission('dashboard:read'), async (req, res
* 这样周次选择器不会再出现"实际 20 周却给到 30 周"的情况;不带 classId 时退回全局默认值。
* 带 classId 时会先校验班级访问权限(见 resolveTermWeeks)。
*/
router.get('/term', requireStaffPermission('term:read'), async (req, res) => {
router.get('/term', requirePermissionOrClassDevice('term:read'), async (req, res) => {
const user = getAuthUser(req);
const classId = typeof req.query.classId === 'string' && req.query.classId ? req.query.classId : undefined;
const maxWeek = await dashboardService.resolveTermWeeks(user, classId);
@@ -1,9 +1,9 @@
import type { PermissionKey } from '@classhelper/shared';
import { classIdsFor, loadAuthPrincipal } from '../../lib/authz/index.js';
import { activeStudentWhere } from '../../lib/students.js';
import type { DashboardSummary, HomeworkDto, NotificationDto } from '@classhelper/shared';
import {
assertClassAccess,
classScopeIdFilter,
classScopeWhere,
isClassDevice,
resolveClassScope,
requireClassDeviceClassId,
@@ -25,7 +25,7 @@ const creatorSelect = { select: { id: true, name: true, username: true } } as co
* - 都不传:全局默认(getMaxTermWeeks 兜底)。
*/
export async function resolveTermWeeks(user: TokenPayload, classId?: string): Promise<number> {
if (classId) await assertClassAccess(user, classId);
if (classId) await assertClassAccess(user, classId, 'term:read');
const targetClassId = classId ?? (isClassDevice(user) ? requireClassDeviceClassId(user) : undefined);
if (!targetClassId) return getMaxTermWeeks();
const record = await prisma.class.findUnique({
@@ -42,10 +42,38 @@ export async function resolveTermWeeks(user: TokenPayload, classId?: string): Pr
export async function getDashboardSummary(user: TokenPayload): Promise<DashboardSummary> {
const device = isClassDevice(user);
// 班级端强制限定在自己班级,避免越权统计
const scope = await resolveClassScope(user, device ? requireClassDeviceClassId(user) : undefined);
const scope = await resolveClassScope(
user,
device ? requireClassDeviceClassId(user) : undefined,
'dashboard:read',
);
// 子表按 classId 过滤;Class 表本身按主键 id 过滤
const where = classScopeWhere(scope);
const classWhere = classScopeIdFilter(scope);
const allowed = scope.mode === 'in' ? scope.classIds : await classIdsFor(user, 'dashboard:read');
const auth = await loadAuthPrincipal(user);
async function segment(key: PermissionKey) {
const ids = device ? allowed : (await classIdsFor(user, key)).filter((id) => allowed.includes(id));
return { classId: { in: ids } };
}
const [classes, students, courses, schedules, homeworks, notifications, grades] = await Promise.all(
[
'class:read',
'roster:read',
'schedule:read',
'schedule:read',
'homework:read',
'notification:read',
'grade:read',
].map((key) => segment(key as PermissionKey)),
);
const sections = {
classes: device || auth.permissions.has('class:read'),
students: device || auth.permissions.has('roster:read'),
courses: device || auth.permissions.has('schedule:read'),
schedules: device || auth.permissions.has('schedule:read'),
homeworks: device || auth.permissions.has('homework:read'),
notifications: device || auth.permissions.has('notification:read'),
grades: device || auth.permissions.has('grade:read'),
};
// ClassHelper 班级端以全班学生为范围
const personalIds = await resolvePersonalIds(user);
const personalWhere = personalIdWhere(personalIds);
@@ -64,27 +92,27 @@ export async function getDashboardSummary(user: TokenPayload): Promise<Dashboard
recentHomeworks,
] = await Promise.all([
// 学生数只算在读的:已毕业/转出的学生不属于"本班现在有多少人"
prisma.class.count({ where: classWhere }),
prisma.student.count({ where: { ...where, ...activeStudentWhere(), archivedYearId: null } }),
prisma.course.count({ where }),
prisma.classPlan.count({ where }),
prisma.homework.count({ where }),
prisma.notification.count({ where }),
prisma.grade.count({ where: { ...where, student: activeStudentWhere() } }),
prisma.notification.count({ where: { ...where, reads: { none: personalWhere } } }),
prisma.class.count({ where: { id: classes.classId } }),
prisma.student.count({ where: { ...students, ...activeStudentWhere(), archivedYearId: null } }),
prisma.course.count({ where: courses }),
prisma.classPlan.count({ where: schedules }),
prisma.homework.count({ where: homeworks }),
prisma.notification.count({ where: notifications }),
prisma.grade.count({ where: { ...grades, student: activeStudentWhere() } }),
prisma.notification.count({ where: { ...notifications, reads: { none: personalWhere } } }),
device
? prisma.homework.count({
where: { ...where, statuses: { none: { ...personalWhere, completed: true } } },
where: { ...homeworks, statuses: { none: { ...personalWhere, completed: true } } },
})
: prisma.homework.count({ where }),
: prisma.homework.count({ where: homeworks }),
prisma.notification.findMany({
where,
where: notifications,
include: { creator: creatorSelect, reads: { where: personalWhere } },
orderBy: { createdAt: 'desc' },
take: 5,
}),
prisma.homework.findMany({
where,
where: homeworks,
include: {
course: courseSelect,
creator: creatorSelect,
@@ -96,6 +124,7 @@ export async function getDashboardSummary(user: TokenPayload): Promise<Dashboard
]);
return {
sections,
classCount,
studentCount,
courseCount,
@@ -1,8 +1,9 @@
import { requireAdmin } from '../../middleware/auth.js';
import fs from 'node:fs';
import path from 'node:path';
import { Router } from 'express';
import { sendCreated, sendOk } from '../../lib/http.js';
import { authenticate, requirePermission } from '../../middleware/auth.js';
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
import { validate, validatedBody, validatedParams } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import {
@@ -30,46 +31,52 @@ import * as databaseService from './database.service.js';
*/
const router = Router();
router.use(authenticate());
router.use(authenticate(), requireAdmin());
router.use(requirePermission('database:manage'));
/** GET /api/database/status - 当前数据库状态(连接/版本/大小/各表行数/备份与定时配置) */
router.get('/status', async (_req, res) => {
sendOk(res, await databaseService.getDatabaseStatus());
router.get('/status', async (req, res) => {
sendOk(res, await databaseService.forAdministrator(getAuthUser(req)).getDatabaseStatus());
});
/** POST /api/database/test-connection - 测试任意目标库连通性(切换前的前置检查) */
router.post('/test-connection', validate({ body: testConnectionSchema }), async (req, res) => {
const result = await databaseService.testConnection(validatedBody<TestConnectionInput>(req));
const result = await databaseService
.forAdministrator(getAuthUser(req))
.testConnection(validatedBody<TestConnectionInput>(req));
sendOk(res, result, result.ok ? '连接成功' : '连接失败');
});
/** GET /api/database/backups - 备份列表 */
router.get('/backups', (_req, res) => {
sendOk(res, databaseService.listBackups());
router.get('/backups', (req, res) => {
sendOk(res, databaseService.forAdministrator(getAuthUser(req)).listBackups());
});
/** POST /api/database/backups - 立即创建一份手动备份 */
router.post('/backups', async (_req, res) => {
sendCreated(res, await databaseService.createBackup('manual'), '备份已创建');
router.post('/backups', async (req, res) => {
sendCreated(
res,
await databaseService.forAdministrator(getAuthUser(req)).createBackup('manual'),
'备份已创建',
);
});
/** POST /api/database/backups/:name/restore - 从备份恢复(整库覆盖) */
router.post('/backups/:name/restore', validate({ params: backupNameParamSchema }), async (req, res) => {
const { name } = validatedParams<{ name: string }>(req);
sendOk(res, await databaseService.restoreBackup(name), '已从备份恢复');
sendOk(res, await databaseService.forAdministrator(getAuthUser(req)).restoreBackup(name), '已从备份恢复');
});
/** DELETE /api/database/backups/:name - 删除备份 */
router.delete('/backups/:name', validate({ params: backupNameParamSchema }), async (req, res) => {
const { name } = validatedParams<{ name: string }>(req);
databaseService.deleteBackup(name);
databaseService.forAdministrator(getAuthUser(req)).deleteBackup(name);
sendOk(res, { name }, '备份已删除');
});
/** GET /api/database/export - 下载当前库的 JSON 快照 */
router.get('/export', async (_req, res) => {
const json = await databaseService.exportSnapshotJson();
router.get('/export', async (req, res) => {
const json = await databaseService.forAdministrator(getAuthUser(req)).exportSnapshotJson();
const filename = `classhelper-snapshot-${new Date().toISOString().slice(0, 19).replaceAll(':', '')}.json`;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
@@ -77,8 +84,8 @@ router.get('/export', async (_req, res) => {
});
/** GET /api/database/sqlite-file - 下载数据库文件(仅 SQLite 模式) */
router.get('/sqlite-file', (_req, res) => {
const filePath = databaseService.sqliteFilePathForDownload();
router.get('/sqlite-file', (req, res) => {
const filePath = databaseService.forAdministrator(getAuthUser(req)).sqliteFilePathForDownload();
res.setHeader('Content-Type', 'application/octet-stream');
res.setHeader('Content-Disposition', `attachment; filename="${path.basename(filePath)}"`);
res.status(200).send(fs.readFileSync(filePath));
@@ -87,33 +94,41 @@ router.get('/sqlite-file', (_req, res) => {
/** POST /api/database/import - 导入快照(整库覆盖;base64 随 JSON 请求体) */
router.post('/import', validate({ body: importSnapshotSchema }), async (req, res) => {
const { data } = validatedBody<ImportSnapshotInput>(req);
sendOk(res, await databaseService.importSnapshotBase64(data), '快照已导入(原数据已被覆盖)');
sendOk(
res,
await databaseService.forAdministrator(getAuthUser(req)).importSnapshotBase64(data),
'快照已导入(原数据已被覆盖)',
);
});
/** GET /api/database/backup-schedule - 定时备份配置 */
router.get('/backup-schedule', (_req, res) => {
sendOk(res, databaseService.getBackupSchedule());
router.get('/backup-schedule', (req, res) => {
sendOk(res, databaseService.forAdministrator(getAuthUser(req)).getBackupSchedule());
});
/** PUT /api/database/backup-schedule - 更新定时备份配置(立即生效,重启后仍有效) */
router.put('/backup-schedule', validate({ body: backupScheduleSchema }), async (req, res) => {
sendOk(
res,
databaseService.saveSchedulePatch(validatedBody<BackupScheduleInput>(req)),
databaseService
.forAdministrator(getAuthUser(req))
.saveSchedulePatch(validatedBody<BackupScheduleInput>(req)),
'定时备份配置已保存',
);
});
/** POST /api/database/switch - 一键切换数据库(异步任务,返回 jobId) */
router.post('/switch', validate({ body: switchDatabaseSchema }), async (req, res) => {
const job = databaseService.startSwitch(validatedBody<SwitchDatabaseInput>(req));
const job = databaseService
.forAdministrator(getAuthUser(req))
.startSwitch(validatedBody<SwitchDatabaseInput>(req));
sendCreated(res, { jobId: job.id }, '切换任务已启动');
});
/** GET /api/database/switch/jobs/:id - 查询切换任务进度 */
router.get('/switch/jobs/:id', validate({ params: jobIdParamSchema }), async (req, res) => {
const { id } = validatedParams<{ id: string }>(req);
const job = databaseService.getSwitchJob(id);
const job = databaseService.forAdministrator(getAuthUser(req)).getSwitchJob(id);
sendOk(res, job, job ? 'ok' : '任务不存在');
});
@@ -1,3 +1,5 @@
import type { TokenPayload } from '../../lib/jwt.js';
import { assertAdmin } from '../../lib/authz/index.js';
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
@@ -795,3 +797,23 @@ async function runSwitch(job: SwitchJob, target: TargetConnectionInput): Promise
job.finishedAt = new Date().toISOString();
}
}
/** 请求入口必须携带 actor;后台调度只调用模块内部维护函数。 */
export function forAdministrator(actor: TokenPayload) {
assertAdmin(actor);
return {
getDatabaseStatus,
testConnection,
listBackups,
createBackup,
restoreBackup,
deleteBackup,
exportSnapshotJson,
importSnapshotBase64,
sqliteFilePathForDownload,
getBackupSchedule,
saveSchedulePatch,
startSwitch,
getSwitchJob,
};
}
@@ -1,11 +1,11 @@
import { Router } from 'express';
import { sendCreated, sendOk } from '../../lib/http.js';
import { ApiError, sendCreated, sendOk } from '../../lib/http.js';
import { idParamSchema } from '../../lib/schemas.js';
import {
authenticate,
getAuthUser,
requirePermission,
requireStaffPermission,
requirePermissionOrClassDevice,
} from '../../middleware/auth.js';
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
@@ -31,9 +31,10 @@ import * as gradeService from './grades.service.js';
const router = Router();
router.use(authenticate());
router.get('/overview', async (req, res) => {
router.get('/overview', requirePermissionOrClassDevice('grade:read'), async (req, res) => {
const user = getAuthUser(req);
const classId = user.classId ?? user.sub;
const classId = user.role === 'CLASS_DEVICE' ? (user.classId ?? user.sub) : String(req.query.classId ?? '');
if (!classId) throw ApiError.badRequest('请指定班级');
const cls = await (await import('../../lib/db.js')).prisma.class.findUnique({ where: { id: classId } });
sendOk(
res,
@@ -42,14 +43,19 @@ router.get('/overview', async (req, res) => {
: await gradeService.collectiveAnalysis(user, classId),
);
});
router.get('/collective', validate({ query: examListQuerySchema }), async (req, res) => {
const user = getAuthUser(req);
const { classId } = validatedQuery<{ classId: string }>(req);
sendOk(res, await gradeService.collectiveAnalysis(user, classId));
});
router.get(
'/collective',
requirePermissionOrClassDevice('grade:read'),
validate({ query: examListQuerySchema }),
async (req, res) => {
const user = getAuthUser(req);
const { classId } = validatedQuery<{ classId: string }>(req);
sendOk(res, await gradeService.collectiveAnalysis(user, classId));
},
);
/** GET /api/grades/my - ClassHelper 班级端查看本班成绩(教师/管理员返回空数组) */
router.get('/my', async (req, res) => {
router.get('/my', requirePermissionOrClassDevice('grade:read'), async (req, res) => {
const user = getAuthUser(req);
sendOk(res, await gradeService.listMyGrades(user), '获取本班成绩成功');
});
@@ -65,7 +71,7 @@ router.get('/my', async (req, res) => {
*/
router.get(
'/student/:studentNo',
requireStaffPermission('student:grade-query'),
requirePermissionOrClassDevice('student:grade-query'),
validate({ params: studentGradeParamSchema, query: studentGradeQuerySchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -93,11 +99,16 @@ router.get(
* **刻意不挂权限键**:ClassHelper 班级端要用它列出可选考试(班级端的有效权限是空集,
* 挂 `grade:read` 会把教室机器挡在门外)。边界在 service 里:`assertClassAccess` + 班级开关。
*/
router.get('/exams', validate({ query: examListQuerySchema }), async (req, res) => {
const user = getAuthUser(req);
const { classId } = validatedQuery<{ classId: string }>(req);
sendOk(res, await gradeService.listExams(user, classId), '获取考试列表成功');
});
router.get(
'/exams',
requirePermissionOrClassDevice('grade:read'),
validate({ query: examListQuerySchema }),
async (req, res) => {
const user = getAuthUser(req);
const { classId } = validatedQuery<{ classId: string }>(req);
sendOk(res, await gradeService.listExams(user, classId), '获取考试列表成功');
},
);
/** PUT /api/grades/exams - 设置"这次考试是否把所有科目相加做总分排名"(教师/管理员) */
router.put(
@@ -121,11 +132,16 @@ router.put(
* 同样不挂权限键(班级端要看),边界在 service 里 —— 与按学号查明细共用
* `canQueryStudentGrades`:班级端受按班开关控制,教师端不受影响。
*/
router.get('/ranking', validate({ query: examRankingQuerySchema }), async (req, res) => {
const user = getAuthUser(req);
const { classId, examName } = validatedQuery<{ classId: string; examName: string }>(req);
sendOk(res, await gradeService.getExamRanking(user, classId, examName), '获取考试排名成功');
});
router.get(
'/ranking',
requirePermissionOrClassDevice('grade:read'),
validate({ query: examRankingQuerySchema }),
async (req, res) => {
const user = getAuthUser(req);
const { classId, examName } = validatedQuery<{ classId: string; examName: string }>(req);
sendOk(res, await gradeService.getExamRanking(user, classId, examName), '获取考试排名成功');
},
);
/** GET /api/grades?classId=&courseId=&studentId=&examName= - 成绩列表(教师) */
router.get(
@@ -1,3 +1,5 @@
import { assertManage, assertPermissionOrClassDevice } from '../../lib/authz/index.js';
import { assertContentWrite } from '../../lib/authz/index.js';
import type { CollectiveGradeAnalysisDto as ContractCollectiveGradeAnalysisDto } from '@classhelper/shared';
import {
SOCKET_EVENTS,
@@ -14,7 +16,6 @@ import {
type StudentGradeDetailDto,
} from '@classhelper/shared';
import {
assertCanManageSubjectContent,
assertClassAccess,
classScopeWhere,
isClassDevice,
@@ -57,16 +58,10 @@ const classSelect = { select: { name: true } } as const;
const include = { course: courseSelect, student: studentSelect, class: classSelect } as const;
async function assertDetailQuery(user: TokenPayload, classId: string): Promise<void> {
await assertClassAccess(user, classId);
await assertClassAccess(user, classId, 'grade:read');
const cls = await prisma.class.findUniqueOrThrow({ where: { id: classId } });
if (
!canQueryStudentGrades({
classRole: await resolveUserClassRole(user, classId),
gradeQueryEnabled: cls.studentGradeQueryEnabled,
})
) {
if (isClassDevice(user) && !cls.studentGradeQueryEnabled)
throw ApiError.forbidden('本班只开放集体分析,未开放个人成绩');
}
}
/** 服务端聚合;不返回极值、个人标识或可由补集反推的稀疏分段。 */
@@ -74,7 +69,7 @@ export async function collectiveAnalysis(
user: TokenPayload,
classId: string,
): Promise<ContractCollectiveGradeAnalysisDto> {
await assertClassAccess(user, classId);
await assertClassAccess(user, classId, 'grade:read');
const rows = await prisma.grade.findMany({
where: { classId, student: activeStudentWhere() },
select: {
@@ -139,12 +134,17 @@ export interface ListGradeOptions {
* 教师/管理员调用时返回空数组(他们没有"自己的成绩"),请用 `listGrades`。
*/
export async function listMyGrades(user: TokenPayload): Promise<GradeDto[]> {
await assertPermissionOrClassDevice(user, 'grade:read');
if (isClassDevice(user)) await assertDetailQuery(user, user.classId ?? user.sub);
const studentIds = await resolvePersonalIds(user);
if (studentIds.length === 0) return [];
const grades = await prisma.grade.findMany({
where: { studentId: { in: studentIds }, student: activeStudentWhere() },
where: {
classId: user.classId ?? user.sub,
studentId: { in: studentIds },
student: activeStudentWhere(),
},
include,
orderBy: [{ publishedAt: 'desc' }, { examName: 'asc' }],
take: 1000,
@@ -154,7 +154,7 @@ export async function listMyGrades(user: TokenPayload): Promise<GradeDto[]> {
/** 教师 / 管理员查看成绩(按可访问班级过滤) */
export async function listGrades(user: TokenPayload, options: ListGradeOptions): Promise<GradeDto[]> {
const scope = await resolveClassScope(user, options.classId);
const scope = await resolveClassScope(user, options.classId, 'grade:read');
if (isClassDevice(user)) await assertDetailQuery(user, user.classId ?? user.sub);
const grades = await prisma.grade.findMany({
where: {
@@ -220,11 +220,11 @@ export async function getGradeStats(user: TokenPayload, options: ListGradeOption
/**
* 录入一条成绩。
*
* 权限走 `assertCanManageSubjectContent`:**科任老师只能录自己任教科目**的成绩,
* 权限走 ``:**科任老师只能录自己任教科目**的成绩,
* 班主任若不教这一科同样被拒;不指定科目(courseId 为空)时只有班主任/管理员可录。
*/
export async function createGrade(user: TokenPayload, input: CreateGradeInput): Promise<GradeDto> {
await assertCanManageSubjectContent(user, input.classId, input.courseId ?? null, '录入成绩');
await assertContentWrite(user, 'grade:write', input.classId, input.courseId ?? null, '录入成绩');
await assertStudentInClass(input.studentId, input.classId);
if (input.courseId) await assertCourseInClass(input.courseId, input.classId);
@@ -249,7 +249,7 @@ export async function createGrade(user: TokenPayload, input: CreateGradeInput):
/** 批量录入:同一个考试一次提交整班成绩 */
export async function bulkCreateGrades(user: TokenPayload, input: BulkCreateGradeInput): Promise<GradeDto[]> {
await assertCanManageSubjectContent(user, input.classId, input.courseId ?? null, '录入成绩');
await assertContentWrite(user, 'grade:write', input.classId, input.courseId ?? null, '录入成绩');
if (input.courseId) await assertCourseInClass(input.courseId, input.classId);
const publishedAt = parseOptionalDate(input.publishedAt) ?? new Date();
@@ -292,8 +292,11 @@ export async function updateGrade(
if (!current) throw ApiError.notFound('成绩记录不存在');
// 改的是哪条成绩,就按那条成绩所属的班级 + 科目判权限
await assertContentWrite(user, 'grade:write', current.classId, current.courseId, '修改成绩', {
allowClassDevice: false,
});
const nextCourseId = input.courseId === undefined ? current.courseId : (input.courseId ?? null);
await assertCanManageSubjectContent(user, current.classId, nextCourseId, '修改成绩');
await assertContentWrite(user, 'grade:write', current.classId, nextCourseId, '修改成绩');
if (nextCourseId) await assertCourseInClass(nextCourseId, current.classId);
const totalScore = input.totalScore ?? current.totalScore;
@@ -331,7 +334,7 @@ export async function updateGrade(
export async function deleteGrade(user: TokenPayload, gradeId: string): Promise<void> {
const current = await prisma.grade.findFirst({ where: { id: gradeId, student: activeStudentWhere() } });
if (!current) throw ApiError.notFound('成绩记录不存在');
await assertCanManageSubjectContent(user, current.classId, current.courseId, '删除成绩');
await assertContentWrite(user, 'grade:write', current.classId, current.courseId, '删除成绩');
await prisma.grade.delete({ where: { id: gradeId } });
await emitGradeUpdated(current.classId, {
@@ -369,7 +372,7 @@ export async function updateGradeLevels(
// 逐条判权限:可能跨多个班级/科目(管理员在成绩页多选修改时会这样)
for (const item of input.items) {
const record = byId.get(item.id)!;
await assertCanManageSubjectContent(user, record.classId, record.courseId, '修改成绩等级');
await assertContentWrite(user, 'grade:write', record.classId, record.courseId, '修改成绩等级');
}
await prisma.$transaction(
@@ -387,7 +390,7 @@ export async function updateGradeLevels(
// 整批重算
const classId = input.classId!;
await assertCanManageSubjectContent(user, classId, input.courseId ?? null, '调整成绩等级');
await assertContentWrite(user, 'grade:write', classId, input.courseId ?? null, '调整成绩等级');
const levelType = input.levelType!;
if (levelType === 'custom') throw ApiError.badRequest('自定义等级不能由分数推算,请逐条填写等级');
const records = await prisma.grade.findMany({
@@ -433,7 +436,7 @@ export async function getStudentGradeDetail(
requestedClassId?: string,
): Promise<StudentGradeDetailDto> {
if (isClassDevice(user)) await assertDetailQuery(user, user.classId ?? user.sub);
const scope = await resolveClassScope(user, requestedClassId);
const scope = await resolveClassScope(user, requestedClassId, 'student:grade-query');
const student = await prisma.student.findFirst({
where: { ...activeStudentWhere(), studentNo: normalizeStudentNo(studentNo), ...classScopeWhere(scope) },
@@ -458,7 +461,7 @@ export async function getStudentGradeDetail(
}
const grades = await prisma.grade.findMany({
where: { studentId: student.id },
where: { studentId: student.id, ...classScopeWhere(scope) },
include,
orderBy: [{ publishedAt: 'desc' }, { examName: 'asc' }],
take: 500,
@@ -488,7 +491,7 @@ export async function getStudentGradeDetailById(
});
if (!student || ('status' in student && !isActiveStudent(student as { status: string })))
throw ApiError.notFound('学生不存在');
await assertClassAccess(user, student.classId ?? '');
await assertClassAccess(user, student.classId ?? '', 'student:grade-query');
return getStudentGradeDetail(user, student.studentNo);
}
@@ -579,7 +582,8 @@ async function assertCourseInClass(courseId: string, classId: string): Promise<v
* 只列 `Exam` 会让刚导进去的那次考试在界面上根本找不到。
*/
export async function listExams(user: TokenPayload, classId: string): Promise<ExamDto[]> {
await assertClassAccess(user, classId);
if (isClassDevice(user)) await assertDetailQuery(user, classId);
await assertClassAccess(user, classId, 'grade:read');
const [groups, exams] = await Promise.all([
prisma.grade.groupBy({
by: ['examName'],
@@ -619,7 +623,7 @@ export async function setExamCountInTotal(
name: string,
countInTotal: boolean,
): Promise<ExamDto> {
await assertClassAccess(user, classId);
await assertManage(user, 'grade:write', classId);
if (!isStaff(user)) throw ApiError.forbidden('只有教师/管理员可以设置考试口径');
const row = await prisma.exam.upsert({
@@ -666,7 +670,7 @@ export async function getExamRanking(
classId: string,
examName: string,
): Promise<ExamRankingDto> {
await assertClassAccess(user, classId);
await assertClassAccess(user, classId, 'grade:read');
const classRecord = await prisma.class.findUnique({
where: { id: classId },
select: { name: true, studentGradeQueryEnabled: true },
@@ -4,7 +4,7 @@ import { idParamSchema } from '../../lib/schemas.js';
import {
authenticate,
getAuthUser,
requireStaffPermission,
requirePermissionOrClassDevice,
requirePermission,
} from '../../middleware/auth.js';
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
@@ -32,7 +32,7 @@ router.use(authenticate());
/** GET /api/homeworks?classId=&courseId=&pendingOnly=&keyword= - 作业列表 */
router.get(
'/',
requireStaffPermission('homework:read'),
requirePermissionOrClassDevice('homework:read'),
validate({ query: listHomeworksQuerySchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -54,7 +54,7 @@ router.get(
*/
router.get(
'/days',
requireStaffPermission('homework:read'),
requirePermissionOrClassDevice('homework:read'),
validate({ query: listHomeworkDaysQuerySchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -65,7 +65,7 @@ router.get(
router.get(
'/:id',
requireStaffPermission('homework:read'),
requirePermissionOrClassDevice('homework:read'),
validate({ params: idParamSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -82,7 +82,7 @@ router.get(
*/
router.post(
'/',
requireStaffPermission('homework:write'),
requirePermissionOrClassDevice('homework:write'),
validate({ body: createHomeworkSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -99,7 +99,7 @@ router.post(
*
* 与 POST / 的区别:批量发布是 Web 管理端的"多班同发"工作流,因此这里挂 `homework:write`;
* 教室机器的班级账号仍走单班录入(POST /,权限在服务层判,见 AGENTS.md 的现行工程约定)。
* 每个班的科目归属依然由 createHomework 里的 assertCanManageSubjectContent 逐个判定 ——
* 每个班的科目归属依然由 createHomework 里的 逐个判定 ——
* 批量不是绕过科目门的后门(§5 第 5 条)。
*/
router.post(
@@ -145,7 +145,7 @@ router.delete(
/** PATCH /api/homeworks/:id/status - 标记完成 / 取消完成 */
router.patch(
'/:id/status',
requireStaffPermission('homework:write'),
requirePermissionOrClassDevice('homework:write'),
validate({ params: idParamSchema, body: updateHomeworkStatusSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -162,7 +162,7 @@ router.patch(
/** GET /api/homeworks/:id/submissions - 提交名单(未交名单功能) */
router.get(
'/:id/submissions',
requireStaffPermission('homework:read'),
requirePermissionOrClassDevice('homework:read'),
validate({ params: idParamSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -175,7 +175,7 @@ router.get(
/** PATCH /api/homeworks/:id/submissions - 勾选未交名单(其余学生一律视为已交) */
router.patch(
'/:id/submissions',
requireStaffPermission('homework:write'),
requirePermissionOrClassDevice('homework:write'),
validate({ params: idParamSchema, body: updateHomeworkSubmissionsSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -1,4 +1,5 @@
import { assertStaffPermission } from '../../lib/permissions.js';
import { assertContentWrite } from '../../lib/authz/index.js';
import { assertPermissionOrClassDevice } from '../../lib/permissions.js';
import {
SOCKET_EVENTS,
dayKeyLocal,
@@ -12,13 +13,7 @@ import {
type HomeworkSubmissionDto,
type HomeworkSubmissionsDto,
} from '@classhelper/shared';
import {
assertCanManageSubjectContent,
assertClassAccess,
classScopeWhere,
isClassDevice,
resolveClassScope,
} from '../../lib/access.js';
import { assertClassAccess, classScopeWhere, isClassDevice, resolveClassScope } from '../../lib/access.js';
import { normalizeBulkClassIds, summarizeBulk, toBulkFailure } from '../../lib/bulk.js';
import { activeStudentWhere, isActiveStudent } from '../../lib/students.js';
import { prisma } from '../../lib/db.js';
@@ -56,8 +51,8 @@ export async function listHomeworks(
user: TokenPayload,
options: ListHomeworkOptions,
): Promise<HomeworkDto[]> {
await assertStaffPermission(user, 'homework:read');
const scope = await resolveClassScope(user, options.classId);
await assertPermissionOrClassDevice(user, 'homework:read');
const scope = await resolveClassScope(user, options.classId, 'homework:read');
const device = isClassDevice(user);
// ClassHelper 班级端以全班学生为范围;教师/管理员没有"自己的完成状态"
const personalIds = device ? await resolvePersonalIds(user) : [];
@@ -97,8 +92,8 @@ export async function listHomeworkDays(
user: TokenPayload,
options: { classId?: string; from?: string; to?: string; days?: number },
): Promise<HomeworkDaysDto> {
await assertStaffPermission(user, 'homework:read');
const scope = await resolveClassScope(user, options.classId);
await assertPermissionOrClassDevice(user, 'homework:read');
const scope = await resolveClassScope(user, options.classId, 'homework:read');
const today = dayKeyLocal(new Date());
const to = options.to ?? today;
const from = options.from ?? shiftDayKey(to, -(options.days ?? 60) + 1);
@@ -122,7 +117,7 @@ export async function listHomeworkDays(
}
export async function getHomework(user: TokenPayload, homeworkId: string): Promise<HomeworkDto> {
await assertStaffPermission(user, 'homework:read');
await assertPermissionOrClassDevice(user, 'homework:read');
const device = isClassDevice(user);
const personalIds = device ? await resolvePersonalIds(user) : [];
const homework = await prisma.homework.findUnique({
@@ -136,7 +131,7 @@ export async function getHomework(user: TokenPayload, homeworkId: string): Promi
},
});
if (!homework) throw ApiError.notFound('作业不存在');
await assertClassAccess(user, homework.classId);
await assertClassAccess(user, homework.classId, 'homework:read');
return toHomeworkDto(homework, {
studentIds: device ? personalIds : [],
@@ -149,15 +144,15 @@ export async function getHomework(user: TokenPayload, homeworkId: string): Promi
/**
* 发布作业并实时推送到班级房间。
*
* 权限走 `assertCanManageSubjectContent`(`allowClassDevice: true`):
* 权限走 ``(`allowClassDevice: true`):
* - **选了科目** → 必须是该科的任课老师(班主任不教这一科也发不了);
* - **没选科目** → 本班班主任或管理员;
* - **ClassHelper 班级端** → 可以录(老师在教室电脑上顺手记一条是常见需求,
* 见 AGENTS.md 的现行工程约定),班级端录入的作业归属该班班主任。
*/
export async function createHomework(user: TokenPayload, input: CreateHomeworkInput): Promise<HomeworkDto> {
await assertStaffPermission(user, 'homework:write');
await assertCanManageSubjectContent(user, input.classId, input.courseId ?? null, '布置作业', {
await assertPermissionOrClassDevice(user, 'homework:write');
await assertContentWrite(user, 'homework:write', input.classId, input.courseId ?? null, '布置作业', {
allowClassDevice: true,
});
if (input.courseId) await assertCourseInClass(input.courseId, input.classId);
@@ -212,7 +207,7 @@ export async function bulkCreateHomeworks(
user: TokenPayload,
input: BulkCreateHomeworkRequest,
): Promise<BulkPublishResult> {
await assertStaffPermission(user, 'homework:write');
await assertPermissionOrClassDevice(user, 'homework:write');
const classIds = normalizeBulkClassIds(input.classIds);
const results: BulkPublishClassResult[] = [];
@@ -261,13 +256,16 @@ export async function updateHomework(
homeworkId: string,
input: UpdateHomeworkInput,
): Promise<HomeworkDto> {
await assertStaffPermission(user, 'homework:write');
await assertPermissionOrClassDevice(user, 'homework:write');
const current = await prisma.homework.findUnique({ where: { id: homeworkId } });
if (!current) throw ApiError.notFound('作业不存在');
// 改科目等于把这条作业挪到另一个科目的权限域下,两端都要判
await assertContentWrite(user, 'homework:write', current.classId, current.courseId, '修改作业', {
allowClassDevice: true,
});
const nextCourseId = input.courseId === undefined ? current.courseId : (input.courseId ?? null);
await assertCanManageSubjectContent(user, current.classId, nextCourseId, '修改作业', {
await assertContentWrite(user, 'homework:write', current.classId, nextCourseId, '修改作业', {
allowClassDevice: true,
});
if (nextCourseId) await assertCourseInClass(nextCourseId, current.classId);
@@ -294,13 +292,13 @@ export async function updateHomework(
}
export async function deleteHomework(user: TokenPayload, homeworkId: string): Promise<void> {
await assertStaffPermission(user, 'homework:write');
await assertPermissionOrClassDevice(user, 'homework:write');
const current = await prisma.homework.findUnique({
where: { id: homeworkId },
include: { course: courseSelect, creator: creatorSelect },
});
if (!current) throw ApiError.notFound('作业不存在');
await assertCanManageSubjectContent(user, current.classId, current.courseId, '删除作业', {
await assertContentWrite(user, 'homework:write', current.classId, current.courseId, '删除作业', {
allowClassDevice: true,
});
@@ -323,11 +321,13 @@ export async function updateHomeworkStatus(
homeworkId: string,
input: UpdateHomeworkStatusInput,
): Promise<HomeworkStatusDto> {
await assertStaffPermission(user, 'homework:write');
await assertPermissionOrClassDevice(user, 'homework:write');
const homework = await prisma.homework.findUnique({ where: { id: homeworkId } });
if (!homework) throw ApiError.notFound('作业不存在');
await assertClassAccess(user, homework.classId);
await assertContentWrite(user, 'homework:write', homework.classId, homework.courseId, '维护作业提交名单', {
allowClassDevice: true,
});
if (isClassDevice(user)) {
const studentIds = await resolvePersonalIds(user);
@@ -417,13 +417,15 @@ export async function listHomeworkSubmissions(
user: TokenPayload,
homeworkId: string,
): Promise<HomeworkSubmissionsDto> {
await assertStaffPermission(user, 'homework:read');
await assertPermissionOrClassDevice(user, 'homework:write');
const homework = await prisma.homework.findUnique({
where: { id: homeworkId },
select: { id: true, classId: true },
select: { id: true, classId: true, courseId: true },
});
if (!homework) throw ApiError.notFound('作业不存在');
await assertClassAccess(user, homework.classId);
await assertContentWrite(user, 'homework:write', homework.classId, homework.courseId, '维护作业提交名单', {
allowClassDevice: true,
});
assertCanManageSubmissions(user);
return buildSubmissions(homework.classId, homeworkId);
}
@@ -437,13 +439,15 @@ export async function updateHomeworkSubmissions(
homeworkId: string,
input: UpdateHomeworkSubmissionsInput,
): Promise<HomeworkSubmissionsDto> {
await assertStaffPermission(user, 'homework:write');
await assertPermissionOrClassDevice(user, 'homework:write');
const homework = await prisma.homework.findUnique({
where: { id: homeworkId },
select: { id: true, classId: true },
select: { id: true, classId: true, courseId: true },
});
if (!homework) throw ApiError.notFound('作业不存在');
await assertClassAccess(user, homework.classId);
await assertContentWrite(user, 'homework:write', homework.classId, homework.courseId, '维护作业提交名单', {
allowClassDevice: true,
});
assertCanManageSubmissions(user);
const students = await prisma.student.findMany({
@@ -6,7 +6,7 @@ import { idParamSchema } from '../../lib/schemas.js';
import {
authenticate,
getAuthUser,
requireStaffPermission,
requirePermissionOrClassDevice,
requirePermission,
requireAnyPermission,
} from '../../middleware/auth.js';
@@ -115,7 +115,7 @@ router.post(
const input = validatedBody<TableCommitInput>(req);
await assertImportKindAllowed(input.kind, getAuthUser(req));
// 教师名单与班级无关;成绩/学生名单必须带班级,否则拒绝(避免写到"全局")
if (input.kind !== 'teachers' && input.kind !== 'classTeachers' && !input.classId) {
if (input.kind === 'grades' && !input.classId) {
throw ApiError.badRequest('请先选择要导入的班级');
}
const result = await commitTable(getAuthUser(req), input);
@@ -147,7 +147,7 @@ router.post(
/** GET /api/imports/time-layout?classId= - 查看该班已导入的时间配置 */
router.get(
'/time-layout',
requireStaffPermission('schedule:read'),
requirePermissionOrClassDevice('schedule:read'),
validate({ query: timeLayoutQuerySchema }),
async (req, res) => {
const { classId } = validatedQuery<{ classId: string }>(req);
@@ -161,7 +161,7 @@ router.get(
*/
router.post(
'/time-layout',
requirePermission('timetable:manage', 'import:schedule'),
requirePermission('schedule:write', 'import:schedule'),
validate({ body: timeLayoutImportSchema }),
async (req, res) => {
const result = await importTimeLayout(getAuthUser(req), validatedBody<TimeLayoutImportInput>(req));
@@ -178,7 +178,7 @@ router.post(
/** DELETE /api/imports/time-layout/:id - 删除某份时间配置 */
router.delete(
'/time-layout/:id',
requirePermission('timetable:manage'),
requirePermission('import:schedule', 'schedule:write'),
validate({ params: idParamSchema }),
async (req, res) => {
const { id } = validatedParams<{ id: string }>(req);
@@ -1,7 +1,8 @@
import { refreshClassSubscriptions } from '../../realtime/bus.js';
import { assertAdmin, assertManage, assertContentWrite } from '../../lib/authz/index.js';
import { changeStudentLifecycle, notifyStudentClasses } from '../../lib/student-lifecycle.js';
import { assertPermission } from '../../lib/permissions.js';
import { assertCanManageRoster, assertCanAssignTeachers, assertClassAccess } from '../../lib/access.js';
import { addStudent } from '../classes/classes.service.js';
import { updateStudent } from '../students/students.service.js';
import * as XLSX from 'xlsx';
import { formatClassName, normalizeStudentNo, type TableImportKind } from '@classhelper/shared';
import { activeStudentWhere, isActiveStudent } from '../../lib/students.js';
@@ -11,8 +12,6 @@ import { ApiError } from '../../lib/http.js';
import type { TokenPayload } from '../../lib/jwt.js';
import { logger } from '../../lib/logger.js';
import { hashPassword } from '../../lib/password.js';
import { resolveUserClassRole } from '../../lib/access.js';
import { canImport, canManageSubjectContent } from '@classhelper/shared';
import type { TableCommitInput, TableFileInput } from './imports.schemas.js';
/**
@@ -362,7 +361,6 @@ export async function commitGrades(user: TokenPayload, input: TableCommitInput):
* 不属于那门课的记录(且覆盖语义的 append/overwrite 会把它写进同一张表)。
* 判定沿用 shared 的 canManageSubjectContent,与 Web 端单条录入、作业发布**同一份规则**。
*/
const classRole = await resolveUserClassRole(user, classId);
const errors: ImportRowError[] = [];
const warnings: string[] = [];
@@ -425,21 +423,7 @@ export async function commitGrades(user: TokenPayload, input: TableCommitInput):
warnings.push(`第 ${rowNumber} 行:科目「${courseName}」不存在,已按"不指定科目"导入`);
// 该科目的成绩能不能由**当前这个人**写(管理员全通;挂了科目就只能是该科任课老师)
const canWriteCourse = canManageSubjectContent({
classRole,
hasSubject: Boolean(course),
isSubjectTeacherOfCourse: course ? course.teacherId === user.sub : false,
allowClassDevice: false,
});
if (!canWriteCourse) {
errors.push({
row: rowNumber,
message: course
? `「${course.name}」不是你任教的科目,不能导入该科目的成绩`
: '不指定科目的成绩只有班主任或管理员可以导入,请填写科目列后再导入',
});
continue;
}
await assertContentWrite(user, 'grade:import', classId, course?.id ?? null, '导入成绩');
// 表格里带等级列就按"自定义等级"落库(教师明确给了等级),否则按百分制换算
const levelType = levelText ? 'custom' : 'percent';
@@ -521,6 +505,15 @@ export async function commitStudents(user: TokenPayload, input: TableCommitInput
else classByName.set(key, item);
}
const operations: {
classId: string;
studentNo: string;
name: string;
gender: string;
guardianPhone: string;
existingId: string | null;
sourceClassId: string | null;
}[] = [];
const errors: ImportRowError[] = [];
const warnings: string[] = [];
let inserted = 0;
@@ -580,19 +573,68 @@ export async function commitStudents(user: TokenPayload, input: TableCommitInput
skipped += 1;
continue;
}
if (existing.classId !== classId)
await addStudent(user, classId, { studentNo, name, gender, guardianPhone });
await updateStudent(user, existing.id, {
if (existing.classId !== classId) await assertCanManageRoster(user, existing.classId ?? undefined);
operations.push({
classId,
studentNo,
name,
...(gender ? { gender } : {}),
...(guardianPhone ? { guardianPhone } : {}),
gender,
guardianPhone,
existingId: existing.id,
sourceClassId: existing.classId,
});
updated += 1;
} else {
await addStudent(user, classId, { studentNo, name, gender, guardianPhone });
operations.push({
classId,
studentNo,
name,
gender,
guardianPhone,
existingId: null,
sourceClassId: null,
});
inserted += 1;
}
}
await prisma.$transaction(
async (tx) => {
for (const op of operations) {
if (!op.existingId || op.sourceClassId !== op.classId)
await changeStudentLifecycle(
user,
{
studentIds: op.existingId ? [op.existingId] : [],
kind: 'class-change',
toClassId: op.classId,
note: '学生名单导入',
...(!op.existingId
? {
newStudent: {
studentNo: op.studentNo,
name: op.name,
gender: op.gender,
guardianPhone: op.guardianPhone,
},
}
: {}),
},
tx,
);
if (op.existingId)
await tx.student.update({
where: { id: op.existingId },
data: {
name: op.name,
...(op.gender ? { gender: op.gender } : {}),
...(op.guardianPhone ? { guardianPhone: op.guardianPhone } : {}),
},
});
}
},
{ timeout: 30000 },
);
notifyStudentClasses(operations.flatMap((op) => [op.sourceClassId, op.classId]));
const result: ImportResult = {
kind: 'students',
@@ -618,73 +660,94 @@ export async function commitStudents(user: TokenPayload, input: TableCommitInput
*/
export async function commitTeachers(user: TokenPayload, input: TableCommitInput): Promise<ImportResult> {
await assertImportAllowed(user, undefined, 'teachers');
const buffer = decodeBase64(input.contentBase64);
const parsed = parseTable(buffer, input.fileName);
assertAdmin(user);
return prisma.$transaction(
async (tx) => {
const buffer = decodeBase64(input.contentBase64);
const parsed = parseTable(buffer, input.fileName);
const errors: ImportRowError[] = [];
const warnings: string[] = [];
let inserted = 0;
let updated = 0;
let skipped = 0;
const errors: ImportRowError[] = [];
const warnings: string[] = [];
let inserted = 0;
let updated = 0;
let skipped = 0;
for (const [index, row] of parsed.rows.entries()) {
const rowNumber = index + 2;
const username = cellValue(row, parsed.columns, input.mapping, 'username');
const name = cellValue(row, parsed.columns, input.mapping, 'name');
const phone = cellValue(row, parsed.columns, input.mapping, 'phone');
const password = cellValue(row, parsed.columns, input.mapping, 'password');
const roleCell = cellValue(row, parsed.columns, input.mapping, 'role');
for (const [index, row] of parsed.rows.entries()) {
const rowNumber = index + 2;
const username = cellValue(row, parsed.columns, input.mapping, 'username');
const name = cellValue(row, parsed.columns, input.mapping, 'name');
const phone = cellValue(row, parsed.columns, input.mapping, 'phone');
const password = cellValue(row, parsed.columns, input.mapping, 'password');
const roleCell = cellValue(row, parsed.columns, input.mapping, 'role');
if (!username || !name) {
errors.push({ row: rowNumber, message: '工号与姓名都必须填写' });
continue;
}
if (!/^[A-Za-z0-9_.-]{3,32}$/.test(username)) {
errors.push({ row: rowNumber, message: `工号「${username}」格式不合法(3~32 位字母/数字/._-)` });
continue;
}
if (!username || !name) {
errors.push({ row: rowNumber, message: '工号与姓名都必须填写' });
continue;
}
if (!/^[A-Za-z0-9_.-]{3,32}$/.test(username)) {
errors.push({ row: rowNumber, message: `工号「${username}」格式不合法(3~32 位字母/数字/._-)` });
continue;
}
const existing = await prisma.user.findUnique({ where: { username }, select: { id: true, role: true } });
if (existing) {
if (input.mode === 'append') {
skipped += 1;
continue;
const existing = await tx.user.findUnique({ where: { username }, select: { id: true, role: true } });
if (existing?.role === 'ADMIN') {
errors.push({ row: rowNumber, message: '管理员账号请通过教师账号管理修改' });
continue;
}
if (existing) {
if (input.mode === 'append') {
skipped += 1;
continue;
}
const nextRole = parseTeacherRole(roleCell) ?? existing.role;
await tx.user.update({
where: { id: existing.id },
data: { name, role: nextRole, ...(phone ? { phone } : {}) },
});
updated += 1;
} else {
const initialPassword = password || env.defaultTeacherPassword;
await tx.user.create({
data: {
username,
name,
phone,
role: parseTeacherRole(roleCell) ?? 'TEACHER',
passwordHash: await hashPassword(initialPassword),
},
});
inserted += 1;
}
}
const nextRole = parseTeacherRole(roleCell) ?? existing.role;
await prisma.user.update({
where: { id: existing.id },
data: { name, role: nextRole, ...(phone ? { phone } : {}) },
});
updated += 1;
} else {
const initialPassword = password || env.defaultTeacherPassword;
await prisma.user.create({
const result: ImportResult = {
kind: 'teachers',
total: parsed.rows.length,
inserted,
updated,
skipped,
failed: errors.length,
errors,
warnings,
};
logger.info(
`教师名单导入:模式=${input.mode} 新增=${inserted} 更新=${updated} 跳过=${skipped} 失败=${errors.length}`,
);
await tx.permissionAudit.create({
data: {
username,
name,
phone,
role: parseTeacherRole(roleCell) ?? 'TEACHER',
passwordHash: await hashPassword(initialPassword),
actorId: user.sub,
actorName: user.name,
action: 'import-commitTeachers',
targetType: 'import',
targetId: '',
beforeJson: 'null',
afterJson: JSON.stringify(result),
},
});
inserted += 1;
}
}
const result: ImportResult = {
kind: 'teachers',
total: parsed.rows.length,
inserted,
updated,
skipped,
failed: errors.length,
errors,
warnings,
};
logger.info(
`教师名单导入:模式=${input.mode} 新增=${inserted} 更新=${updated} 跳过=${skipped} 失败=${errors.length}`,
return result;
},
{ timeout: 30000 },
);
return result;
}
/* ------------------------------------------------------------------ 班级任课老师 */
@@ -705,160 +768,184 @@ export async function commitClassTeachers(
input: TableCommitInput,
): Promise<ImportResult> {
await assertImportAllowed(user, undefined, 'classTeachers');
const buffer = decodeBase64(input.contentBase64);
const parsed = parseTable(buffer, input.fileName);
assertAdmin(user);
return prisma
.$transaction(
async (tx) => {
const buffer = decodeBase64(input.contentBase64);
const parsed = parseTable(buffer, input.fileName);
const useTeacherNo = input.useTeacherNo !== false; // 默认开:有工号就优先按工号匹配
const useTeacherNo = input.useTeacherNo !== false; // 默认开:有工号就优先按工号匹配
const errors: ImportRowError[] = [];
const warnings: string[] = [];
let inserted = 0;
let updated = 0;
let skipped = 0;
const errors: ImportRowError[] = [];
const warnings: string[] = [];
let inserted = 0;
let updated = 0;
let skipped = 0;
const classes = await prisma.class.findMany({
where: { archivedYearId: null },
select: { id: true, name: true, teacherId: true },
});
const classByName = new Map<string, { id: string; name: string; teacherId: string | null }>();
for (const item of classes) {
const key = item.name.toLowerCase();
if (classByName.has(key)) classByName.delete(key);
else classByName.set(key, item);
}
// 本次导入新建的教师账号(同一份表格里再遇到同名/同工号时复用,避免重复建号)
const createdTeachers = new Map<string, string>();
for (const [index, row] of parsed.rows.entries()) {
const rowNumber = index + 2;
const className = cellValue(row, parsed.columns, input.mapping, 'className');
const subjectName = cellValue(row, parsed.columns, input.mapping, 'subjectName');
const teacherNo = cellValue(row, parsed.columns, input.mapping, 'teacherNo');
const teacherName = cellValue(row, parsed.columns, input.mapping, 'teacherName');
const roleCell = cellValue(row, parsed.columns, input.mapping, 'role');
if (!className || !teacherNo) {
errors.push({ row: rowNumber, message: '班级与教师工号都必须填写' });
continue;
}
const targetClass = classByName.get(className.toLowerCase());
if (!targetClass) {
errors.push({ row: rowNumber, message: `找不到班级「${className}」(可能已毕业归档或名称重复)` });
continue;
}
await assertImportAllowed(user, targetClass.id, 'classTeachers');
const role = parseClassTeacherRole(roleCell);
if (!role) {
errors.push({ row: rowNumber, message: `角色「${roleCell}」无法识别,请填「班主任」或「科任」` });
continue;
}
if (role === 'SUBJECT' && !subjectName) {
errors.push({ row: rowNumber, message: '科任老师必须填写科目' });
continue;
}
// ---- 匹配或新建教师账号
let teacherId = createdTeachers.get(teacherNo.toLowerCase());
if (!teacherId) {
if (useTeacherNo) {
const byNo = await prisma.user.findUnique({
where: { username: teacherNo },
select: { id: true },
const classes = await tx.class.findMany({
where: { archivedYearId: null },
select: { id: true, name: true, teacherId: true },
});
if (byNo) {
teacherId = byNo.id;
const classByName = new Map<string, { id: string; name: string; teacherId: string | null }>();
for (const item of classes) {
const key = item.name.toLowerCase();
if (classByName.has(key)) classByName.delete(key);
else classByName.set(key, item);
}
} else if (teacherName) {
const byName = await prisma.user.findMany({
where: { name: teacherName, role: { in: ['TEACHER', 'ADMIN'] } },
select: { id: true },
take: 2,
});
if (byName.length === 1) {
teacherId = byName[0]!.id;
} else if (byName.length > 1) {
errors.push({
row: rowNumber,
message: `姓名「${teacherName}」对应多个教师账号,无法确定是哪一个;请打开「使用工号匹配」再导入`,
});
continue;
// 本次导入新建的教师账号(同一份表格里再遇到同名/同工号时复用,避免重复建号)
const createdTeachers = new Map<string, string>();
for (const [index, row] of parsed.rows.entries()) {
const rowNumber = index + 2;
const className = cellValue(row, parsed.columns, input.mapping, 'className');
const subjectName = cellValue(row, parsed.columns, input.mapping, 'subjectName');
const teacherNo = cellValue(row, parsed.columns, input.mapping, 'teacherNo');
const teacherName = cellValue(row, parsed.columns, input.mapping, 'teacherName');
const roleCell = cellValue(row, parsed.columns, input.mapping, 'role');
if (!className || !teacherNo) {
errors.push({ row: rowNumber, message: '班级与教师工号都必须填写' });
continue;
}
const targetClass = classByName.get(className.toLowerCase());
if (!targetClass) {
errors.push({
row: rowNumber,
message: `找不到班级「${className}」(可能已毕业归档或名称重复)`,
});
continue;
}
const role = parseClassTeacherRole(roleCell);
if (!role) {
errors.push({ row: rowNumber, message: `角色「${roleCell}」无法识别,请填「班主任」或「科任」` });
continue;
}
if (role === 'SUBJECT' && !subjectName) {
errors.push({ row: rowNumber, message: '科任老师必须填写科目' });
continue;
}
// ---- 匹配或新建教师账号
let teacherId = createdTeachers.get(teacherNo.toLowerCase());
if (!teacherId) {
if (useTeacherNo) {
const byNo = await tx.user.findUnique({
where: { username: teacherNo },
select: { id: true },
});
if (byNo) {
teacherId = byNo.id;
}
} else if (teacherName) {
const byName = await tx.user.findMany({
where: { name: teacherName, role: { in: ['TEACHER', 'ADMIN'] } },
select: { id: true },
take: 2,
});
if (byName.length === 1) {
teacherId = byName[0]!.id;
} else if (byName.length > 1) {
errors.push({
row: rowNumber,
message: `姓名「${teacherName}」对应多个教师账号,无法确定是哪一个;请打开「使用工号匹配」再导入`,
});
continue;
}
}
}
if (!teacherId) {
if (!useTeacherNo) {
errors.push({
row: rowNumber,
message: `按姓名找不到教师「${teacherName || teacherNo}」;请打开「使用工号匹配」以便按工号新建账号`,
});
continue;
}
await assertPermission(user, 'teacher:manage');
await assertPermission(user, 'import:teachers');
const created = await tx.user.create({
data: {
username: teacherNo,
name: teacherName || teacherNo,
role: 'TEACHER',
passwordHash: await hashPassword(env.defaultTeacherPassword),
},
select: { id: true },
});
teacherId = created.id;
inserted += 1;
warnings.push(
`第 ${rowNumber} 行:工号 ${teacherNo} 没有对应账号,已新建教师「${teacherName || teacherNo}」(初始密码见部署配置)`,
);
}
createdTeachers.set(teacherNo.toLowerCase(), teacherId);
// ---- 写入角色
if (role === 'HEAD') {
if (targetClass.teacherId === teacherId) {
skipped += 1;
continue;
}
await tx.class.update({ where: { id: targetClass.id }, data: { teacherId } });
targetClass.teacherId = teacherId;
updated += 1;
} else {
const existing = await tx.course.findUnique({
where: { classId_name: { classId: targetClass.id, name: subjectName } },
select: { id: true, teacherId: true },
});
if (existing) {
if (existing.teacherId === teacherId) {
skipped += 1;
continue;
}
await tx.course.update({ where: { id: existing.id }, data: { teacherId } });
} else {
await tx.course.create({
data: { name: subjectName, classId: targetClass.id, teacherId },
});
}
updated += 1;
}
}
}
}
if (!teacherId) {
if (!useTeacherNo) {
errors.push({
row: rowNumber,
message: `按姓名找不到教师「${teacherName || teacherNo}」;请打开「使用工号匹配」以便按工号新建账号`,
const result: ImportResult = {
kind: 'classTeachers',
total: parsed.rows.length,
inserted,
updated,
skipped,
failed: errors.length,
errors,
warnings,
};
logger.info(
`班级任课老师导入:用工号匹配=${useTeacherNo} 新建教师=${inserted} 更新关系=${updated} 跳过=${skipped} 失败=${errors.length}`,
);
await tx.permissionAudit.create({
data: {
actorId: user.sub,
actorName: user.name,
action: 'import-commitClassTeachers',
targetType: 'import',
targetId: '',
beforeJson: 'null',
afterJson: JSON.stringify(result),
},
});
continue;
}
await assertPermission(user, 'teacher:manage');
await assertPermission(user, 'import:teachers');
const created = await prisma.user.create({
data: {
username: teacherNo,
name: teacherName || teacherNo,
role: 'TEACHER',
passwordHash: await hashPassword(env.defaultTeacherPassword),
},
select: { id: true },
});
teacherId = created.id;
inserted += 1;
warnings.push(
`第 ${rowNumber} 行:工号 ${teacherNo} 没有对应账号,已新建教师「${teacherName || teacherNo}」(初始密码见部署配置)`,
);
}
createdTeachers.set(teacherNo.toLowerCase(), teacherId);
// ---- 写入角色
if (role === 'HEAD') {
if (targetClass.teacherId === teacherId) {
skipped += 1;
continue;
}
await prisma.class.update({ where: { id: targetClass.id }, data: { teacherId } });
targetClass.teacherId = teacherId;
updated += 1;
} else {
const existing = await prisma.course.findUnique({
where: { classId_name: { classId: targetClass.id, name: subjectName } },
select: { id: true, teacherId: true },
});
if (existing) {
if (existing.teacherId === teacherId) {
skipped += 1;
continue;
}
await prisma.course.update({ where: { id: existing.id }, data: { teacherId } });
} else {
await prisma.course.create({
data: { name: subjectName, classId: targetClass.id, teacherId },
});
}
updated += 1;
}
}
const result: ImportResult = {
kind: 'classTeachers',
total: parsed.rows.length,
inserted,
updated,
skipped,
failed: errors.length,
errors,
warnings,
};
logger.info(
`班级任课老师导入:用工号匹配=${useTeacherNo} 新建教师=${inserted} 更新关系=${updated} 跳过=${skipped} 失败=${errors.length}`,
);
return result;
return result;
},
{ timeout: 30000 },
)
.then((result) => {
refreshClassSubscriptions();
return result;
});
}
export async function commitTable(user: TokenPayload, input: TableCommitInput): Promise<ImportResult> {
@@ -890,19 +977,15 @@ async function assertImportAllowed(
} as const;
await assertPermission(user, key[kind]);
if (classId) await assertClassAccess(user, classId, key[kind]);
if (kind === 'students') return assertCanManageRoster(user, classId);
if (kind === 'students') {
if (classId) await assertManage(user, 'import:students', classId);
return assertCanManageRoster(user, classId);
}
if (kind === 'teachers') return assertPermission(user, 'teacher:manage');
if (kind === 'classTeachers') return assertCanAssignTeachers(user, classId);
const classRole = classId ? await resolveUserClassRole(user, classId) : 'NONE';
if (canImport(user.role, classRole, kind)) return;
if (kind === 'grades') {
throw ApiError.forbidden(
classRole === 'SUBJECT' || classRole === 'CLASS_DEVICE'
? '科任老师与 ClassHelper 班级端不能导入名单或成绩,请联系班主任或管理员'
: '只能导入自己担任班主任的班级的数据',
);
}
throw ApiError.forbidden('该导入仅限管理员使用');
if (kind === 'grades' && classId) return;
if (kind === 'scheduleTimes' && classId) return assertManage(user, 'import:schedule', classId);
throw ApiError.forbidden('请指定目标班级');
}
/** 只接受明确的角色值;空值或未识别值保留原角色,避免意外降级。 */
@@ -324,7 +324,7 @@ export async function importTimeLayout(
user: TokenPayload,
input: TimeLayoutImportInput,
): Promise<{ layout: TimeLayoutDto; mode: string; warnings: string[]; replaced: number; merged: number }> {
await assertCanImportSchedule(user, input.classId, 'timetable:manage');
await assertCanImportSchedule(user, input.classId);
const parsed = parseClassIslandTimeLayout(input.payload);
if (parsed.errors.length > 0) {
@@ -380,7 +380,7 @@ export async function importTimeLayout(
actionSet: '{}',
};
});
const layout = await saveLayout({ classId: input.classId, name, points }, current?.id);
const layout = await saveLayout(user, { classId: input.classId, name, points }, current?.id);
logger.info(`课表时间配置导入:班级=${input.classId} 模式=${input.mode} 共 ${combined.length} 个时间点`);
return {
layout: toDto(layout),
@@ -4,7 +4,7 @@ import { idParamSchema } from '../../lib/schemas.js';
import {
authenticate,
getAuthUser,
requireStaffPermission,
requirePermissionOrClassDevice,
requirePermission,
} from '../../middleware/auth.js';
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
@@ -28,7 +28,7 @@ router.use(authenticate());
/** GET /api/notifications?classId=&priority=&unreadOnly=&date=&from=&to=&createdBy= - 通知列表 */
router.get(
'/',
requireStaffPermission('notification:read'),
requirePermissionOrClassDevice('notification:read'),
validate({ query: listNotificationsQuerySchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -40,7 +40,7 @@ router.get(
/** GET /api/notifications/days?classId=&from=&to=&createdBy= - 按天归档(日期 + 条数) */
router.get(
'/days',
requireStaffPermission('notification:read'),
requirePermissionOrClassDevice('notification:read'),
validate({ query: notificationArchiveQuerySchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -52,7 +52,7 @@ router.get(
/** GET /api/notifications/creators?classId=&from=&to=&date= - 按发布人归档 */
router.get(
'/creators',
requireStaffPermission('notification:read'),
requirePermissionOrClassDevice('notification:read'),
validate({ query: notificationArchiveQuerySchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -64,7 +64,7 @@ router.get(
/** GET /api/notifications/unread-count?classId= - 未读数(客户端红点) */
router.get(
'/unread-count',
requireStaffPermission('notification:read'),
requirePermissionOrClassDevice('notification:read'),
validate({ query: notificationScopeQuerySchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -76,7 +76,7 @@ router.get(
/** POST /api/notifications/read-all - 全部标为已读 */
router.post(
'/read-all',
requireStaffPermission('notification:read'),
requirePermissionOrClassDevice('notification:read'),
validate({ body: notificationScopeQuerySchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -88,7 +88,7 @@ router.post(
/** POST /api/notifications - 发布通知(发布后广播 notification:new) */
router.post(
'/',
requireStaffPermission('notification:publish'),
requirePermissionOrClassDevice('notification:publish'),
validate({ body: createNotificationSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -100,7 +100,7 @@ router.post(
/** POST /api/notifications/bulk - 一次给多个班级发同一条通知(逐班结果,不整批回滚) */
router.post(
'/bulk',
requireStaffPermission('notification:publish'),
requirePermissionOrClassDevice('notification:publish'),
validate({ body: bulkCreateNotificationSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -115,7 +115,7 @@ router.post(
/** POST /api/notifications/:id/read - 标记已读 */
router.post(
'/:id/read',
requireStaffPermission('notification:read'),
requirePermissionOrClassDevice('notification:read'),
validate({ params: idParamSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -1,4 +1,6 @@
import { assertStaffPermission, assertPermission } from '../../lib/permissions.js';
import { assertClassRead, isHead } from '../../lib/authz/index.js';
import { assertPublish } from '../../lib/authz/index.js';
import { assertPermissionOrClassDevice, assertPermission } from '../../lib/permissions.js';
import { resolveContentCreator } from '../../lib/access.js';
import {
SOCKET_EVENTS,
@@ -12,12 +14,7 @@ import {
type NotificationDto,
} from '@classhelper/shared';
import { normalizeBulkClassIds, summarizeBulk, toBulkFailure } from '../../lib/bulk.js';
import {
assertClassAccess,
assertCanPublishNotification,
classScopeWhere,
resolveClassScope,
} from '../../lib/access.js';
import { assertClassAccess, classScopeWhere, resolveClassScope } from '../../lib/access.js';
import { activeStudentWhere } from '../../lib/students.js';
import { prisma } from '../../lib/db.js';
import { ApiError } from '../../lib/http.js';
@@ -73,8 +70,8 @@ export async function listNotifications(
user: TokenPayload,
options: ListNotificationOptions,
): Promise<NotificationDto[]> {
await assertStaffPermission(user, 'notification:read');
const scope = await resolveClassScope(user, options.classId);
await assertPermissionOrClassDevice(user, 'notification:read');
const scope = await resolveClassScope(user, options.classId, 'notification:read');
const isStaff = user.role === 'ADMIN' || user.role === 'TEACHER';
// 班级账号(班级设备)以全班学生为"个人范围",普通学生就是自己
const personalIds = await resolvePersonalIds(user);
@@ -112,8 +109,8 @@ export async function listNotificationDays(
user: TokenPayload,
options: { classId?: string; priority?: string; createdBy?: string; from?: string; to?: string },
): Promise<NotificationDaysDto> {
await assertStaffPermission(user, 'notification:read');
const scope = await resolveClassScope(user, options.classId);
await assertPermissionOrClassDevice(user, 'notification:read');
const scope = await resolveClassScope(user, options.classId, 'notification:read');
const grouped = await prisma.notification.groupBy({
by: ['assignDate'],
where: { ...classScopeWhere(scope), ...archiveWhere(options) },
@@ -133,8 +130,8 @@ export async function listNotificationCreators(
user: TokenPayload,
options: { classId?: string; priority?: string; date?: string; from?: string; to?: string },
): Promise<NotificationCreatorsDto> {
await assertStaffPermission(user, 'notification:read');
const scope = await resolveClassScope(user, options.classId);
await assertPermissionOrClassDevice(user, 'notification:read');
const scope = await resolveClassScope(user, options.classId, 'notification:read');
const grouped = await prisma.notification.groupBy({
by: ['createdBy'],
where: { ...classScopeWhere(scope), ...archiveWhere(options) },
@@ -171,7 +168,7 @@ export async function bulkCreateNotifications(
user: TokenPayload,
input: BulkCreateNotificationRequest,
): Promise<BulkPublishResult> {
await assertStaffPermission(user, 'notification:publish');
await assertPermissionOrClassDevice(user, 'notification:publish');
const classIds = normalizeBulkClassIds(input.classIds);
const results: BulkPublishClassResult[] = [];
@@ -208,8 +205,8 @@ export async function createNotification(
user: TokenPayload,
input: CreateNotificationInput,
): Promise<NotificationDto> {
await assertStaffPermission(user, 'notification:publish');
await assertCanPublishNotification(user, input.classId);
await assertPermissionOrClassDevice(user, 'notification:publish');
await assertPublish(user, 'notification:publish', input.classId, { allowClassDevice: true });
const priority = input.priority ?? 'NORMAL';
if (priority === 'URGENT' && input.confirmDuringClass !== true) {
@@ -251,7 +248,9 @@ export async function deleteNotification(user: TokenPayload, notificationId: str
await assertPermission(user, 'notification:delete');
const current = await prisma.notification.findUnique({ where: { id: notificationId } });
if (!current) throw ApiError.notFound('通知不存在');
await assertCanPublishNotification(user, current.classId);
await assertClassRead(user, 'notification:delete', current.classId);
if (user.role !== 'ADMIN' && current.createdBy !== user.sub && !(await isHead(user, current.classId)))
throw ApiError.forbidden('只有发布者、班主任或管理员可删除通知');
await prisma.notification.delete({ where: { id: notificationId } });
emitToClass(current.classId, SOCKET_EVENTS.notificationDeleted, {
id: notificationId,
@@ -267,10 +266,10 @@ export async function markAsRead(
user: TokenPayload,
notificationId: string,
): Promise<{ notificationId: string; readAt: string; marked: number }> {
await assertStaffPermission(user, 'notification:read');
await assertPermissionOrClassDevice(user, 'notification:read');
const notification = await prisma.notification.findUnique({ where: { id: notificationId } });
if (!notification) throw ApiError.notFound('通知不存在');
await assertClassAccess(user, notification.classId);
await assertClassAccess(user, notification.classId, 'notification:read');
const personalIds = await resolvePersonalIds(user);
// 先过滤已读过的记录再创建:SQLite 下 createMany 不支持 skipDuplicates,
@@ -293,8 +292,8 @@ export async function markAsRead(
/** 全部标为已读(可按班级限定) */
export async function markAllAsRead(user: TokenPayload, classId?: string): Promise<{ marked: number }> {
await assertStaffPermission(user, 'notification:read');
const scope = await resolveClassScope(user, classId);
await assertPermissionOrClassDevice(user, 'notification:read');
const scope = await resolveClassScope(user, classId, 'notification:read');
const personalIds = await resolvePersonalIds(user);
const personalWhere = personalIdWhere(personalIds);
@@ -324,8 +323,8 @@ export async function markAllAsRead(user: TokenPayload, classId?: string): Promi
/** 未读数量(客户端红点) */
export async function unreadCount(user: TokenPayload, classId?: string): Promise<number> {
await assertStaffPermission(user, 'notification:read');
const scope = await resolveClassScope(user, classId);
await assertPermissionOrClassDevice(user, 'notification:read');
const scope = await resolveClassScope(user, classId, 'notification:read');
const personalIds = await resolvePersonalIds(user);
return prisma.notification.count({
where: { ...classScopeWhere(scope), reads: { none: personalIdWhere(personalIds) } },
@@ -1,9 +1,14 @@
import { z } from 'zod';
import { Router } from 'express';
import { sendCreated, sendOk } from '../../lib/http.js';
import { ApiError, sendCreated, sendOk } from '../../lib/http.js';
import { idParamSchema } from '../../lib/schemas.js';
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
import { validate, validatedBody, validatedParams, validatedQuery } from '../../middleware/validate.js';
import { authenticate, getAuthUser, requirePermission, requireAdmin } from '../../middleware/auth.js';
import {
validateBadRequest as validate,
validatedBody,
validatedParams,
validatedQuery,
} from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import {
assignUserGroupsSchema,
@@ -29,17 +34,19 @@ router.get('/me', async (req, res) => {
sendOk(res, await permissionService.myPermissions(user), '获取当前权限成功');
});
router.use(requireAdmin());
/** GET /api/permissions/catalog - 权限键目录(权限页整棵勾选树的唯一来源) */
router.get('/catalog', requirePermission('permission:manage'), async (_req, res) => {
sendOk(res, await permissionService.permissionCatalog(), '获取权限目录成功');
router.get('/catalog', requirePermission('permission:manage'), async (req, res) => {
sendOk(res, await permissionService.permissionCatalog(getAuthUser(req)), '获取权限目录成功');
});
/** GET /api/permissions/groups - 权限组列表 */
router.get('/groups', requirePermission('permission:manage'), async (_req, res) => {
sendOk(res, await permissionService.listGroups(), '获取权限组成功');
router.get('/groups', requirePermission('permission:manage'), async (req, res) => {
sendOk(res, await permissionService.listGroups(getAuthUser(req)), '获取权限组成功');
});
router.get('/scopes', requirePermission('permission:manage'), async (_req, res) =>
router.get('/scopes', requirePermission('permission:manage'), async (req, res) =>
sendOk(res, await permissionService.scopeOptions()),
);
router.get(
@@ -53,14 +60,15 @@ router.get(
}),
async (req, res) => {
const { page, pageSize } = validatedQuery<{ page: number; pageSize: number }>(req);
sendOk(res, await permissionService.listAudits(page, pageSize));
sendOk(res, await permissionService.listAudits(getAuthUser(req), page, pageSize));
},
);
router.get(
'/groups/:id/members',
requirePermission('permission:manage'),
validate({ params: idParamSchema }),
async (req, res) => sendOk(res, await permissionService.groupMembers(String(req.params.id))),
async (req, res) =>
sendOk(res, await permissionService.groupMembers(getAuthUser(req), String(req.params.id))),
);
/** POST /api/permissions/groups - 新建权限组 */
@@ -99,10 +107,29 @@ router.delete(
);
/** GET /api/permissions/users - 成员列表(含所属权限组与生效权限数量) */
router.get('/users', requirePermission('permission:manage'), async (_req, res) => {
sendOk(res, await permissionService.listMembers(), '获取成员成功');
router.get('/users', requirePermission('permission:manage'), async (req, res) => {
sendOk(res, await permissionService.listMembers(getAuthUser(req)), '获取成员成功');
});
router.get(
'/users/:id/effective',
validate({ params: idParamSchema, query: z.object({ assignments: z.string().max(16000).optional() }) }),
async (req, res) => {
const query = validatedQuery<{ assignments?: string }>(req);
let preview: AssignUserGroupsInput | undefined;
if (query.assignments !== undefined) {
try {
preview = assignUserGroupsSchema.parse({ assignments: JSON.parse(query.assignments) });
} catch {
throw ApiError.badRequest('分配预览格式不正确');
}
}
sendOk(
res,
await permissionService.effectivePermissions(getAuthUser(req), String(req.params.id), preview),
);
},
);
/** PUT /api/permissions/users/:id/groups - 整体替换某账号的权限组 */
router.put(
'/users/:id/groups',
@@ -22,7 +22,7 @@ const keysSchema = z.array(permissionKeySchema).max(200, '一次最多提交 200
export const createPermissionGroupSchema = z.object({
name: z.string().trim().min(1, '请输入权限组名称').max(40, '权限组名称最多 40 个字'),
description: z.string().trim().max(200, '描述最多 200 个字').optional(),
scope: z.enum(['SELF_CLASS', 'GRADE', 'DIVISION', 'ALL']).optional(),
scope: z.enum(['TEACHING', 'HEAD', 'GRADE', 'DIVISION', 'ALL']).optional(),
scopeValue: z.string().trim().max(200).optional(),
keys: keysSchema.optional(),
});
@@ -36,15 +36,33 @@ export const createPermissionGroupSchema = z.object({
export const updatePermissionGroupSchema = z.object({
name: z.string().trim().min(1, '请输入权限组名称').max(40, '权限组名称最多 40 个字').optional(),
description: z.string().trim().max(200, '描述最多 200 个字').optional(),
scope: z.enum(['SELF_CLASS', 'GRADE', 'DIVISION', 'ALL']).optional(),
scope: z.enum(['TEACHING', 'HEAD', 'GRADE', 'DIVISION', 'ALL']).optional(),
scopeValue: z.string().trim().max(200).optional(),
keys: keysSchema.optional(),
});
/** 给账号分配权限组:整体替换;传空数组 = 回到角色默认模板 */
export const assignUserGroupsSchema = z.object({
groupIds: z.array(z.string().min(1)).max(50, '一次最多分配 50 个权限组'),
});
export const assignUserGroupsSchema = z
.object({
groupIds: z.array(z.string().min(1)).max(50).optional(),
assignments: z
.array(
z
.object({
groupId: z.string().min(1),
scope: z.enum(['TEACHING', 'HEAD', 'GRADE', 'DIVISION', 'ALL']),
scopeValue: z.string().max(200).default(''),
})
.strict(),
)
.max(50)
.optional(),
})
.strict()
.refine(
(v) => (v.assignments !== undefined) !== (v.groupIds !== undefined),
'请提供 assignments 或 groupIds 之一',
);
export type CreatePermissionGroupInput = z.infer<typeof createPermissionGroupSchema>;
export type UpdatePermissionGroupInput = z.infer<typeof updatePermissionGroupSchema>;
@@ -1,165 +1,72 @@
import { disconnectUserSessions } from '../../realtime/bus.js';
import {
PERMISSION_CATEGORIES,
PERMISSION_KEYS,
PERMISSION_META,
PERMISSION_LABELS,
PERMISSION_CATEGORIES,
ROLE_DEFAULT_PERMISSIONS,
parsePermissionScope,
defaultPromotionPlan,
type PermissionKey,
type PermissionScope,
type PermissionGroupDto,
type PermissionMemberDto,
type PermissionCatalogDto,
type EffectivePermissionsDto,
type AssignUserGroupsRequest,
type CreatePermissionGroupRequest,
type MyPermissionsDto,
type PermissionCatalogDto,
type PermissionGroupDto,
type PermissionKey,
type PermissionMemberDto,
type UpdatePermissionGroupRequest,
type UserRole,
type PromotionPlan,
} from '@classhelper/shared';
import { prisma } from '../../lib/db.js';
import { ApiError } from '../../lib/http.js';
import type { TokenPayload } from '../../lib/jwt.js';
import { ensureDefaultGroups } from '../../lib/permissions.js';
import {
parsePermissionScope,
defaultPromotionPlan,
type PermissionScope,
type PromotionPlan,
} from '@classhelper/shared';
import { assertPermission } from '../../lib/permissions.js';
assertAdmin,
invalidateAuthPrincipals,
loadAuthPrincipal,
principalForAssignments,
classIdsFor,
manageClassIdsFor,
actionScopesFor,
type AssignmentRow,
} from '../../lib/authz/index.js';
import { getSetting } from '../../lib/settings.js';
import {
ADMIN_UNREMOVABLE_PERMISSIONS,
ensureDefaultGroups,
loadEffectivePermissions,
} from '../../lib/permissions.js';
/**
* 权限组服务:组 / 键 / 成员三件事的全部读写都收在这里。
*
* 生效权限的计算**不在本文件**,而在 `lib/permissions.ts` 的 `loadEffectivePermissions`
* (鉴权中间件每请求调用一次);本文件只负责"编辑这些组",两者共用同一份 shared 常量。
*/
const GROUP_SELECT = {
id: true,
name: true,
description: true,
builtin: true,
defaultRole: true,
scope: true,
scopeValue: true,
createdAt: true,
updatedAt: true,
items: { select: { key: true } },
_count: { select: { users: true } },
} as const;
type GroupRow = {
id: string;
name: string;
description: string;
builtin: boolean;
defaultRole: string | null;
scope: string;
scopeValue: string;
createdAt: Date;
updatedAt: Date;
items: { key: string }[];
_count: { users: number };
};
const MEMBER_SELECT = {
id: true,
username: true,
name: true,
role: true,
permissionGroups: { select: { groupId: true } },
} as const;
/** 目录顺序(key → 序号):组的键与"我的权限"都按它排序,前端渲染才稳定 */
const KEY_ORDER = new Map<string, number>(PERMISSION_KEYS.map((key, index) => [key, index]));
function knownKey(key: string): key is PermissionKey {
return KEY_ORDER.has(key);
}
function byCatalogOrder(a: string, b: string): number {
return (KEY_ORDER.get(a) ?? Number.MAX_SAFE_INTEGER) - (KEY_ORDER.get(b) ?? Number.MAX_SAFE_INTEGER);
}
/** 去重 + 丢掉已下线的键 + 按目录顺序排序 */
function normalizeKeys(keys: readonly string[] | undefined): PermissionKey[] {
const unique = new Set<PermissionKey>();
for (const key of keys ?? []) if (knownKey(key)) unique.add(key);
return [...unique].sort(byCatalogOrder);
}
function toGroupDto(row: GroupRow): PermissionGroupDto {
return {
id: row.id,
scope: row.scope as PermissionGroupDto['scope'],
scopeValue: row.scopeValue,
scopeLabel: scopeLabel(row),
name: row.name,
description: row.description,
builtin: row.builtin,
defaultRole: row.defaultRole as UserRole | null,
keys: row.items
.map((item) => item.key)
.filter(knownKey)
.sort(byCatalogOrder),
memberCount: row._count.users,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
function scopeLabel(group: { scope: string; scopeValue: string }) {
const include = { items: true, _count: { select: { users: true } } } as const;
type Group = Awaited<ReturnType<typeof prisma.permissionGroup.findMany<{ include: typeof include }>>>[number];
const known = (key: string): key is PermissionKey => (PERMISSION_KEYS as readonly string[]).includes(key);
const keysFor = (group: { items: { key: string }[] }) => group.items.map((i) => i.key).filter(known);
const activeKeys = (group: { items: { key: string }[] }) =>
keysFor(group).filter((key) => PERMISSION_META[key].kind !== 'admin');
function label(scope: string, scopeValue: string) {
try {
return parsePermissionScope(group.scope as PermissionScope, group.scopeValue).scopeLabel;
return parsePermissionScope(scope as PermissionScope, scopeValue).scopeLabel;
} catch {
return '未配置范围(无班级访问权)';
}
}
export async function scopeOptions() {
const classes = await prisma.class.findMany({ distinct: ['grade'], select: { grade: true } });
const raw = await getSetting('term.promotionPlan');
const plan: PromotionPlan = raw ? JSON.parse(raw) : defaultPromotionPlan();
const planGrades = plan.tracks.flatMap((track) => track.stages.map((stage) => stage.grade));
function dto(group: Group): PermissionGroupDto {
const inactiveKeys = keysFor(group).filter((k) => PERMISSION_META[k].kind === 'admin');
return {
grades: [...new Set([...classes.map((cls) => cls.grade), ...planGrades])].sort(),
planGrades,
divisions: plan.tracks.map((track) => ({
name: track.name,
grades: track.stages.map((stage) => stage.grade),
})),
...group,
keys: activeKeys(group),
scope: group.scope as PermissionScope,
scopeLabel: label(group.scope, group.scopeValue),
defaultScope: group.scope as PermissionScope,
defaultScopeValue: group.scopeValue,
defaultScopeLabel: label(group.scope, group.scopeValue),
defaultRole: group.defaultRole === 'TEACHER' ? 'TEACHER' : null,
kind: group.defaultRole ? 'default' : group.builtin ? 'builtin' : 'custom',
inactiveKeys,
warnings: inactiveKeys.length ? ['旧管理员专用键已停用,仅管理员固定拥有'] : [],
memberCount: group._count.users,
createdAt: group.createdAt.toISOString(),
updatedAt: group.updatedAt.toISOString(),
};
}
async function validateScope(input: { scope?: PermissionScope; scopeValue?: string }) {
try {
return parsePermissionScope(
input.scope ?? 'SELF_CLASS',
input.scopeValue ?? '',
(await scopeOptions()).grades,
);
} catch (error) {
throw ApiError.badRequest(String(error instanceof Error ? error.message : error));
}
}
async function assertGrant(
actor: TokenPayload,
group: { scope: string; defaultRole?: string | null; keys: readonly string[] },
) {
await assertPermission(actor, 'permission:manage');
if (actor.role === 'ADMIN') return;
if (group.defaultRole) throw ApiError.forbidden('只有管理员可以管理角色默认组');
if (group.scope !== 'SELF_CLASS') throw ApiError.forbidden('只有管理员可以管理年级、部或全校范围组');
const own = await loadEffectivePermissions(actor);
if (group.keys.some((key) => !own.has(key as PermissionKey)))
throw ApiError.forbidden('不能授予或管理超出自己生效权限的组');
}
function auditData(
export function auditData(
actor: TokenPayload,
action: string,
targetType: string,
@@ -177,17 +84,34 @@ function auditData(
afterJson: JSON.stringify(after),
};
}
export async function createGroup(
actor: TokenPayload,
input: CreatePermissionGroupRequest,
): Promise<PermissionGroupDto> {
const scope = await validateScope(input);
const keys = normalizeKeys(input.keys);
await assertGrant(actor, { ...scope, keys });
if (await prisma.permissionGroup.findUnique({ where: { name: input.name.trim() } }))
throw ApiError.conflict('权限组名称已存在');
const created = await prisma.$transaction(async (tx) => {
export async function scopeOptions() {
const classes = await prisma.class.findMany({ distinct: ['grade'], select: { grade: true } });
const raw = await getSetting('term.promotionPlan');
const plan: PromotionPlan = raw ? JSON.parse(raw) : defaultPromotionPlan();
const planGrades = plan.tracks.flatMap((t) => t.stages.map((s) => s.grade));
return {
grades: [...new Set([...classes.map((c) => c.grade), ...planGrades])].sort(),
planGrades,
divisions: plan.tracks.map((t) => ({ name: t.name, grades: t.stages.map((s) => s.grade) })),
};
}
async function validateScope(scope: PermissionScope = 'TEACHING', scopeValue = '') {
try {
return parsePermissionScope(scope, scopeValue, (await scopeOptions()).grades);
} catch (error) {
throw ApiError.badRequest(error instanceof Error ? error.message : String(error));
}
}
function validateKeys(keys: readonly string[]) {
if (keys.some((k) => !known(k) || PERMISSION_META[k].kind === 'admin'))
throw ApiError.badRequest('教师模板不能包含管理员专用键或未知键');
return [...new Set(keys)] as PermissionKey[];
}
export async function createGroup(actor: TokenPayload, input: CreatePermissionGroupRequest) {
assertAdmin(actor);
const scope = await validateScope(input.scope, input.scopeValue);
const keys = validateKeys(input.keys ?? []);
return prisma.$transaction(async (tx) => {
const row = await tx.permissionGroup.create({
data: {
name: input.name.trim(),
@@ -196,35 +120,34 @@ export async function createGroup(
scopeValue: scope.scopeValue,
items: { create: keys.map((key) => ({ key })) },
},
select: GROUP_SELECT,
include,
});
await tx.permissionAudit.create({
data: auditData(actor, 'create', 'group', row.id, null, toGroupDto(row)),
});
return row;
await tx.permissionAudit.create({ data: auditData(actor, 'create', 'group', row.id, null, dto(row)) });
invalidateAuthPrincipals();
return dto(row);
});
return toGroupDto(created);
}
export async function updateGroup(
actor: TokenPayload,
groupId: string,
input: UpdatePermissionGroupRequest,
): Promise<PermissionGroupDto> {
const current = await prisma.permissionGroup.findUnique({ where: { id: groupId }, select: GROUP_SELECT });
if (!current) throw ApiError.notFound('权限组不存在');
const before = toGroupDto(current);
await assertGrant(actor, { ...current, keys: before.keys });
const scope = await validateScope({
scope: input.scope ?? before.scope,
scopeValue: input.scopeValue ?? before.scopeValue,
});
const keys = normalizeKeys(input.keys ?? before.keys);
await assertGrant(actor, { ...scope, defaultRole: current.defaultRole, keys });
const updated = await prisma.$transaction(async (tx) => {
await tx.permissionGroupItem.deleteMany({ where: { groupId } });
export async function updateGroup(actor: TokenPayload, id: string, input: UpdatePermissionGroupRequest) {
assertAdmin(actor);
const current = await prisma.permissionGroup.findUnique({ where: { id }, include });
if (!current || current.defaultRole === 'ADMIN') throw ApiError.notFound('模板不存在');
const scope = await validateScope(
input.scope ?? (current.scope as PermissionScope),
input.scopeValue ?? current.scopeValue,
);
if (current.defaultRole === 'TEACHER' && scope.scope !== 'TEACHING')
throw ApiError.badRequest('教师默认模板固定使用任教班级范围');
// 旧管理员键仅停用;编辑可分配能力时保留它们,不能通过写入口重新授予。
const inactiveKeys = keysFor(current).filter((key) => PERMISSION_META[key].kind === 'admin');
const keys =
input.keys === undefined
? current.items.map((i) => i.key)
: [...new Set([...validateKeys(input.keys), ...inactiveKeys])];
let affected: string[] = [];
const result = await prisma.$transaction(async (tx) => {
await tx.permissionGroupItem.deleteMany({ where: { groupId: id } });
const row = await tx.permissionGroup.update({
where: { id: groupId },
where: { id },
data: {
name: input.name?.trim(),
description: input.description?.trim(),
@@ -232,120 +155,204 @@ export async function updateGroup(
scopeValue: scope.scopeValue,
items: { create: keys.map((key) => ({ key })) },
},
select: GROUP_SELECT,
include,
});
await tx.permissionAudit.create({
data: auditData(actor, 'update', 'group', id, dto(current), dto(row)),
});
const members =
current.defaultRole === 'TEACHER'
? await tx.user.findMany({ where: { role: 'TEACHER' }, select: { id: true } })
: await tx.userPermissionGroup
.findMany({ where: { groupId: id }, select: { userId: true } })
.then((rows) => rows.map((r) => ({ id: r.userId })));
affected = members.map((u) => u.id);
return dto(row);
});
invalidateAuthPrincipals();
disconnectUserSessions(affected);
return result;
}
export async function deleteGroup(actor: TokenPayload, id: string) {
assertAdmin(actor);
const group = await prisma.permissionGroup.findUnique({ where: { id }, include });
if (!group) throw ApiError.notFound('模板不存在');
if (group.builtin || group.defaultRole) throw ApiError.badRequest('内置或默认模板不可删除');
const members = await prisma.userPermissionGroup.findMany({
where: { groupId: id },
select: { userId: true },
});
await prisma.$transaction(async (tx) => {
await tx.permissionGroup.delete({ where: { id } });
await tx.permissionAudit.create({ data: auditData(actor, 'delete', 'group', id, dto(group), null) });
});
invalidateAuthPrincipals();
disconnectUserSessions(members.map((m) => m.userId));
return { id };
}
export async function listMembers(actor: TokenPayload): Promise<PermissionMemberDto[]> {
assertAdmin(actor);
await ensureDefaultGroups();
const defaultGroup = await prisma.permissionGroup.findUniqueOrThrow({
where: { defaultRole: 'TEACHER' },
include: { items: true },
});
const users = await prisma.user.findMany({
where: { role: { in: ['ADMIN', 'TEACHER'] } },
include: { permissionGroups: { include: { group: { include: { items: true } } } } },
orderBy: { name: 'asc' },
});
return Promise.all(
users.map(async (u) => {
const user: TokenPayload = {
sub: u.id,
role: u.role as TokenPayload['role'],
username: u.username,
name: u.name,
classId: null,
};
const auth = await principalForAssignments(user, u.permissionGroups, defaultGroup);
const assignments =
u.role === 'ADMIN'
? []
: u.permissionGroups.map((a) => ({
groupId: a.groupId,
groupName: a.group.name,
scope: a.scope as PermissionScope,
scopeValue: a.scopeValue,
scopeLabel: label(a.scope, a.scopeValue),
}));
return {
id: u.id,
username: u.username,
name: u.name,
role: user.role,
fixed: u.role === 'ADMIN',
assignments,
groupIds: [...new Set(assignments.map((a) => a.groupId))],
permissions: [...auth.permissions],
permissionCount: auth.permissions.size,
scopeLabels: assignments.length
? [...new Set(assignments.map((a) => a.scopeLabel))]
: [u.role === 'ADMIN' ? '固定全部权限' : '默认模板 · 任教班级'],
};
}),
);
}
export async function groupMembers(actor: TokenPayload, id: string) {
assertAdmin(actor);
const group = await prisma.permissionGroup.findUnique({ where: { id } });
if (!group || group.defaultRole === 'ADMIN') throw ApiError.notFound('模板不存在');
return (await listMembers(actor)).filter(
(u) =>
!u.fixed && (u.groupIds.includes(id) || (group.defaultRole === 'TEACHER' && !u.assignments.length)),
);
}
async function assignmentRows(input: AssignUserGroupsRequest): Promise<AssignmentRow[]> {
const ids = input.assignments?.map((a) => a.groupId) ?? input.groupIds ?? [];
const groups = await prisma.permissionGroup.findMany({
where: { id: { in: ids } },
include: { items: true },
});
if (new Set(ids).size !== groups.length || groups.some((g) => g.defaultRole === 'ADMIN'))
throw ApiError.badRequest('模板不存在或已停用');
const assignments =
input.assignments ??
ids.map((groupId) => {
const g = groups.find((g) => g.id === groupId)!;
return { groupId, scope: g.scope as PermissionScope, scopeValue: g.scopeValue };
});
const rows: AssignmentRow[] = [];
for (const assignment of assignments) {
const group = groups.find((g) => g.id === assignment.groupId)!;
const scope = await validateScope(assignment.scope, assignment.scopeValue);
if (group.defaultRole === 'TEACHER' && scope.scope !== 'TEACHING')
throw ApiError.badRequest('教师默认模板只允许任教班级范围');
if (
activeKeys(group).some(
(k) => PERMISSION_META[k].kind === 'class' && !PERMISSION_META[k].allowedScopes.includes(scope.scope),
)
)
throw ApiError.badRequest('分配范围不适用于模板能力');
rows.push({ groupId: group.id, scope: scope.scope, scopeValue: scope.scopeValue, group });
}
return [...new Map(rows.map((a) => [`${a.groupId}:${a.scope}:${a.scopeValue}`, a])).values()];
}
export async function effectivePermissions(
actor: TokenPayload,
id: string,
preview?: AssignUserGroupsRequest,
): Promise<EffectivePermissionsDto> {
assertAdmin(actor);
await ensureDefaultGroups();
const u = await prisma.user.findUnique({
where: { id },
include: { permissionGroups: { include: { group: { include: { items: true } } } } },
});
if (!u) throw ApiError.notFound('账号不存在');
const user: TokenPayload = {
sub: id,
name: u.name,
username: u.username,
role: u.role as TokenPayload['role'],
classId: null,
};
if (preview && u.role === 'ADMIN') throw ApiError.badRequest('管理员固定全部权限,不接受分配');
const auth = await principalForAssignments(
user,
preview ? await assignmentRows(preview) : u.permissionGroups,
);
const classes = await prisma.class.findMany({ select: { id: true, name: true } });
const entries = await Promise.all(
[...auth.permissions].map(async (key) => {
const ids = await classIdsFor(user, key, auth);
return {
key,
scopes: [...new Set((auth.grants.get(key) ?? []).map((g) => label(g.scope, g.scopeValue)))],
classes: classes.filter((c) => ids.includes(c.id)),
classCount: ids.length,
};
}),
);
return {
permissions: [...auth.permissions],
entries,
conflicts: entries
.filter((e) => e.scopes.length > 1)
.map((e) => `${PERMISSION_LABELS[e.key]}:实际合并范围 ${e.scopes.join(' ∪ ')};较窄分配可能被覆盖`),
headOnlyKeys: [...auth.permissions].filter((k) => PERMISSION_META[k].relation === 'head'),
};
}
export async function assignUserGroups(actor: TokenPayload, userId: string, input: AssignUserGroupsRequest) {
assertAdmin(actor);
const u = await prisma.user.findUnique({ where: { id: userId } });
if (!u) throw ApiError.notFound('账号不存在');
if (u.role !== 'TEACHER') throw ApiError.badRequest('管理员固定全部权限,不能分配模板');
const rows = await assignmentRows(input);
const before = (await listMembers(actor)).find((m) => m.id === userId)!;
await prisma.$transaction(async (tx) => {
await tx.userPermissionGroup.deleteMany({ where: { userId } });
if (rows.length)
await tx.userPermissionGroup.createMany({
data: rows.map(({ groupId, scope, scopeValue }) => ({ userId, groupId, scope, scopeValue })),
});
await tx.permissionAudit.create({
data: auditData(
actor,
current.defaultRole ? 'update-default' : 'update',
'group',
groupId,
before,
toGroupDto(row),
'assign',
'user',
userId,
before.assignments,
rows.map(({ groupId, scope, scopeValue }) => ({ groupId, scope, scopeValue })),
),
});
return row;
});
return toGroupDto(updated);
invalidateAuthPrincipals();
disconnectUserSessions([userId]);
return (await listMembers(actor)).find((m) => m.id === userId)!;
}
export async function deleteGroup(actor: TokenPayload, groupId: string) {
const current = await prisma.permissionGroup.findUnique({ where: { id: groupId }, select: GROUP_SELECT });
if (!current) throw ApiError.notFound('权限组不存在');
await assertGrant(actor, { ...current, keys: current.items.map((item) => item.key) });
if (current.builtin) throw ApiError.badRequest('内置权限组不可删除');
// 删除最后一组会回落默认组;非管理员不能借删除制造额外授权。
if (actor.role !== 'ADMIN') throw ApiError.forbidden('删除组会改变成员默认权限,仅管理员可执行');
await prisma.$transaction(async (tx) => {
await tx.permissionGroup.delete({ where: { id: groupId } });
await tx.permissionAudit.create({
data: auditData(actor, 'delete', 'group', groupId, toGroupDto(current), null),
});
});
return { id: groupId };
}
/** 组和成员各预取一次,账号数增长不会增加权限查询次数。 */
export async function listMembers(): Promise<PermissionMemberDto[]> {
await ensureDefaultGroups();
const [rows, groups] = await Promise.all([
prisma.user.findMany({
where: { role: { in: ['ADMIN', 'TEACHER'] } },
select: MEMBER_SELECT,
orderBy: [{ role: 'asc' }, { name: 'asc' }],
}),
prisma.permissionGroup.findMany({ include: { items: true } }),
]);
return rows.map((row) => {
const groupIds = row.permissionGroups.map((link) => link.groupId);
const effective = groups.filter((group) =>
groupIds.length ? groupIds.includes(group.id) : group.defaultRole === row.role,
);
const permissions = normalizeKeys([
...effective.flatMap((group) => group.items.map((item) => item.key)),
...(row.role === 'ADMIN' ? ADMIN_UNREMOVABLE_PERMISSIONS : []),
]);
return {
id: row.id,
username: row.username,
name: row.name,
role: row.role as UserRole,
groupIds,
permissions,
permissionCount: permissions.length,
scopeLabels: [...new Set(effective.map(scopeLabel))],
};
});
}
export async function groupMembers(groupId: string) {
const group = await prisma.permissionGroup.findUnique({ where: { id: groupId } });
if (!group) throw ApiError.notFound('权限组不存在');
return (await listMembers()).filter((member) =>
member.groupIds.length ? member.groupIds.includes(groupId) : member.role === group.defaultRole,
);
}
export async function assignUserGroups(
actor: TokenPayload,
userId: string,
input: AssignUserGroupsRequest,
): Promise<PermissionMemberDto> {
await assertPermission(actor, 'permission:manage');
const user = await prisma.user.findUnique({ where: { id: userId }, select: MEMBER_SELECT });
if (!user || !['ADMIN', 'TEACHER'].includes(user.role)) throw ApiError.notFound('员工账号不存在');
const ids = [...new Set(input.groupIds)];
const groups = await prisma.permissionGroup.findMany({
where: ids.length ? { id: { in: ids } } : { defaultRole: user.role },
include: { items: true },
});
if (ids.length && groups.length !== ids.length) throw ApiError.badRequest('部分权限组不存在');
for (const group of groups) {
if (group.defaultRole && group.defaultRole !== user.role)
throw ApiError.forbidden('默认组只能分配给同角色账号');
// 同角色默认组可显式选中,但仍检查范围与键的授权能力。
await assertGrant(actor, { ...group, defaultRole: null, keys: group.items.map((item) => item.key) });
}
if (actor.role !== 'ADMIN' && user.role === 'ADMIN') throw ApiError.forbidden('非管理员不能更改管理员分组');
const before = (await listMembers()).find((member) => member.id === userId)!;
await prisma.$transaction(async (tx) => {
await tx.userPermissionGroup.deleteMany({ where: { userId } });
if (ids.length)
await tx.userPermissionGroup.createMany({ data: ids.map((groupId) => ({ userId, groupId })) });
await tx.permissionAudit.create({
data: auditData(actor, 'assign', 'user', userId, before, {
groupIds: ids,
permissions: normalizeKeys([
...groups.flatMap((group) => group.items.map((item) => item.key)),
...(user.role === 'ADMIN' ? ADMIN_UNREMOVABLE_PERMISSIONS : []),
]),
}),
});
});
return (await listMembers()).find((member) => member.id === userId)!;
}
export async function listAudits(page = 1, pageSize = 30) {
export async function listAudits(actor: TokenPayload, page = 1, pageSize = 30) {
assertAdmin(actor);
const [items, total] = await Promise.all([
prisma.permissionAudit.findMany({
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
@@ -356,71 +363,68 @@ export async function listAudits(page = 1, pageSize = 30) {
]);
return { items, total, page, pageSize };
}
/** GET /api/permissions/catalog - 权限键目录(权限页整棵勾选树都按它渲染) */
export async function permissionCatalog(): Promise<PermissionCatalogDto> {
export async function permissionCatalog(actor: TokenPayload): Promise<PermissionCatalogDto> {
assertAdmin(actor);
await ensureDefaultGroups();
const defaults = await prisma.permissionGroup.findMany({
where: { defaultRole: { not: null } },
const group = await prisma.permissionGroup.findUniqueOrThrow({
where: { defaultRole: 'TEACHER' },
include: { items: true },
});
const roles = Object.keys(ROLE_DEFAULT_PERMISSIONS) as UserRole[];
const keysForRole = (role: UserRole): PermissionKey[] => {
const keys =
defaults.find((group) => group.defaultRole === role)?.items.map((item) => item.key) ??
ROLE_DEFAULT_PERMISSIONS[role];
return normalizeKeys(role === 'ADMIN' ? [...keys, ...ADMIN_UNREMOVABLE_PERMISSIONS] : keys);
};
return {
keys: [...PERMISSION_KEYS],
labels: { ...PERMISSION_LABELS },
categories: PERMISSION_CATEGORIES.map((category) => ({
title: category.title,
keys: [...category.keys],
})),
meta: PERMISSION_META,
labels: PERMISSION_LABELS,
categories: PERMISSION_CATEGORIES.map((c) => ({ title: c.title, keys: [...c.keys] })),
defaultRoles: Object.fromEntries(
PERMISSION_KEYS.map((key) => [key, roles.filter((role) => keysForRole(role).includes(key))]),
) as Record<PermissionKey, UserRole[]>,
roleDefaults: Object.fromEntries(roles.map((role) => [role, keysForRole(role)])) as Record<
UserRole,
PermissionKey[]
>,
unremovable: [...ADMIN_UNREMOVABLE_PERMISSIONS],
PERMISSION_KEYS.map((k) => [k, [...PERMISSION_META[k].defaultRoles]]),
) as PermissionCatalogDto['defaultRoles'],
roleDefaults: {
...ROLE_DEFAULT_PERMISSIONS,
ADMIN: [...PERMISSION_KEYS],
TEACHER: activeKeys(group),
CLASS_DEVICE: [],
},
unremovable: [...PERMISSION_KEYS],
};
}
/** GET /api/permissions/groups - 权限组列表(内置组排在最前,其余按创建时间) */
export async function listGroups(): Promise<PermissionGroupDto[]> {
export async function listGroups(actor: TokenPayload): Promise<PermissionGroupDto[]> {
assertAdmin(actor);
await ensureDefaultGroups();
const rows = await prisma.permissionGroup.findMany({
select: GROUP_SELECT,
orderBy: [{ builtin: 'desc' }, { createdAt: 'asc' }],
});
const members = await listMembers();
return (rows as GroupRow[]).map((row) => ({
...toGroupDto(row),
memberCount: members.filter((m) =>
m.groupIds.length ? m.groupIds.includes(row.id) : row.defaultRole === m.role,
const members = await listMembers(actor);
return (
await prisma.permissionGroup.findMany({
where: { OR: [{ defaultRole: null }, { defaultRole: 'TEACHER' }] },
include,
orderBy: [{ builtin: 'desc' }, { createdAt: 'asc' }],
})
).map((group) => ({
...dto(group),
memberCount: members.filter(
(m) =>
!m.fixed &&
(m.groupIds.includes(group.id) || (group.defaultRole === 'TEACHER' && !m.assignments.length)),
).length,
}));
}
/**
* GET /api/permissions/me - 当前账号的生效权限。
*
* 两端按它决定"入口显不显示"(真正的拦截始终在服务端)。
* 班级端会话恒为空集:它的能力由班级维度断言决定。
*/
export async function myPermissions(user: TokenPayload): Promise<MyPermissionsDto> {
const permissions = await loadEffectivePermissions(user);
const fromGroups =
user.classSession || user.role === 'CLASS_DEVICE'
? false
: (await prisma.userPermissionGroup.count({ where: { userId: user.sub } })) > 0;
export async function myPermissions(user: TokenPayload) {
const auth = await loadAuthPrincipal(user);
const classKeys = [...auth.permissions].filter((key) => PERMISSION_META[key].kind === 'class');
const managementKeys = classKeys.filter((key) =>
['roster:manage', 'schedule:write', 'import:students', 'import:schedule', 'grade:write'].includes(key),
);
const [scopes, management] = await Promise.all([
Promise.all(classKeys.map(async (key) => [key, await classIdsFor(user, key, auth)] as const)),
Promise.all(managementKeys.map(async (key) => [key, await manageClassIdsFor(user, key, auth)] as const)),
]);
return {
role: user.role,
permissions: [...permissions].sort(byCatalogOrder),
fromGroups,
permissions: [...auth.permissions],
fromGroups: auth.fromGroups,
classScopes: Object.fromEntries(scopes),
manageScopes: Object.fromEntries(management),
actionScopes: await actionScopesFor(user, scopes, management),
allScopeKeys: [...auth.grants]
.filter(([, grants]) => grants.some((g) => g.scope === 'ALL'))
.map(([key]) => key),
};
}
@@ -40,8 +40,8 @@ export async function listSchedules(
user: TokenPayload,
options: ListScheduleOptions,
): Promise<ScheduleDto[]> {
if (options.classId) await assertClassAccess(user, options.classId);
const classIds = options.classId ? [options.classId] : await getAccessibleClassIds(user);
if (options.classId) await assertClassAccess(user, options.classId, 'schedule:read');
const classIds = options.classId ? [options.classId] : await getAccessibleClassIds(user, 'schedule:read');
const result: ScheduleDto[] = [];
for (const classId of classIds) {
if (options.week) {
@@ -98,7 +98,7 @@ export async function getActiveScheduleWeek(
) {
const classId = isClassDevice(user) ? requireClassDeviceClassId(user) : options.classId;
if (!classId) throw ApiError.badRequest('请指定班级');
await assertClassAccess(user, classId);
await assertClassAccess(user, classId, 'schedule:read');
const context = await loadTermContext(classId),
week = options.week ?? resolveCurrentWeek(new Date(), context);
if (week > context.maxWeek) throw ApiError.badRequest('周次超过本班教学周数');
@@ -124,7 +124,7 @@ export async function computeClassStatus(classId: string, at = new Date()): Prom
export async function getClassStatus(user: TokenPayload, options: { classId?: string; at?: Date } = {}) {
const classId = isClassDevice(user) ? requireClassDeviceClassId(user) : options.classId;
if (!classId) throw ApiError.badRequest('请指定班级');
await assertClassAccess(user, classId);
await assertClassAccess(user, classId, 'schedule:read');
return computeClassStatus(classId, options.at);
}
@@ -151,8 +151,8 @@ function matches(p: PlanRow, day: number, week: number) {
(p.weekCountDiv === 0 || ((week - 1) % p.weekCountDivTotal) + 1 === p.weekCountDiv)
);
}
async function assertCourse(courseId: string, classId: string) {
if (!(await prisma.course.findFirst({ where: { id: courseId, classId } })))
async function assertCourse(courseId: string, classId: string, db: Tx = prisma) {
if (!(await db.course.findFirst({ where: { id: courseId, classId } })))
throw ApiError.badRequest('课程不属于本班');
}
export async function createSchedule(
@@ -161,10 +161,8 @@ export async function createSchedule(
transaction?: Tx,
identity?: string,
): Promise<ScheduleDto> {
if (!transaction) {
await assertCanManageSchedule(user, input.classId);
await assertCourse(input.courseId, input.classId);
}
await assertCanManageSchedule(user, input.classId, transaction);
await assertCourse(input.courseId, input.classId, transaction);
const cls = await (transaction ?? prisma).class.findUniqueOrThrow({ where: { id: input.classId } }),
max = transaction
? normalizeMaxWeeks(
@@ -282,6 +280,8 @@ export async function updateSchedule(
const rule: Partial<{ weekStart: number; weekEnd: number; weekParity: 'ALL' | 'ODD' | 'EVEN' }> = stored
? JSON.parse(stored.value)
: {};
for (const targetClassId of new Set(entries.map((e) => e.plan.classId)))
await assertCanManageSchedule(user, targetClassId);
const classId = entries[0]!.plan.classId;
await assertCanManageSchedule(user, classId);
if (input.courseId) await assertCourse(input.courseId, classId);
@@ -10,7 +10,7 @@ import { idParamSchema } from '../../lib/schemas.js';
import {
authenticate,
getAuthUser,
requireStaffPermission,
requirePermissionOrClassDevice,
requirePermission,
} from '../../middleware/auth.js';
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
@@ -30,18 +30,18 @@ const router = Router();
router.use(authenticate());
router.get(
'/active-week',
requireStaffPermission('schedule:read'),
requirePermissionOrClassDevice('schedule:read'),
validate({ query: gridQuerySchema }),
async (req, res) =>
sendOk(res, await scheduleService.getActiveScheduleWeek(getAuthUser(req), validatedQuery(req))),
);
router.get(
'/scheduled',
requireStaffPermission('schedule:read'),
requirePermissionOrClassDevice('schedule:read'),
validate({ query: z.object({ classId: z.string().min(1) }) }),
async (req, res) => {
const { classId } = validatedQuery<{ classId: string }>(req);
await assertClassAccess(getAuthUser(req), classId);
await assertClassAccess(getAuthUser(req), classId, 'schedule:read');
sendOk(
res,
await prisma.scheduledClassPlan.findMany({
@@ -109,7 +109,7 @@ router.post(
/** GET /api/schedules?classId=&week=&dayOfWeek= - 课表列表 */
router.get(
'/',
requireStaffPermission('schedule:read'),
requirePermissionOrClassDevice('schedule:read'),
validate({ query: listSchedulesQuerySchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -121,7 +121,7 @@ router.get(
/** GET /api/schedules/grid?classId=&week= - 周视图(7 列结构) */
router.get(
'/grid',
requireStaffPermission('schedule:read'),
requirePermissionOrClassDevice('schedule:read'),
validate({ query: gridQuerySchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -145,7 +145,7 @@ router.get(
*/
router.get(
'/current',
requireStaffPermission('schedule:read'),
requirePermissionOrClassDevice('schedule:read'),
validate({ query: classStatusQuerySchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -8,11 +8,11 @@ import {
getAccessibleClassIds,
resolveClassScope,
} from '../../lib/access.js';
import { assertStaffPermission, assertPermission } from '../../lib/permissions.js';
import { assertPermissionOrClassDevice, assertPermission } from '../../lib/permissions.js';
import { changeStudentLifecycle, notifyStudentClasses } from '../../lib/student-lifecycle.js';
import { activeStudentWhere } from '../../lib/students.js';
import { prisma } from '../../lib/db.js';
import { hasAllScope } from '../../lib/permission-scope.js';
import { hasOrganizationalGrant } from '../../lib/authz/index.js';
import { ApiError } from '../../lib/http.js';
import type { TokenPayload } from '../../lib/jwt.js';
import { toStudentDto, toStudentTransferDto } from '../../lib/mappers.js';
@@ -38,7 +38,7 @@ const withClass = { class: { select: { name: true, grade: true } } } as const;
/** 学生名单(按班级权限过滤) */
export async function listStudents(user: TokenPayload, options: ListStudentsQuery): Promise<StudentDto[]> {
await assertStaffPermission(user, 'roster:read');
await assertPermissionOrClassDevice(user, 'roster:read');
const scope = await resolveClassScope(user, options.classId, 'roster:read');
const students = await prisma.student.findMany({
@@ -219,16 +219,16 @@ export async function listTransfers(
options: ListTransfersQuery,
): Promise<PaginatedResult<StudentTransferDto>> {
await assertPermission(user, 'roster:read');
const hasGlobalRosterScope = isAdmin(user) || (await hasAllScope(user, 'roster:read'));
const hasGlobalRosterScope = isAdmin(user) || (await hasOrganizationalGrant(user, 'roster:read'));
if (options.classId) await assertClassAccess(user, options.classId);
if (options.classId) await assertClassAccess(user, options.classId, 'roster:read');
if (options.studentId) {
const student = await prisma.student.findUnique({
where: { id: options.studentId },
select: { classId: true },
});
if (!student) throw ApiError.notFound('学生不存在');
if (student.classId) await assertClassAccess(user, student.classId);
if (student.classId) await assertClassAccess(user, student.classId, 'roster:read');
else if (!hasGlobalRosterScope) throw ApiError.forbidden('无权查看未分班学生的调班历史');
}
@@ -1,3 +1,4 @@
import { requireAdmin } from '../../middleware/auth.js';
import { Router } from 'express';
import { API_PATHS } from '@classhelper/shared';
import { sendOk } from '../../lib/http.js';
@@ -13,7 +14,7 @@ import {
import * as systemService from './system.service.js';
const router = Router();
router.use(authenticate());
router.use(authenticate(), requireAdmin());
/**
* 系统设置与运维接口(**全部仅 ADMIN**,权限键 `system:manage`)。
@@ -46,7 +47,7 @@ router.put(
validate({ body: updateAutostartSchema }),
async (req, res) => {
const input = validatedBody<UpdateAutostartInput>(req);
sendOk(res, await systemService.updateAutostart(input), '开机自启设置已生效');
sendOk(res, await systemService.updateAutostart(getAuthUser(req), input), '开机自启设置已生效');
},
);
@@ -1,3 +1,5 @@
import { assertAdmin } from '../../lib/authz/index.js';
import type { TokenPayload } from '../../lib/jwt.js';
import { createAutostartController } from './autostart.js';
import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
@@ -10,7 +12,6 @@ import {
} from '@classhelper/shared';
import { env } from '../../config/env.js';
import { prisma } from '../../lib/db.js';
import type { TokenPayload } from '../../lib/jwt.js';
import { logger } from '../../lib/logger.js';
import { SETTING_KEYS, getBooleanSetting, setBooleanSetting } from '../../lib/settings.js';
import { APP_VERSION } from '../../lib/version.js';
@@ -39,6 +40,7 @@ export async function updateSystemSettings(
user: TokenPayload,
input: UpdateSystemSettingsInput,
): Promise<SystemSettingsDto> {
assertAdmin(user);
if (input.developerMode !== undefined) {
await setBooleanSetting(SETTING_KEYS.developerMode, input.developerMode);
logger.info(`开发者模式已${input.developerMode ? '开启' : '关闭'}(操作人=${user.username})`);
@@ -55,7 +57,11 @@ const autostart = createAutostartController({
export async function getAutostartStatus(): Promise<AutostartStatusDto> {
return autostart.read();
}
export async function updateAutostart(input: UpdateAutostartInput): Promise<AutostartStatusDto> {
export async function updateAutostart(
actor: TokenPayload,
input: UpdateAutostartInput,
): Promise<AutostartStatusDto> {
assertAdmin(actor);
return autostart.write(input.enabled);
}
@@ -1,7 +1,13 @@
import { Router } from 'express';
import { sendCreated, sendOk } from '../../lib/http.js';
import { idParamSchema } from '../../lib/schemas.js';
import { authenticate, requirePermission, requireAnyPermission } from '../../middleware/auth.js';
import {
authenticate,
getAuthUser,
requireAdmin,
requirePermission,
requireAnyPermission,
} from '../../middleware/auth.js';
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import {
@@ -27,39 +33,50 @@ router.get(
validate({ query: listTeachersQuerySchema }),
async (req, res) => {
const { keyword } = validatedQuery<{ keyword?: string }>(req);
sendOk(res, await teacherService.listTeachers(keyword), '获取教师列表成功');
sendOk(res, await teacherService.listTeachers(getAuthUser(req), keyword), '获取教师列表成功');
},
);
/** POST /api/teachers - 录入教师账号(需 teacher:manage) */
router.post(
'/',
requireAdmin(),
requirePermission('teacher:manage'),
validate({ body: createTeacherSchema }),
async (req, res) => {
sendCreated(res, await teacherService.createTeacher(req.body as CreateTeacherInput), '教师账号创建成功');
sendCreated(
res,
await teacherService.createTeacher(getAuthUser(req), req.body as CreateTeacherInput),
'教师账号创建成功',
);
},
);
/** PATCH /api/teachers/:id - 编辑教师(姓名 / 用户名 / 角色) */
router.patch(
'/:id',
requireAdmin(),
requirePermission('teacher:manage'),
validate({ params: idParamSchema, body: updateTeacherSchema }),
async (req, res) => {
const { id } = validatedParams<{ id: string }>(req);
sendOk(res, await teacherService.updateTeacher(id, req.body as UpdateTeacherInput), '教师信息更新成功');
sendOk(
res,
await teacherService.updateTeacher(getAuthUser(req), id, req.body as UpdateTeacherInput),
'教师信息更新成功',
);
},
);
/** POST /api/teachers/:id/reset-password - 重置教师密码 */
router.post(
'/:id/reset-password',
requireAdmin(),
requirePermission('teacher:manage'),
validate({ params: idParamSchema, body: resetTeacherPasswordSchema }),
async (req, res) => {
const { id } = validatedParams<{ id: string }>(req);
await teacherService.resetTeacherPassword(id, req.body as ResetTeacherPasswordInput);
await teacherService.resetTeacherPassword(getAuthUser(req), id, req.body as ResetTeacherPasswordInput);
sendOk(res, { id }, '密码已重置');
},
);
@@ -67,11 +84,12 @@ router.post(
/** DELETE /api/teachers/:id - 删除教师账号(有班级职责/课程时拒绝,见 service) */
router.delete(
'/:id',
requireAdmin(),
requirePermission('teacher:manage'),
validate({ params: idParamSchema }),
async (req, res) => {
const { id } = validatedParams<{ id: string }>(req);
await teacherService.deleteTeacher(id);
await teacherService.deleteTeacher(getAuthUser(req), id);
sendOk(res, { id }, '教师账号已删除');
},
);
@@ -1,3 +1,8 @@
import type { Prisma } from '../../generated/prisma/client.js';
import { disconnectUserSessions } from '../../realtime/bus.js';
import { assertAdmin, assertPermission } from '../../lib/authz/index.js';
import type { TokenPayload } from '../../lib/jwt.js';
import { auditData } from '../permissions/permissions.service.js';
import type { UserDto } from '@classhelper/shared';
import { env } from '../../config/env.js';
import { prisma } from '../../lib/db.js';
@@ -17,7 +22,8 @@ import type {
*/
/** 教师列表(教师 + 管理员账号) */
export async function listTeachers(keyword?: string): Promise<UserDto[]> {
export async function listTeachers(actor: TokenPayload, keyword?: string): Promise<UserDto[]> {
await assertPermission(actor, 'teacher:read');
const teachers = await prisma.user.findMany({
where: {
role: { in: ['TEACHER', 'ADMIN'] },
@@ -29,7 +35,8 @@ export async function listTeachers(keyword?: string): Promise<UserDto[]> {
return teachers.map(toUserDto);
}
export async function createTeacher(input: CreateTeacherInput): Promise<UserDto> {
export async function createTeacher(actor: TokenPayload, input: CreateTeacherInput): Promise<UserDto> {
assertAdmin(actor);
const created = await prisma.user.create({
data: {
username: input.username,
@@ -38,6 +45,12 @@ export async function createTeacher(input: CreateTeacherInput): Promise<UserDto>
passwordHash: await hashPassword(input.password ?? env.defaultTeacherPassword),
},
});
await prisma.permissionAudit.create({
data: auditData(actor, 'teacher-create', 'user', created.id, null, {
role: created.role,
name: created.name,
}),
});
return toUserDto(created);
}
@@ -53,29 +66,50 @@ async function findTeacherOrThrow(teacherId: string): Promise<{ id: string; role
return teacher;
}
export async function updateTeacher(teacherId: string, input: UpdateTeacherInput): Promise<UserDto> {
export async function updateTeacher(
actor: TokenPayload,
teacherId: string,
input: UpdateTeacherInput,
): Promise<UserDto> {
assertAdmin(actor);
const teacher = await findTeacherOrThrow(teacherId);
const updated = await prisma.user.update({
where: { id: teacher.id },
data: {
...(input.name ? { name: input.name } : {}),
...(input.username ? { username: input.username } : {}),
...(input.role ? { role: input.role } : {}),
},
const updated = await prisma.$transaction(async (tx) => {
if (teacher.role === 'ADMIN' && input.role && input.role !== 'ADMIN') await protectLastAdmin(tx);
const result = await tx.user.update({
where: { id: teacher.id },
data: {
...(input.name ? { name: input.name } : {}),
...(input.username ? { username: input.username } : {}),
...(input.role ? { role: input.role } : {}),
},
});
await tx.permissionAudit.create({
data: auditData(actor, 'teacher-update', 'user', teacher.id, teacher, {
role: result.role,
name: result.name,
}),
});
return result;
});
disconnectUserSessions([teacher.id]);
return toUserDto(updated);
}
/** 重置教师密码(未指定时用默认初始密码) */
export async function resetTeacherPassword(
actor: TokenPayload,
teacherId: string,
input: ResetTeacherPasswordInput,
): Promise<void> {
assertAdmin(actor);
const teacher = await findTeacherOrThrow(teacherId);
await prisma.user.update({
where: { id: teacher.id },
data: { passwordHash: await hashPassword(input.newPassword ?? env.defaultTeacherPassword) },
});
await prisma.permissionAudit.create({
data: auditData(actor, 'teacher-password', 'user', teacher.id, null, { passwordChanged: true }),
});
}
/**
@@ -101,8 +135,11 @@ export async function resetTeacherPassword(
* 因此只有"没有留下任何班级共享内容"的账号才允许删除(实践中就是刚录入错的那条),
* 否则返回 409 让管理员先转移/清理。
*/
export async function deleteTeacher(userId: string): Promise<void> {
export async function deleteTeacher(actor: TokenPayload, userId: string): Promise<void> {
assertAdmin(actor);
const teacher = await findTeacherOrThrow(userId);
if (teacher.role === 'ADMIN' && (await prisma.user.count({ where: { role: 'ADMIN' } })) <= 1)
throw ApiError.conflict('不能删除最后一个管理员');
const [ownedClasses, courses, homeworks, notifications, coursewares] = await Promise.all([
prisma.class.count({ where: { teacherId: teacher.id } }),
@@ -128,7 +165,21 @@ export async function deleteTeacher(userId: string): Promise<void> {
);
}
if (coursewares > 0) throw ApiError.conflict(`「${teacher.name}」还有 ${coursewares} 个课件,请先清理课件再删除账号`);
if (coursewares > 0)
throw ApiError.conflict(`「${teacher.name}」还有 ${coursewares} 个课件,请先清理课件再删除账号`);
await prisma.user.delete({ where: { id: teacher.id } });
await prisma.$transaction(async (tx) => {
if (teacher.role === 'ADMIN') await protectLastAdmin(tx);
await tx.user.delete({ where: { id: teacher.id } });
await tx.permissionAudit.create({
data: auditData(actor, 'teacher-delete', 'user', teacher.id, teacher, null),
});
});
disconnectUserSessions([teacher.id]);
}
async function protectLastAdmin(tx: Prisma.TransactionClient) {
await tx.user.updateMany({ where: { role: 'ADMIN' }, data: { role: 'ADMIN' } });
if ((await tx.user.count({ where: { role: 'ADMIN' } })) <= 1)
throw ApiError.conflict('不能删除或降级最后一个管理员');
}
@@ -1,3 +1,4 @@
import { refreshClassSubscriptions } from '../../realtime/bus.js';
import { createHash, randomUUID } from 'node:crypto';
import fs from 'node:fs/promises';
import path from 'node:path';
@@ -441,6 +442,7 @@ async function executePromotion(year: number, options: RunOptions) {
for (const track of preview.tracks)
for (const cls of track.classes)
emitToClass(cls.id, SOCKET_EVENTS.classUpdated, { classId: cls.id, action: 'updated' });
refreshClassSubscriptions();
await notifyScheduleChanged();
return { ...(await prisma.promotionRun.findUniqueOrThrow({ where: { year } })), alreadyDone: false };
}
@@ -9,7 +9,13 @@ import {
} from './promotion.service.js';
import { Router } from 'express';
import { ApiError, sendOk } from '../../lib/http.js';
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
import {
authenticate,
getAuthUser,
requireAnyPermission,
requirePermissionOrClassDevice,
requirePermission,
} from '../../middleware/auth.js';
import { validate, validatedBody, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import {
@@ -74,16 +80,21 @@ router.post(
* - **写**:仅管理员 —— 周次是全校口径,改错会让所有班的课表整体错位;
* - `classId` 留空 = 全校默认;某个班有自己的配置时优先用它。
*/
router.get('/', requirePermission('term:read'), validate({ query: termQuerySchema }), async (req, res) => {
const user = getAuthUser(req);
const { classId } = validatedQuery<{ classId?: string }>(req);
sendOk(res, await termService.getTermWeeks(user, classId), '获取学期周次成功');
});
router.get(
'/',
requirePermissionOrClassDevice('term:read'),
validate({ query: termQuerySchema }),
async (req, res) => {
const user = getAuthUser(req);
const { classId } = validatedQuery<{ classId?: string }>(req);
sendOk(res, await termService.getTermWeeks(user, classId), '获取学期周次成功');
},
);
/** PUT /api/term - 保存逐周区间(整表覆盖,仅管理员) */
router.put(
'/',
requirePermission('term:write'),
requireAnyPermission('term:write', 'class:settings'),
validate({ body: updateTermWeeksSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -102,7 +113,7 @@ router.put(
*/
router.post(
'/auto',
requirePermission('term:write'),
requireAnyPermission('term:write', 'class:settings'),
validate({ body: autoTermWeeksSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -121,7 +132,7 @@ router.post(
*/
router.get(
'/holidays',
requirePermission('term:write'),
requireAnyPermission('term:write', 'class:settings'),
validate({ query: holidayQuerySchema }),
async (req, res) => {
const { year } = validatedQuery<{ year?: number }>(req);
@@ -1,3 +1,4 @@
import { assertAdmin, assertHeadOnly } from '../../lib/authz/index.js';
import {
buildAutoTermWeeks,
validateTermWeeks,
@@ -38,9 +39,15 @@ function scopeOf(classId?: string | null): string {
/** 读取某个范围的周次配置 */
export async function getTermWeeks(user: TokenPayload, classId?: string | null): Promise<TermWeeksDto> {
const scope = scopeOf(classId);
if (scope) await assertClassAccess(user, scope);
const scope = user.role === 'CLASS_DEVICE' ? (user.classId ?? user.sub) : scopeOf(classId);
if (user.role === 'CLASS_DEVICE' && classId && classId !== scope)
throw ApiError.forbidden('不能读取其他班级周次');
if (!scope) await assertPermission(user, 'term:read');
if (scope) await assertClassAccess(user, scope, 'term:read');
return readTermWeeks(scope);
}
async function readTermWeeks(scope: string): Promise<TermWeeksDto> {
const [weeks] = await Promise.all([
prisma.termWeek.findMany({ where: { classId: scope }, orderBy: { weekNumber: 'asc' } }),
scope
@@ -75,8 +82,9 @@ export async function updateTermWeeks(
user: TokenPayload,
input: UpdateTermWeeksInput,
): Promise<TermWeeksDto> {
await assertPermission(user, 'term:write');
const scope = scopeOf(input.classId);
if (scope) await assertHeadOnly(user, 'class:settings', scope);
else assertAdmin(user);
const maximum = input.maxTermWeeks ?? (await getMaxTermWeeks());
if (input.maxTermWeeks !== undefined && scope) throw ApiError.badRequest('总周数只能在全校默认范围修改');
@@ -132,7 +140,7 @@ export async function updateTermWeeks(
logger.info(`学期周次已更新:范围=${scope || '全校默认'} 周数=${input.weeks.length} 由 ${user.name} 执行`);
if (input.maxTermWeeks !== undefined) await setSetting(SETTING_KEYS.termMaxWeeks, String(maximum));
await publishTermChange(scope);
return getTermWeeks(user, scope || null);
return readTermWeeks(scope);
}
/**
@@ -142,8 +150,9 @@ export async function updateTermWeeks(
* 比从空白开始一周周填要快得多。
*/
export async function autoTermWeeks(user: TokenPayload, input: AutoTermWeeksInput): Promise<TermWeeksDto> {
await assertPermission(user, 'term:write');
const scope = scopeOf(input.classId);
if (scope) await assertHeadOnly(user, 'class:settings', scope);
else assertAdmin(user);
const context = await loadTermContext(scope || null);
const termStartDate = input.termStartDate || context.termStartDate || env.termStartDate;
const maxWeek = input.maxWeek ?? context.maxWeek;
@@ -1,3 +1,5 @@
import type { TokenPayload } from '../../lib/jwt.js';
import { assertAdmin } from '../../lib/authz/index.js';
import { getReleaseProgress, setReleaseProgress } from './release-progress.js';
import { Transform } from 'node:stream';
import crypto from 'node:crypto';
@@ -54,10 +56,14 @@ function releaseRoot(): string {
}
function releaseDir(version: string): string {
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(version) || version.includes('..'))
throw ApiError.badRequest('版本号不是安全路径段');
return path.join(releaseRoot(), version);
}
function releaseFilePath(version: string, fileName: string): string {
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(fileName) || fileName.includes('..'))
throw ApiError.badRequest('文件名不是安全路径段');
return path.join(releaseDir(version), fileName);
}
@@ -128,9 +134,11 @@ export async function resolveReleaseFile(
/** 登记一份已经在磁盘上的安装包:算 sha256 / 大小后落库(可重复登记,会覆盖) */
export async function registerRelease(
userId: string,
actor: TokenPayload,
input: RegisterClientReleaseInput,
): Promise<ClientReleaseDto> {
assertAdmin(actor);
const userId = actor.sub;
const version = input.version;
// 文件名默认走发布命名约定(与 GitHub Release、官网兜底直链一致)
const known = (await prisma.clientRelease.findUnique({ where: { version } })) as ReleaseRow | null;
@@ -171,11 +179,12 @@ export async function registerRelease(
/** 让服务端从 GitHub 拉一份安装包并登记(教室无外网时由服务端代下) */
export async function fetchReleaseFromGithub(
userId: string,
actor: TokenPayload,
input: FetchClientReleaseInput,
): Promise<ClientReleaseDto> {
if (['downloading', 'verifying'].includes(getReleaseProgress().stage))
throw ApiError.conflict('已有安装包正在下载');
assertAdmin(actor);
const version = input.version;
setReleaseProgress({ version, stage: 'downloading', downloadedBytes: 0, totalBytes: 0, error: '' });
const fileName = clientReleaseFileName(version);
@@ -221,13 +230,14 @@ export async function fetchReleaseFromGithub(
// 落盘后统一走登记:sha256 / 大小 / 落库都在同一处算
setReleaseProgress({ stage: 'verifying' });
const release = await registerRelease(userId, { version, fileName });
const release = await registerRelease(actor, { version, fileName });
setReleaseProgress({ stage: 'ready', downloadedBytes: release.size, totalBytes: release.size });
return release;
}
/** 删除一份缓存的安装包(元数据 + 磁盘文件) */
export async function deleteRelease(version: string): Promise<{ version: string }> {
export async function deleteRelease(actor: TokenPayload, version: string): Promise<{ version: string }> {
assertAdmin(actor);
const row = (await prisma.clientRelease.findUnique({ where: { version } })) as ReleaseRow | null;
if (!row) throw ApiError.notFound('服务端没有这个版本的安装包');
await prisma.clientRelease.delete({ where: { version } });
@@ -1,3 +1,4 @@
import { assertClassDevice, assertPermission, classIdsFor } from '../../lib/authz/index.js';
import { getReleaseProgress, setReleaseProgress } from './release-progress.js';
import { Router } from 'express';
import { sendCreated, sendOk, ApiError } from '../../lib/http.js';
@@ -7,9 +8,10 @@ import {
hasAuthPermission,
requireAnyPermission,
requirePermission,
requireStaffPermission,
requireAdmin,
requirePermissionOrClassDevice,
} from '../../middleware/auth.js';
import { validate, validatedBody, validatedParams } from '../../middleware/validate.js';
import { validateBadRequest as validate, validatedBody, validatedParams } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import * as clientReleases from './client-releases.service.js';
import * as updateService from './update.service.js';
@@ -39,7 +41,7 @@ router.get('/client/progress', requirePermission('client:update'), async (_req,
* 权限:任何已登录用户都能查(信息本身来自公开仓库)。`force=1` 绕过服务端缓存,
* 但**只有管理员生效** —— 否则任意登录用户都能靠刷这个参数把匿名限流打满。
*/
router.get('/check', requireStaffPermission('update:check'), async (req, res) => {
router.get('/check', requirePermissionOrClassDevice('update:check'), async (req, res) => {
const force = req.query.force === '1' && hasAuthPermission(req, 'update:force');
sendOk(res, await updateService.checkForUpdates({ force }), '更新检查完成');
});
@@ -68,13 +70,14 @@ router.get(
*/
router.post(
'/client/fetch',
requireAdmin(),
requirePermission('client:update'),
validate({ body: fetchClientReleaseSchema }),
async (req, res) => {
const user = getAuthUser(req);
const input = validatedBody<FetchClientReleaseInput>(req);
try {
sendCreated(res, await clientReleases.fetchReleaseFromGithub(user.sub, input), '安装包已下载并登记');
sendCreated(res, await clientReleases.fetchReleaseFromGithub(user, input), '安装包已下载并登记');
} catch (error) {
if (
!(error instanceof ApiError && error.status === 409) &&
@@ -97,23 +100,25 @@ router.post(
*/
router.post(
'/client/releases',
requireAdmin(),
requirePermission('client:update'),
validate({ body: registerClientReleaseSchema }),
async (req, res) => {
const user = getAuthUser(req);
const input = validatedBody<RegisterClientReleaseInput>(req);
sendCreated(res, await clientReleases.registerRelease(user.sub, input), '安装包已登记');
sendCreated(res, await clientReleases.registerRelease(user, input), '安装包已登记');
},
);
/** DELETE /api/update/client/releases/:version - 删除缓存(元数据 + 磁盘文件) */
router.delete(
'/client/releases/:version',
requireAdmin(),
requirePermission('client:update'),
validate({ params: clientReleaseVersionParamSchema }),
async (req, res) => {
const { version } = validatedParams<{ version: string }>(req);
sendOk(res, await clientReleases.deleteRelease(version), '安装包已删除');
sendOk(res, await clientReleases.deleteRelease(getAuthUser(req), version), '安装包已删除');
},
);
@@ -128,6 +133,13 @@ router.get(
'/client/download/:version',
validate({ params: clientReleaseVersionParamSchema }),
async (req, res) => {
const user = getAuthUser(req);
if (user.role === 'CLASS_DEVICE') assertClassDevice(user, user.classId ?? user.sub);
else {
await assertPermission(user, 'client:read');
if (user.role !== 'ADMIN' && !(await classIdsFor(user, 'client:read')).length)
throw ApiError.forbidden('无可访问班级');
}
const { version } = validatedParams<{ version: string }>(req);
const found = await clientReleases.resolveReleaseFile(version);
if (!found) throw ApiError.notFound('服务端没有缓存这个版本的安装包');
@@ -13,7 +13,7 @@ export const clientReleaseVersionSchema = z
.trim()
.min(1, '请输入版本号')
.max(40, '版本号最多 40 个字符')
.refine((value) => SAFE_SEGMENT.test(value), {
.refine((value) => SAFE_SEGMENT.test(value) && value !== '.' && value !== '..' && !value.includes('..'), {
message: '版本号只能包含字母、数字、点、下划线与短横线',
});
@@ -30,7 +30,9 @@ export const registerClientReleaseSchema = z.object({
.trim()
.min(1)
.max(120)
.refine((value) => SAFE_SEGMENT.test(value), { message: '文件名只能包含字母、数字、点、下划线与短横线' })
.refine((value) => SAFE_SEGMENT.test(value) && value !== '.' && value !== '..' && !value.includes('..'), {
message: '文件名只能包含字母、数字、点、下划线与短横线',
})
.optional(),
notes: z.string().trim().max(500).optional(),
});
+14 -2
View File
@@ -1,4 +1,4 @@
import { SOCKET_ROOMS } from '@classhelper/shared';
import { SOCKET_ROOMS, SOCKET_EVENT_CHANNELS } from '@classhelper/shared';
/**
* 实时事件总线。
@@ -21,7 +21,19 @@ export function emitToRooms(rooms: string[], event: string, payload: unknown): v
/** 按班级房间广播(class:{classId}) */
export function emitToClass(classId: string, event: string, payload: unknown): void {
emitToRooms([SOCKET_ROOMS.class(classId)], event, payload);
emitToRooms([SOCKET_ROOMS.classChannel(classId, SOCKET_EVENT_CHANNELS[event] ?? 'core')], event, payload);
}
let disconnectUsers: (ids: string[] | null) => void = () => {};
export function registerSessionInvalidator(invalidate: (ids: string[] | null) => void) {
disconnectUsers = invalidate;
}
export function disconnectUserSessions(ids: string[]) {
disconnectUsers(ids);
}
/** 班级集合或任课关系变化后,重连重新解析按键范围。 */
export function refreshClassSubscriptions() {
disconnectUsers(null);
}
/**
+91 -103
View File
@@ -3,36 +3,42 @@ import { Server } from 'socket.io';
import {
SOCKET_EVENTS,
SOCKET_ROOMS,
SOCKET_CHANNELS,
type SocketChannel,
type PermissionKey,
type ClientToServerEvents,
type ServerToClientEvents,
type SocketAuthPayload,
type UserRole,
} from '@classhelper/shared';
import { env } from '../config/env.js';
import { getAccessibleClassIds } from '../lib/access.js';
import { verifyToken } from '../lib/jwt.js';
import { verifyToken, type TokenPayload } from '../lib/jwt.js';
import { loadSessionUser } from '../lib/authz/session.js';
import { classIdsFor, loadAuthPrincipal, classDeviceClassId } from '../lib/authz/index.js';
import { logger } from '../lib/logger.js';
import { registerEmitter, type RealtimeEmitter } from './bus.js';
import { registerEmitter, registerSessionInvalidator } from './bus.js';
interface SocketData {
user: {
id: string;
username: string;
name: string;
role: UserRole;
classId: string | null;
classSession: boolean;
};
user: TokenPayload;
token: string;
}
export type RealtimeServer = Server<
ClientToServerEvents,
ServerToClientEvents,
Record<string, never>,
SocketData
>;
/** 绑定 Socket.IO 到 HTTP 服务,并注册到事件总线 */
const channelKeys: Record<SocketChannel, PermissionKey[]> = {
core: ['class:read', 'schedule:read'],
homework: ['homework:read'],
notification: ['notification:read'],
grade: ['grade:read'],
courseware: ['courseware:read'],
};
async function channelClasses(user: TokenPayload, channel: SocketChannel): Promise<Set<string>> {
const id = classDeviceClassId(user);
if (id) return new Set([id]);
return new Set((await Promise.all(channelKeys[channel].map((key) => classIdsFor(user, key)))).flat());
}
export function initRealtime(httpServer: HttpServer): RealtimeServer {
const io: RealtimeServer = new Server(httpServer, {
cors: { origin: env.corsOrigins, credentials: true },
@@ -40,110 +46,92 @@ export function initRealtime(httpServer: HttpServer): RealtimeServer {
pingInterval: 25000,
pingTimeout: 20000,
});
// 握手鉴权:客户端通过 handshake.auth.token 传入 JWT
io.use((socket, next) => {
try {
const auth = (socket.handshake.auth ?? {}) as Partial<SocketAuthPayload>;
const queryToken =
typeof socket.handshake.query.token === 'string' ? socket.handshake.query.token : null;
const token = auth.token ?? queryToken;
if (!token) {
next(new Error('缺少认证 token'));
return;
void (async () => {
try {
const auth = socket.handshake.auth as Partial<SocketAuthPayload>;
const token =
auth.token ??
(typeof socket.handshake.query.token === 'string' ? socket.handshake.query.token : '');
const user = await loadSessionUser(verifyToken(token));
await loadAuthPrincipal(user);
socket.data = { user, token };
next();
} catch {
next(new Error('认证失败,请重新登录'));
}
const payload = verifyToken(token);
// ClassHelper 班级端:socket.data.user.id 用**班级 id**,据此加入 session:{classId} 房间,
// 「叫人」等定向消息因此能直达教室机器(见 calls.service)。
socket.data.user = {
id: payload.classSession ? (payload.classId ?? payload.sub) : payload.sub,
username: payload.classSession ? (payload.classCode ?? payload.username) : payload.username,
name: payload.name,
role: payload.role,
classId: payload.classId,
classSession: payload.classSession === true,
};
next();
} catch {
next(new Error('认证失败,请重新登录'));
}
})();
});
io.on('connection', (socket) => {
const user = socket.data.user;
void (async () => {
const rooms = [SOCKET_ROOMS.session(user.id), SOCKET_ROOMS.role(user.role)];
try {
const classIds = await getAccessibleClassIds({
sub: user.id,
username: user.username,
name: user.name,
role: user.role,
classId: user.classId,
classSession: user.classSession,
});
for (const classId of classIds) {
await socket.join(SOCKET_ROOMS.class(classId));
rooms.push(SOCKET_ROOMS.class(classId));
}
} catch (error) {
logger.warn(`加入班级房间失败(user=${user.id})`, error);
}
// ClassHelper 班级端归入 students 房间(它代表全班),教师/管理员归入 teachers
const audienceRoom = user.role === 'CLASS_DEVICE' ? SOCKET_ROOMS.students : SOCKET_ROOMS.teachers;
await socket.join(audienceRoom);
rooms.push(audienceRoom);
logger.info(`实时连接建立:${user.name}(${user.role}) sid=${socket.id}`);
socket.emit(SOCKET_EVENTS.connected, { sessionId: user.id, role: user.role, rooms });
})();
// 客户端可显式订阅某个班级(服务端二次校验权限)
socket.on('class:join', (classId, ack) => {
// 手动订阅每次回查账号与权限;不沿用握手时的权限快照。
const ready = (async () => {
const base = [
SOCKET_ROOMS.session(user.sub),
SOCKET_ROOMS.role(user.role),
user.role === 'CLASS_DEVICE' ? SOCKET_ROOMS.students : SOCKET_ROOMS.teachers,
];
await socket.join(base);
for (const channel of SOCKET_CHANNELS)
for (const id of await channelClasses(user, channel))
await socket.join(SOCKET_ROOMS.classChannel(id, channel));
socket.emit(SOCKET_EVENTS.connected, {
sessionId: user.sub,
role: user.role,
rooms: [...socket.rooms].filter((r) => r !== socket.id),
});
})().catch((error) => {
logger.warn('实时订阅失败', error);
socket.disconnect(true);
});
socket.on('class:join', (request, ack) => {
void (async () => {
await ready;
try {
const allowed = await getAccessibleClassIds({
sub: user.id,
username: user.username,
name: user.name,
role: user.role,
classId: user.classId,
classSession: user.classSession,
});
if (!allowed.includes(classId)) {
ack?.({ ok: false, message: '无权订阅该班级' });
const current = await loadSessionUser(verifyToken(socket.data.token));
const classId = typeof request === 'string' ? request : request.classId;
const requested =
typeof request === 'string' ? SOCKET_CHANNELS : (request.channels ?? SOCKET_CHANNELS);
if (!requested.length || requested.some((c) => !SOCKET_CHANNELS.includes(c))) {
ack?.({ ok: false, message: '未知事件通道' });
return;
}
await socket.join(SOCKET_ROOMS.class(classId));
const allowed: SocketChannel[] = [];
for (const channel of requested)
if ((await channelClasses(current, channel)).has(classId)) allowed.push(channel);
// 旧字符串协议只加入有权通道;新协议显式通道有任一无权则全部拒绝。
if (!allowed.length || (typeof request !== 'string' && allowed.length !== requested.length)) {
ack?.({ ok: false, message: '无权订阅该班级或通道' });
return;
}
for (const channel of allowed) await socket.join(SOCKET_ROOMS.classChannel(classId, channel));
ack?.({ ok: true });
} catch {
ack?.({ ok: false, message: '订阅失败' });
}
})();
});
socket.on('class:leave', (classId) => {
void socket.leave(SOCKET_ROOMS.class(classId));
});
socket.on('ping', (ack) => {
ack?.();
});
socket.on('disconnect', (reason) => {
logger.debug(`实时连接断开:${user.name} (${reason})`);
socket.on('class:leave', (request) => {
void (async () => {
await ready;
const classId = typeof request === 'string' ? request : request.classId;
for (const channel of typeof request === 'string'
? SOCKET_CHANNELS
: (request.channels ?? SOCKET_CHANNELS))
await socket.leave(SOCKET_ROOMS.classChannel(classId, channel));
})();
});
socket.on('ping', (ack) => ack?.());
});
registerEmitter((rooms, event, payload) => {
(io.to(rooms) as unknown as { emit(event: string, payload: unknown): void }).emit(event, payload);
});
registerSessionInvalidator((ids) => {
if (ids === null) {
io.in(SOCKET_ROOMS.teachers).disconnectSockets(true);
return;
}
for (const id of ids) io.in(SOCKET_ROOMS.session(id)).disconnectSockets(true);
});
const emit: RealtimeEmitter = (rooms, event, payload) => {
const target = io.to(rooms) as unknown as { emit: (event: string, payload: unknown) => void };
target.emit(event, payload);
logger.debug(`实时推送 ${event} -> ${rooms.join(',')}`);
};
registerEmitter(emit);
logger.info('Socket.IO 实时通道已就绪(房间规则:class:{classId} / session:{id})');
return io;
}
+1 -1
View File
@@ -89,7 +89,7 @@ export function getMaxTermWeeks(configured?: unknown): number {
return Number.isInteger(value) && value >= 1 && value <= 60 ? value : 30;
}
export type PermissionScope = 'SELF_CLASS' | 'GRADE' | 'DIVISION' | 'ALL';
export type PermissionScope = 'TEACHING' | 'HEAD' | 'GRADE' | 'DIVISION' | 'ALL';
export interface ReleaseProgressDto {
version: string;
stage: 'idle' | 'downloading' | 'verifying' | 'ready' | 'waiting' | 'installing' | 'failed';
+18
View File
@@ -493,6 +493,22 @@ export const SOCKET_EVENTS = {
connected: 'connected',
} as const;
/** 班级事件域;订阅资格按对应读能力逐域求值。 */
export const SOCKET_CHANNELS = ['core', 'homework', 'notification', 'grade', 'courseware'] as const;
export type SocketChannel = (typeof SOCKET_CHANNELS)[number];
export const SOCKET_EVENT_CHANNELS: Record<string, SocketChannel> = {
'class:updated': 'core',
'schedule:updated': 'core',
'homework:new': 'homework',
'homework:updated': 'homework',
'homework:status': 'homework',
'notification:new': 'notification',
'notification:deleted': 'notification',
'call:new': 'notification',
'grade:updated': 'grade',
'courseware:changed': 'courseware',
};
/** ClassIsland JSON 导入:时间点类型(与 TimeLayoutItem.TimeType 一致) */
export const CLASSISLAND_TIME_TYPES = {
class: 0,
@@ -560,6 +576,8 @@ export function buildCallTitle(studentName: string, teacherName: string): string
/** Socket.IO 房间名生成规则:与后端保持一致,客户端订阅时复用 */
export const SOCKET_ROOMS = {
classChannel: (classId: string, channel: SocketChannel): string => `class:${classId}:${channel}`,
/** @deprecated 仅供旧协议测试识别,生产不再订阅。 */
class: (classId: string): string => `class:${classId}`,
/**
* 会话自身的房间:老师/管理员是账号 id,ClassHelper 班级端是班级 id。
+11 -5
View File
@@ -2,14 +2,13 @@ import type { PermissionScope } from './class-plan.js';
/** 范围解析供保存、查询与展示共用;旧库空岗位范围会由调用方拒绝访问。 */
export function parsePermissionScope(scope: PermissionScope, value = '', knownGrades?: readonly string[]) {
if (scope === 'ALL') return { scope, scopeValue: '', grades: [], scopeLabel: '全校' };
if (scope === 'SELF_CLASS') {
if (value !== '' && value !== 'HEAD') throw new Error('关联班级范围只允许全部关联班级或仅班主任班');
if (scope === 'ALL' || scope === 'TEACHING' || scope === 'HEAD') {
if (value !== '') throw new Error('关联班级或全校范围不接受范围值');
return {
scope,
scopeValue: value,
scopeValue: '',
grades: [],
scopeLabel: value === 'HEAD' ? '仅班主任班' : '班主任与任课班级',
scopeLabel: scope === 'ALL' ? '全校' : scope === 'HEAD' ? '仅班主任班' : '班主任与任课班级',
};
}
if (scope !== 'GRADE' && scope !== 'DIVISION') throw new Error('未知的数据范围');
@@ -50,3 +49,10 @@ export function defaultPromotionPlan(classCount = 1): PromotionPlan {
resetTermWeeks: true,
};
}
/** 仅迁移和旧快照恢复使用;运行时不接受旧枚举。 */
export function migratePermissionScope(scope: string, scopeValue = '') {
return scope === 'SELF_CLASS'
? { scope: scopeValue === 'HEAD' ? 'HEAD' : 'TEACHING', scopeValue: '' }
: { scope, scopeValue };
}
+92 -7
View File
@@ -1,4 +1,5 @@
import type { UserRole } from './types.js';
import type { PermissionScope } from './class-plan.js';
/**
* 班级内的角色(相对"某一个班级"而言)。
@@ -168,6 +169,8 @@ export const PERMISSION_KEYS = [
// 班级与人员
'class:read',
'class:manage',
'class:account',
'class:settings',
'roster:read',
'roster:manage',
'teacher:read',
@@ -243,11 +246,22 @@ export const BUILTIN_TEACHER_GROUPS = [
{
id: 'builtin-head',
name: '班主任',
scope: 'SELF_CLASS',
scopeValue: 'HEAD',
keys: [...TEACHING_KEYS, 'client:read', 'client:manage'],
scope: 'HEAD',
scopeValue: '',
keys: [
...TEACHING_KEYS,
'class:read',
'class:account',
'class:settings',
'roster:manage',
'schedule:write',
'import:students',
'import:schedule',
'client:read',
'client:manage',
],
},
{ id: 'builtin-teacher', name: '教师', scope: 'SELF_CLASS', scopeValue: '', keys: TEACHING_KEYS },
{ id: 'builtin-teacher', name: '教师', scope: 'TEACHING', scopeValue: '', keys: TEACHING_KEYS },
{ id: 'builtin-grade', name: '级长', scope: 'GRADE', scopeValue: '', keys: LEAD_KEYS },
{ id: 'builtin-division', name: '部长', scope: 'DIVISION', scopeValue: '', keys: LEAD_KEYS },
] as const;
@@ -257,7 +271,9 @@ export const PERMISSION_LABELS: Record<PermissionKey, string> = {
'courseware:read': '查看与下载课件',
'courseware:write': '上传、设置可见范围与推送自己的课件',
'class:read': '查看班级与班级列表',
'class:manage': '创建 / 修改 / 删除班级(含班级账号密码)',
'class:manage': '创建 / 修改 / 删除班级',
'class:account': '管理本班班级码与密码(仅班主任)',
'class:settings': '管理本班学期设置(仅班主任)',
'roster:read': '查看学生名单',
'roster:manage': '学生名单管理(资料 / 调班 / 转入转出 / 停用)',
'teacher:read': '查看教师名单',
@@ -305,6 +321,8 @@ export const PERMISSION_CATEGORIES: readonly { title: string; keys: readonly Per
keys: [
'class:read',
'class:manage',
'class:account',
'class:settings',
'roster:read',
'roster:manage',
'teacher:read',
@@ -376,6 +394,8 @@ export const PERMISSION_DEFAULT_ROLES: Record<PermissionKey, readonly UserRole[]
'courseware:write': ['ADMIN', 'TEACHER'],
'class:read': ['ADMIN', 'TEACHER'],
'class:manage': ['ADMIN'],
'class:account': ['ADMIN', 'TEACHER'],
'class:settings': ['ADMIN', 'TEACHER'],
'roster:read': ['ADMIN', 'TEACHER'],
'roster:manage': ['ADMIN', 'TEACHER'],
'teacher:read': ['ADMIN', 'TEACHER'],
@@ -422,7 +442,7 @@ export const ALL_PERMISSION_KEYS: readonly PermissionKey[] = PERMISSION_KEYS;
/**
* 角色默认权限清单(由 {@link PERMISSION_DEFAULT_ROLES} 派生)。
*
* `ADMIN` 是全集(服务端还会强制补上 `permission:manage` / `system:manage`,防止把自己锁死)。
* `ADMIN` 固定为全集,不由模板限制。
*/
export const ROLE_DEFAULT_PERMISSIONS: Record<UserRole, readonly PermissionKey[]> = {
ADMIN: PERMISSION_KEYS,
@@ -460,7 +480,7 @@ export const PERMISSION_MATRIX: readonly {
subject: boolean;
classDevice?: boolean;
}[] = [
{ action: '学年升级(默认管理员,可按权限组授权)', admin: true, head: false, subject: false },
{ action: '学年升级(仅管理员)', admin: true, head: false, subject: false },
{ action: '班级创建 / 修改 / 删除', admin: true, head: false, subject: false },
{ action: '分配班主任与各科科任老师', admin: true, head: false, subject: false },
{ action: '学生名单管理(增删 / 导入 / 调班)', admin: true, head: true, subject: false },
@@ -474,3 +494,68 @@ export const PERMISSION_MATRIX: readonly {
{ action: '按学号查询学生成绩明细', admin: true, head: true, subject: true, classDevice: true },
{ action: '查看 ClassHelper 班级端在线状态', admin: true, head: true, subject: true },
];
/** 能力目录:管理员能力不可下放,关系提示不替代服务层数据校验。 */
export const PERMISSION_SCOPES = ['TEACHING', 'HEAD', 'GRADE', 'DIVISION', 'ALL'] as const;
export interface PermissionMeta {
kind: 'admin' | 'class' | 'org';
risk: 'normal' | 'high';
relation: 'head' | 'subject' | 'publish' | 'creator' | 'none';
allowedScopes: readonly PermissionScope[];
defaultRoles: readonly UserRole[];
label: string;
category: string;
}
const ADMIN_KEYS: readonly PermissionKey[] = [
'class:manage',
'teacher:manage',
'permission:manage',
'system:manage',
'database:manage',
'update:force',
'ai:manage',
'import:teachers',
'import:class-teachers',
'timetable:manage',
'promotion:manage',
'archive:manage',
'term:write',
'client:update',
];
const ORG_KEYS: readonly PermissionKey[] = ['teacher:read', 'update:check'];
const HIGH_KEYS: readonly PermissionKey[] = [
...ADMIN_KEYS,
'class:account',
'client:manage',
'client:update-push',
'remote:control',
'import:students',
'import:schedule',
'grade:import',
];
export const PERMISSION_META: Record<PermissionKey, PermissionMeta> = Object.fromEntries(
PERMISSION_KEYS.map((key) => {
const kind = ADMIN_KEYS.includes(key) ? 'admin' : ORG_KEYS.includes(key) ? 'org' : 'class';
const relation = ['class:account', 'class:settings'].includes(key)
? 'head'
: ['homework:write', 'grade:write', 'grade:import'].includes(key)
? 'subject'
: ['notification:publish', 'call:create'].includes(key)
? 'publish'
: key === 'notification:delete'
? 'creator'
: 'none';
return [
key,
{
kind,
relation,
risk: HIGH_KEYS.includes(key) ? 'high' : 'normal',
allowedScopes: kind === 'class' ? PERMISSION_SCOPES : [],
defaultRoles: PERMISSION_DEFAULT_ROLES[key],
label: PERMISSION_LABELS[key],
category: PERMISSION_CATEGORIES.find((c) => c.keys.includes(key))?.title ?? '',
},
];
}),
) as unknown as Record<PermissionKey, PermissionMeta>;
+51 -13
View File
@@ -12,7 +12,8 @@ import type {
// 只导入类型(PermissionKey 由 permissions.ts 的 PERMISSION_KEYS 推导):
// 两个文件之间是**类型层面**的相互引用,编译后都会被抹掉,运行时不存在循环依赖。
import type { PermissionKey } from './permissions.js';
import type { PermissionKey, PermissionMeta } from './permissions.js';
import type { SocketChannel } from './constants.js';
/**
* 账号角色:管理员 / 教师 / ClassHelper 班级端。
@@ -495,14 +496,12 @@ export interface CreateClassRequest {
}
export interface UpdateClassRequest {
/** 本班学期开始日期(YYYY-MM-DD,第 1 教学周的周一);留空串表示回落到全局配置 */
termStartDate?: string | null;
/** 入学年份(有名无实的旧数据需要补填;填了会重算 name) */
enrollmentYear?: number;
/** 班号(同上) */
classIndex?: number;
/** 本学期教学周数(1~30,仅管理员可调) */
}
export interface UpdateClassSettingsRequest {
termWeeks?: number;
termStartDate?: string | null;
}
/** 班级端成绩查询开关 */
@@ -1205,6 +1204,10 @@ export interface IslandState {
/* ------------------------------------------------------------------ 仪表盘 */
export interface DashboardSummary {
sections: Record<
'classes' | 'students' | 'courses' | 'schedules' | 'homeworks' | 'notifications' | 'grades',
boolean
>;
classCount: number;
studentCount: number;
courseCount: number;
@@ -1325,8 +1328,11 @@ export interface TimeLayoutImportResult {
/** 客户端 -> 服务端事件名 */
export interface ClientToServerEvents {
'class:join': (classId: string, ack?: (result: { ok: boolean; message?: string }) => void) => void;
'class:leave': (classId: string) => void;
'class:join': (
classId: string | { classId: string; channels?: SocketChannel[] },
ack?: (result: { ok: boolean; message?: string }) => void,
) => void;
'class:leave': (classId: string | { classId: string; channels?: SocketChannel[] }) => void;
ping: (ack?: () => void) => void;
}
@@ -1465,6 +1471,12 @@ export interface UpdateInfo {
* **没有任何分组**时回落角色默认模板(逐条镜像权限组上线前各路由的 requireRole)。
*/
export interface PermissionGroupDto {
defaultScope: ContractPermissionScope;
defaultScopeValue: string;
defaultScopeLabel: string;
kind: 'builtin' | 'custom' | 'default';
inactiveKeys: PermissionKey[];
warnings: string[];
scope?: ContractPermissionScope;
scopeValue?: string;
scopeLabel: string;
@@ -1481,14 +1493,32 @@ export interface PermissionGroupDto {
}
/** 权限页里的一个成员(管理员/教师账号) */
export interface PermissionAssignment {
groupId: string;
scope: ContractPermissionScope;
scopeValue: string;
}
export interface EffectivePermissionsDto {
permissions: PermissionKey[];
entries: {
key: PermissionKey;
scopes: string[];
classes: { id: string; name: string }[];
classCount: number;
}[];
conflicts: string[];
headOnlyKeys: PermissionKey[];
}
export interface PermissionMemberDto {
fixed: boolean;
assignments: (PermissionAssignment & { groupName: string; scopeLabel: string })[];
id: string;
username: string;
name: string;
role: UserRole;
/** 所属权限组;空数组 = 走角色默认模板 */
/** 所属模板的派生 id;空数组 = 教师默认模板 */
groupIds: string[];
/** 生效权限数量;管理员在分组并集之外始终保留两个管理入口 */
/** 生效能力数量;管理员固定为全集 */
permissionCount: number;
permissions: PermissionKey[];
scopeLabels: string[];
@@ -1496,6 +1526,7 @@ export interface PermissionMemberDto {
/** `GET /api/permissions/catalog`:权限键目录(与 shared 的常量一一对应,供权限页渲染) */
export interface PermissionCatalogDto {
meta: Record<PermissionKey, PermissionMeta>;
keys: PermissionKey[];
labels: Record<PermissionKey, string>;
categories: { title: string; keys: PermissionKey[] }[];
@@ -1503,7 +1534,7 @@ export interface PermissionCatalogDto {
defaultRoles: Record<PermissionKey, UserRole[]>;
/** 每个角色的默认模板("没有分组时会是什么") */
roleDefaults: Record<UserRole, PermissionKey[]>;
/** 管理员不可被剥夺的键(前端置灰、服务端也会合并回去) */
/** 兼容字段:管理员固定拥有的全部目录键 */
unremovable: PermissionKey[];
}
@@ -1525,7 +1556,8 @@ export interface UpdatePermissionGroupRequest {
/** `PUT /api/permissions/users/:id/groups`:整体替换某账号的权限组 */
export interface AssignUserGroupsRequest {
groupIds: string[];
groupIds?: string[];
assignments?: PermissionAssignment[];
}
/**
@@ -1535,9 +1567,15 @@ export interface AssignUserGroupsRequest {
* 真正的拦截始终在服务端。
*/
export interface MyPermissionsDto {
/** 各能力实际班级集合,供界面选择器与按钮使用;服务端仍须再授权。 */
classScopes?: Partial<Record<PermissionKey, string[]>>;
manageScopes?: Partial<Record<PermissionKey, string[]>>;
/** 结合班主任、任课及发布者关系后的动作范围,用于维护页面入口。 */
actionScopes?: Partial<Record<PermissionKey, string[]>>;
allScopeKeys?: PermissionKey[];
role: UserRole;
permissions: PermissionKey[];
/** true = 权限来自权限组;false = 走角色默认模板(未分配任何组) */
/** true = 教师有显式分配;管理员和班级端恒为 false */
fromGroups: boolean;
}
+4
View File
@@ -105,6 +105,10 @@ export const classApi = {
create: (payload: CreateClassRequest): Promise<ClassDto> => api.post(API_PATHS.classes, payload),
update: (id: string, payload: UpdateClassRequest): Promise<ClassDto> =>
api.patch(`${API_PATHS.classes}/${id}`, payload),
updateSettings: (
id: string,
payload: { termWeeks?: number; termStartDate?: string | null },
): Promise<ClassDto> => api.patch(`${API_PATHS.classes}/${id}/settings`, payload),
remove: (id: string): Promise<{ id: string }> => api.delete(`${API_PATHS.classes}/${id}`),
students: (id: string): Promise<StudentDto[]> => api.get(`${API_PATHS.classes}/${id}/students`),
addStudent: (
@@ -21,6 +21,7 @@ const props = defineProps<{
layouts: TimeLayoutDetailDto[];
courses: CourseDto[];
canEdit: boolean;
canCreate: boolean;
saving: boolean;
/** 引用当前时间表的日课表数量,显示在「时间表信息」里。 */
planCount: number;
@@ -321,7 +322,7 @@ onUnmounted(() => {
<aside class="layout-explorer" aria-label="时间表列表">
<el-input v-model="search" placeholder="搜索时间表" clearable />
<div class="explorer-actions">
<button class="command" :disabled="!canEdit" @click="emit('create')">
<button class="command" :disabled="!canCreate" @click="emit('create')">
<ArchiveIcon name="plus" />
新时间表
</button>
@@ -388,7 +389,7 @@ onUnmounted(() => {
<div v-if="!layout" class="panel-empty">
<ArchiveIcon name="clock" />
<p>新建一份时间表开始编辑</p>
<button class="command" :disabled="!canEdit" @click="emit('create')">+ 新时间表</button>
<button class="command" :disabled="!canCreate" @click="emit('create')">+ 新时间表</button>
</div>
<div v-else-if="view === 'list'" class="point-list" aria-label="时间点列表">
<div
+7 -2
View File
@@ -1,4 +1,9 @@
/** 只定义分组;子页标题、图标和权限直接读取路由,避免菜单与路由各维护一套权限。 */
import type { PermissionKey } from '@classhelper/shared';
/** 字符串动作使用服务端业务范围;manage 用于班级管理,permission 用于无班级目标的操作。 */
export type MenuAction = PermissionKey | { key: PermissionKey; scope: 'manage' | 'permission' };
/** 只定义分组;标题、图标、读权限和可操作条件均读取路由。 */
export const MANAGEMENT_GROUPS = [
{ id: 'workspace', title: '工作台', icon: 'Odometer', paths: ['/dashboard', '/assistant'] },
{
@@ -11,7 +16,7 @@ export const MANAGEMENT_GROUPS = [
id: 'school',
title: '班级与人员',
icon: 'School',
paths: ['/classes', '/students', '/teachers', '/roster', '/term-weeks', '/promotion', '/archives'],
paths: ['/classes', '/students', '/teachers', '/term-weeks', '/promotion', '/archives'],
},
{ id: 'devices', title: '设备运维', icon: 'Monitor', paths: ['/clients'] },
{
+4 -20
View File
@@ -77,18 +77,7 @@ function openAssistant(): void {
void router.push('/assistant');
}
/**
* 菜单项与可见角色(与后端 `@classhelper/shared/permissions` 矩阵保持一致):
* - 班级管理 / 学生管理 / 教师管理:仅管理员(班主任与科任老师都没有班级增删改与人员分配权限)
* - 课表管理:管理员 + 班主任(页面内再按"是否本班班主任"控制增删改按钮)
* - 成绩录入:管理员 + 教师;页面内只对"管理员或所选班级的班主任"显示写入/导入按钮
* (需求 6:成绩与表格导入老师端可用且不越权;科任老师只能看,写入会被后端 403)
* - 作业 / 通知:所有教师
* - 数据库管理:仅管理员(备份/恢复/导入导出/一键切换是最高危操作,后端同样 requireRole('ADMIN'))
* - 权限组管理 / 客户端设备:`roles` 只是粗筛(管理员 + 教师),真正的门控是**生效权限**
* (`permission:manage` / `client:manage`)—— 教师被某个权限组授予它之后同样该看见
* (见 `visibleMenuItems`)
*/
/** 菜单标题、图标、能力取路由;服务端负责范围与业务关系的最终校验。 */
const menuItems = router
.getRoutes()
.filter((record) => MANAGEMENT_GROUPS.some((g) => (g.paths as readonly string[]).includes(record.path)))
@@ -96,16 +85,11 @@ const menuItems = router
path: record.path,
title: String(record.meta.title || record.path),
icon: String(record.meta.icon || 'Setting'),
roles: record.meta.roles as string[] | undefined,
permission: record.meta.permission as PermissionKey | undefined,
menuActions: record.meta.menuActions,
}));
const visibleMenuItems = computed(() =>
menuItems.filter(
(item) =>
!!auth.role &&
(!item.roles?.length || item.roles.includes(auth.role)) &&
(!item.permission || auth.can(item.permission)),
),
menuItems.filter((item) => !!auth.role && auth.canShowMenu(item.permission, item.menuActions)),
);
const menuGroups = computed(() =>
MANAGEMENT_GROUPS.map((group) => ({
@@ -123,12 +107,12 @@ const searchResults = computed(() =>
);
const tabs = ref<Array<ManagementMenuItem & { fullPath: string }>>([]);
function addCurrentTab() {
tabs.value = tabs.value.filter((tab) => visibleMenuItems.value.some((item) => item.path === tab.path));
const item = visibleMenuItems.value.find((item) => item.path === route.path);
if (!item) return;
const tab = tabs.value.find((tab) => tab.path === item.path);
if (tab) tab.fullPath = route.fullPath;
else tabs.value.push({ path: item.path, title: item.title, icon: item.icon, fullPath: route.fullPath });
tabs.value = tabs.value.filter((tab) => visibleMenuItems.value.some((item) => item.path === tab.path));
if (tabs.value.length > 20) tabs.value.splice(tabs.value[0]?.path === '/dashboard' ? 1 : 0, 1);
}
watch([() => route.fullPath, visibleMenuItems], addCurrentTab, { immediate: true });
+78 -46
View File
@@ -21,8 +21,8 @@ const routes: RouteRecordRaw[] = [
meta: {
title: '课件',
icon: 'FolderOpened',
roles: ['ADMIN', 'TEACHER'],
permission: 'courseware:read',
menuActions: [{ key: 'courseware:write', scope: 'permission' }],
},
},
{
@@ -32,15 +32,13 @@ const routes: RouteRecordRaw[] = [
meta: {
title: '课表档案',
icon: 'Calendar',
roles: ['ADMIN', 'TEACHER'],
permission: 'schedule:read',
menuActions: ['schedule:write', 'timetable:manage'],
},
},
{
path: 'roster',
name: 'roster',
component: () => import('@/views/StudentsView.vue'),
meta: { title: '学生名单', icon: 'User', roles: ['TEACHER'], permission: 'roster:read' },
redirect: (to) => ({ path: '/students', query: to.query, hash: to.hash }),
},
{
path: 'system-settings',
@@ -52,37 +50,59 @@ const routes: RouteRecordRaw[] = [
path: 'dashboard',
name: 'dashboard',
component: () => import('@/views/DashboardView.vue'),
meta: { title: '仪表盘', icon: 'Odometer' },
meta: { title: '仪表盘', icon: 'Odometer', permission: 'dashboard:read' },
},
{
path: 'classes',
name: 'classes',
component: () => import('@/views/ClassesView.vue'),
meta: { title: '班级管理', icon: 'School', permission: 'class:read' },
meta: {
title: '班级管理',
icon: 'School',
permission: 'class:read',
menuActions: [
'class:manage',
'class:account',
'class:settings',
'roster:manage',
'schedule:write',
{ key: 'grade:write', scope: 'manage' },
],
},
},
{
path: 'students',
name: 'students',
component: () => import('@/views/StudentsView.vue'),
meta: { title: '学生管理', icon: 'User', permission: 'roster:read' },
meta: {
title: '学生管理',
icon: 'User',
permission: 'roster:read',
menuActions: ['roster:manage', 'call:create'],
},
},
{
path: 'teachers',
name: 'teachers',
component: () => import('@/views/TeachersView.vue'),
meta: { title: '教师管理', icon: 'UserFilled', roles: ['ADMIN'] },
meta: { title: '教师管理', icon: 'UserFilled', permission: 'teacher:manage' },
},
{
path: 'term-weeks',
name: 'term-weeks',
component: () => import('@/views/TermWeeksView.vue'),
// 改周次会让全校/某班的课表口径整体变化,与「班级管理」同一档权限
meta: { title: '学期周次', icon: 'Calendar', permission: 'term:read' },
meta: {
title: '学期周次',
icon: 'Calendar',
permission: 'term:read',
menuActions: ['term:write', 'class:settings'],
},
},
{
path: 'promotion',
name: 'promotion',
component: () => import('@/components/PromotionSettings.vue'),
component: () => import('@/views/PromotionView.vue'),
meta: { title: '学年升级', icon: 'Calendar', permission: 'promotion:manage' },
},
{
@@ -96,25 +116,40 @@ const routes: RouteRecordRaw[] = [
path: 'schedules',
name: 'schedules',
redirect: '/plan-editor',
meta: { title: '课表管理', icon: 'Calendar' },
meta: { title: '课表管理', icon: 'Calendar', permission: 'schedule:read' },
},
{
path: 'homeworks',
name: 'homeworks',
component: () => import('@/views/HomeworksView.vue'),
meta: { title: '作业发布', icon: 'Notebook' },
meta: {
title: '作业发布',
icon: 'Notebook',
permission: 'homework:read',
menuActions: ['homework:write'],
},
},
{
path: 'notifications',
name: 'notifications',
component: () => import('@/views/NotificationsView.vue'),
meta: { title: '通知发布', icon: 'Bell' },
meta: {
title: '通知发布',
icon: 'Bell',
permission: 'notification:read',
menuActions: ['notification:publish', 'notification:delete', 'call:create'],
},
},
{
path: 'grades',
name: 'grades',
component: () => import('@/views/GradesView.vue'),
meta: { title: '成绩录入', icon: 'Trophy' },
meta: {
title: '成绩录入',
icon: 'Trophy',
permission: 'grade:read',
menuActions: ['grade:write', 'grade:import'],
},
},
{
path: 'assistant',
@@ -122,7 +157,7 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/AssistantView.vue'),
// 按**生效权限**门控(教师默认就有 ai:use,见 shared/permissions.ts 的默认模板):
// 管理员把 ai:use 从某个权限组收回后,这一页同时从菜单与路由上消失
meta: { title: 'AI 助手', icon: 'MagicStick', permission: 'ai:use' },
meta: { title: 'AI 助手', icon: 'MagicStick', permission: 'ai:use', menuActions: ['ai:use'] },
},
{
path: 'ai-settings',
@@ -136,25 +171,35 @@ const routes: RouteRecordRaw[] = [
name: 'database',
component: () => import('@/views/DatabaseView.vue'),
// 与后端 requireRole('ADMIN') 对齐:改动数据库是最高危操作,仅管理员
meta: { title: '数据库管理', icon: 'Coin', roles: ['ADMIN'] },
meta: { title: '数据库管理', icon: 'Coin', permission: 'database:manage' },
},
{
path: 'permissions',
name: 'permissions',
component: () => import('@/views/PermissionsView.vue'),
// 这一页**按生效权限**门控而不是按角色:教师被某个权限组授予 permission:manage 后同样可用,
// 与后端 requirePermission('permission:manage') 对齐(详见下方守卫)
meta: { title: '权限组管理', icon: 'Lock', permission: 'permission:manage' },
// 管理员专用能力不可下放,菜单与路由仍统一按服务端能力集合过滤。
meta: { title: '权限与范围', icon: 'Lock', permission: 'permission:manage' },
},
{
path: 'clients',
name: 'clients',
component: () => import('@/views/ClientsView.vue'),
meta: { title: '客户端', icon: 'Monitor', permission: 'client:read' },
meta: {
title: '客户端',
icon: 'Monitor',
permission: 'client:read',
menuActions: ['client:manage', 'client:update-push', 'remote:control', 'client:update'],
},
},
{ path: 'client-devices', redirect: '/clients' },
],
},
{
path: '/no-access',
name: 'no-access',
component: () => import('@/views/NoAccessView.vue'),
meta: { title: '暂无功能授权' },
},
{
path: '/:pathMatch(.*)*',
name: 'not-found',
@@ -168,18 +213,6 @@ export const router = createRouter({
routes,
});
/** 读取已保存的登录用户角色(路由守卫用;未登录/解析失败返回 null) */
function storedRole(): string | null {
try {
const raw = localStorage.getItem(STORAGE_KEYS.user);
if (!raw) return null;
const parsed = JSON.parse(raw) as { role?: string };
return parsed?.role ?? null;
} catch {
return null;
}
}
router.beforeEach(async (to) => {
const token = localStorage.getItem(STORAGE_KEYS.token);
if (to.meta.public) {
@@ -189,23 +222,22 @@ router.beforeEach(async (to) => {
if (!token) {
return { name: 'login', query: { redirect: to.fullPath } };
}
// 仅管理员可见的页面(班级/学生/教师管理):直接输网址也要挡回去,
// 不能依赖"菜单隐藏"——菜单隐藏只是看不见,不是权限。
const roles = to.meta.roles;
if (Array.isArray(roles) && roles.length > 0) {
const role = storedRole();
if (!role || !(roles as string[]).includes(role)) {
return { name: 'dashboard' };
}
}
// 按**生效权限**门控的页面(权限组管理):角色判不出来 —— 教师被权限组授予
// permission:manage 后同样该放行,所以这里异步取一次生效权限。
// `ensurePermissions()` 在 store 内是幂等的(本次会话只真拉一次),不会每次跳转都打接口。
const permission = to.meta.permission;
if (typeof permission === 'string') {
const auth = useAuthStore();
await auth.ensurePermissions();
if (!auth.can(permission as PermissionKey)) return { name: 'dashboard' };
if (!auth.can(permission as PermissionKey)) {
if (auth.can('dashboard:read')) return { name: 'dashboard' };
const fallback = router
.getRoutes()
.find(
(r) =>
r.name &&
typeof r.meta.permission === 'string' &&
auth.canShowMenu(r.meta.permission, r.meta.menuActions),
);
return fallback ? { name: fallback.name! } : { name: 'no-access' };
}
}
return true;
});
+62 -2
View File
@@ -2,6 +2,7 @@ import { computed, ref } from 'vue';
import { defineStore } from 'pinia';
import {
ROLE_LABELS,
PERMISSION_META,
STORAGE_KEYS,
hasPermission,
type LoginRequest,
@@ -10,6 +11,7 @@ import {
type SessionUser,
} from '@classhelper/shared';
import { authApi, permissionApi } from '@/api';
import type { MenuAction } from '@/config/navigation';
/**
* 会话主体。
@@ -44,6 +46,54 @@ export const useAuthStore = defineStore('auth', () => {
/** 生效权限是否来自显式分组;没有显式分组时使用可编辑的角色默认组。 */
const permissionsFromGroups = ref(false);
const permissionsLoaded = ref(false);
const classScopes = ref<Partial<Record<PermissionKey, string[]>>>({});
const manageScopes = ref<Partial<Record<PermissionKey, string[]>>>({});
const actionScopes = ref<Partial<Record<PermissionKey, string[]>>>({});
const allScopeKeys = ref<PermissionKey[]>([]);
function canForClass(key: PermissionKey, classId: string | null | undefined): boolean {
return (
can(key) &&
(classId ? (classScopes.value[key] ?? []).includes(classId) : allScopeKeys.value.includes(key))
);
}
function canManageClass(key: PermissionKey, classId: string | null | undefined): boolean {
return (
can(key) &&
(classId
? (manageScopes.value[key] ?? classScopes.value[key] ?? []).includes(classId)
: allScopeKeys.value.includes(key))
);
}
function canShowMenu(permission: PermissionKey | undefined, actions?: readonly MenuAction[]): boolean {
if (permission && !can(permission)) return false;
if (!actions) return true;
return actions.some((action) => {
const key = typeof action === 'string' ? action : action.key;
if (!can(key)) return false;
if (
(typeof action !== 'string' && action.scope === 'permission') ||
PERMISSION_META[key].kind !== 'class'
)
return true;
const ids =
typeof action !== 'string' && action.scope === 'manage'
? (manageScopes.value[key] ?? [])
: (actionScopes.value[key] ?? []);
// 全校名单维护还允许新建未分班学生。
if (
key === 'roster:manage' &&
allScopeKeys.value.includes(key) &&
(!permission || allScopeKeys.value.includes(permission))
)
return true;
return ids.some(
(id) =>
!permission ||
PERMISSION_META[permission].kind !== 'class' ||
(classScopes.value[permission] ?? []).includes(id),
);
});
}
const isAuthenticated = computed(() => Boolean(token.value));
const role = computed(() => user.value?.role ?? null);
@@ -54,10 +104,9 @@ export const useAuthStore = defineStore('auth', () => {
/**
* 是否有某个生效权限。
*
* 管理员同样遵守生效集合,只保留与服务端一致的两个管理入口,避免锁死。
* 页面直接使用服务端生效集合;管理员全集也由服务端返回。
*/
function can(key: PermissionKey): boolean {
if (isAdmin.value && (key === 'permission:manage' || key === 'system:manage')) return true;
return hasPermission(permissions.value, key);
}
@@ -73,6 +122,10 @@ export const useAuthStore = defineStore('auth', () => {
try {
const mine = await permissionApi.me();
permissions.value = mine.permissions;
classScopes.value = mine.classScopes ?? {};
manageScopes.value = mine.manageScopes ?? {};
actionScopes.value = mine.actionScopes ?? {};
allScopeKeys.value = mine.allScopeKeys ?? [];
permissionsFromGroups.value = mine.fromGroups;
permissionsLoaded.value = true;
} catch {
@@ -83,6 +136,10 @@ export const useAuthStore = defineStore('auth', () => {
/** 清掉权限缓存(换账号 / 退出登录时必须调用,否则会沿用上一个账号的权限) */
function resetPermissions(): void {
permissions.value = [];
classScopes.value = {};
manageScopes.value = {};
actionScopes.value = {};
allScopeKeys.value = [];
permissionsFromGroups.value = false;
permissionsLoaded.value = false;
}
@@ -146,6 +203,9 @@ export const useAuthStore = defineStore('auth', () => {
displayName,
roleLabel,
can,
canForClass,
canManageClass,
canShowMenu,
ensurePermissions,
login,
logout,
+14 -1
View File
@@ -12,6 +12,7 @@ import {
type ServerToClientEvents,
} from '@classhelper/shared';
import { SOCKET_URL } from '@/config';
import { useAuthStore } from '@/stores/auth';
type RealtimeEventName = keyof ServerToClientEvents;
type EventHandler = (payload: unknown) => void;
@@ -55,8 +56,20 @@ export const useRealtimeStore = defineStore('realtime', () => {
connected.value = true;
connecting.value = false;
});
instance.on('disconnect', () => {
instance.on('disconnect', (reason: string) => {
connected.value = false;
if (reason === 'io server disconnect') {
void (async () => {
const auth = useAuthStore();
try {
await auth.fetchProfile();
await auth.ensurePermissions(true);
if (socket === instance && auth.token) instance.connect();
} catch {
/* 身份失效时等待重新登录。 */
}
})();
}
});
instance.on('connect_error', (error: Error) => {
connected.value = false;
+11
View File
@@ -0,0 +1,11 @@
import 'vue-router';
import type { PermissionKey } from '@classhelper/shared';
import type { MenuAction } from '@/config/navigation';
declare module 'vue-router' {
interface RouteMeta {
permission?: PermissionKey;
/** 维护页面任一动作在至少一个可读班级生效时显示;纯查看页面省略此项。 */
menuActions?: readonly MenuAction[];
}
}
+61 -17
View File
@@ -32,7 +32,10 @@ const realtime = useRealtimeStore();
const { isMobile } = useResponsive();
/** 仅管理员可管理班级(增删改)与人员分配 —— 与后端权限矩阵一致 */
const canManageClass = computed(() => auth.can('class:manage'));
const canManageClass = computed(() => auth.isAdmin && auth.can('class:manage'));
function canHeadAction(row: ClassDto, key: 'class:account' | 'class:settings') {
return auth.canForClass(key, row.id) && (auth.isAdmin || row.teacher?.id === auth.user?.id);
}
/** 小屏下详情描述改为单列,避免文字被压成竖排 */
const detailColumns = computed(() => (isMobile.value ? 1 : 3));
@@ -131,7 +134,6 @@ async function submitForm(): Promise<void> {
await classApi.update(editingId.value, {
enrollmentYear: form.enrollmentYear,
classIndex: form.classIndex,
termWeeks: form.termWeeks,
});
// 编辑弹窗里也能直接换班主任(仅管理员;班主任决定谁能管这个班的课表与成绩)
if (canManageClass.value && form.headTeacherId) {
@@ -246,6 +248,9 @@ async function removeClass(row: ClassDto): Promise<void> {
const detailVisible = ref(false);
const detailLoading = ref(false);
const detail = ref<ClassDetailDto | null>(null);
const canManageDetailRoster = computed(() => auth.canManageClass('roster:manage', detail.value?.id));
const canManageDetailCourses = computed(() => auth.canManageClass('schedule:write', detail.value?.id));
const canManageDetailGradeQuery = computed(() => auth.canManageClass('grade:write', detail.value?.id));
const activeTab = ref('students');
async function openDetail(row: ClassDto): Promise<void> {
@@ -285,7 +290,7 @@ const studentRules: FormRules = {
};
async function addStudent(): Promise<void> {
if (!detail.value) return;
if (!detail.value || !canManageDetailRoster.value) return;
if (!studentForm.studentNo.trim() || !studentForm.name.trim()) {
ElMessage.warning('请填写学号和姓名');
return;
@@ -307,7 +312,7 @@ async function addStudent(): Promise<void> {
}
async function removeStudent(row: StudentDto): Promise<void> {
if (!detail.value) return;
if (!detail.value || !canManageDetailRoster.value) return;
await ElMessageBox.confirm(`将「${row.name}」移出本班级?`, '确认', { type: 'warning' });
await classApi.removeStudent(detail.value.id, row.id);
ElMessage.success('已移出班级');
@@ -320,6 +325,7 @@ async function removeStudent(row: StudentDto): Promise<void> {
const courseForm = reactive({ name: '' });
async function addCourse(): Promise<void> {
if (!canManageDetailCourses.value) return;
if (!detail.value || !courseForm.name.trim()) {
ElMessage.warning('请输入课程名称');
return;
@@ -331,6 +337,7 @@ async function addCourse(): Promise<void> {
}
async function removeCourse(row: CourseDto): Promise<void> {
if (!auth.canManageClass('schedule:write', row.classId)) return;
await ElMessageBox.confirm(`删除课程「${row.name}」?相关课表与作业关联会被清除。`, '确认', {
type: 'warning',
});
@@ -409,7 +416,7 @@ const gradeQuerySaving = ref(false);
/** 是否允许教室的 ClassHelper 班级端按学号查询本班学生的成绩明细 */
async function toggleGradeQuery(enabled: boolean): Promise<void> {
if (!detail.value) return;
if (!detail.value || !canManageDetailGradeQuery.value) return;
gradeQuerySaving.value = true;
try {
await classApi.setGradeQuery(detail.value.id, enabled);
@@ -470,6 +477,24 @@ onUnmounted(() => {
realtime.off(SOCKET_EVENTS.notificationNew, onClassChanged);
realtime.off(SOCKET_EVENTS.homeworkNew, onClassChanged);
});
const settings = reactive({ visible: false, id: '', termWeeks: 20, termStartDate: '' });
function openSettings(row: ClassDto) {
Object.assign(settings, {
visible: true,
id: row.id,
termWeeks: row.termWeeks,
termStartDate: row.termStartDate ?? '',
});
}
async function saveSettings() {
await classApi.updateSettings(settings.id, {
termWeeks: settings.termWeeks,
termStartDate: settings.termStartDate,
});
settings.visible = false;
await loadClasses();
ElMessage.success('学期设置已保存');
}
</script>
<template>
@@ -546,9 +571,17 @@ onUnmounted(() => {
<el-table-column label="操作" width="310" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openDetail(row)">详情</el-button>
<el-button v-if="canManageClass" link type="warning" @click="openAccount(row)">
<el-button
v-if="canHeadAction(row, 'class:account')"
link
type="warning"
@click="openAccount(row)"
>
班级账号
</el-button>
<el-button v-if="canHeadAction(row, 'class:settings')" link @click="openSettings(row)">
学期设置
</el-button>
<el-button v-if="canManageClass" link type="primary" @click="openEdit(row)">编辑</el-button>
<el-button v-if="canManageClass" link type="danger" @click="removeClass(row)">删除</el-button>
</template>
@@ -556,6 +589,20 @@ onUnmounted(() => {
</el-table>
</el-card>
<el-dialog v-model="settings.visible" title="本班学期设置" width="440px">
<el-form label-width="100px">
<el-form-item label="教学周数">
<el-input-number v-model="settings.termWeeks" :min="1" :max="maxTermWeeks" />
</el-form-item>
<el-form-item label="开学日期">
<el-date-picker v-model="settings.termStartDate" type="date" value-format="YYYY-MM-DD" clearable />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="settings.visible = false">取消</el-button>
<el-button type="primary" @click="saveSettings">保存设置</el-button>
</template>
</el-dialog>
<!-- 班级账号:班级码 + 班级密码(教室机器上 ClassHelper 班级端的登录凭据) -->
<el-dialog v-model="accountVisible" title="班级账号(ClassHelper 班级端登录)" width="480px">
<el-alert type="info" :closable="false" show-icon class="mb-12">
@@ -625,7 +672,7 @@ onUnmounted(() => {
班主任可以管理本班课表与成绩;留空则默认由创建者本人担任
</span>
</el-form-item>
<el-form-item label="学期周数">
<el-form-item v-if="!editingId" label="学期周数">
<el-input-number v-model="form.termWeeks" :min="1" :max="maxTermWeeks" :step="1" />
<span class="text-muted ml-8">
本学期一共多少教学周(默认 20,最多 {{ maxTermWeeks }} 周);课表周次选择与默认结束周都按它来
@@ -680,7 +727,7 @@ onUnmounted(() => {
<el-tab-pane label="学生名单" name="students">
<div class="toolbar mt-12">
<el-button
v-if="auth.can('roster:manage')"
v-if="canManageDetailRoster"
type="primary"
size="small"
:icon="'Plus'"
@@ -703,11 +750,9 @@ onUnmounted(() => {
<el-table-column label="加入时间" width="180">
<template #default="{ row }">{{ formatDate(row.createdAt, true) }}</template>
</el-table-column>
<el-table-column label="操作" width="90">
<el-table-column v-if="canManageDetailRoster" label="操作" width="90">
<template #default="{ row }">
<el-button v-if="auth.can('roster:manage')" link type="danger" @click="removeStudent(row)">
移出
</el-button>
<el-button link type="danger" @click="removeStudent(row)">移出</el-button>
</template>
</el-table-column>
</el-table>
@@ -719,15 +764,14 @@ onUnmounted(() => {
<el-table-column label="任课教师" width="140">
<template #default="{ row }">{{ row.teacher?.name ?? '-' }}</template>
</el-table-column>
<el-table-column label="操作" width="90">
<el-table-column v-if="canManageDetailCourses" label="操作" width="90">
<template #default="{ row }">
<el-button v-if="auth.can('schedule:write')" link type="danger" @click="removeCourse(row)">
删除
</el-button>
<el-button link type="danger" @click="removeCourse(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-input
v-if="canManageDetailCourses"
v-model="courseForm.name"
placeholder="输入课程名称后回车快速添加"
class="mt-12"
@@ -817,7 +861,7 @@ onUnmounted(() => {
<el-switch
:model-value="detail?.studentGradeQueryEnabled ?? true"
:loading="gradeQuerySaving"
:disabled="!auth.can('grade:write')"
:disabled="!canManageDetailGradeQuery"
@change="(value: boolean) => toggleGradeQuery(value)"
/>
</div>
+3 -3
View File
@@ -672,15 +672,15 @@ const deviceEmptyText = computed(() =>
<div class="client-actions">
<el-tooltip
:disabled="can('client:update')"
content="当前账号没有「向客户端统一下发更新」权限(client:update)"
:disabled="can('client:update-push')"
content="当前账号没有「向客户端统一下发更新」权限(client:update-push)"
placement="top"
>
<span>
<el-button
type="primary"
:icon="'Upload'"
:disabled="!can('client:update')"
:disabled="!can('client:update-push')"
@click="openUpdateDialog"
>
一键推送更新
+30 -12
View File
@@ -37,8 +37,20 @@ const quickEntries = computed(() =>
icon: 'Calendar',
enabled: auth.can('schedule:read'),
},
{ path: '/homeworks', title: '发布作业', hint: '布置并追踪完成情况', icon: 'Notebook', enabled: true },
{ path: '/notifications', title: '通知与叫人', hint: '实时送达教室', icon: 'Bell', enabled: true },
{
path: '/homeworks',
title: '发布作业',
hint: '布置并追踪完成情况',
icon: 'Notebook',
enabled: auth.can('homework:read'),
},
{
path: '/notifications',
title: '通知与叫人',
hint: '实时送达教室',
icon: 'Bell',
enabled: auth.can('notification:read'),
},
{
path: '/clients',
title: '客户端',
@@ -52,18 +64,24 @@ const quickEntries = computed(() =>
const statCards = computed(() => {
const data = summary.value;
return [
{ label: '管理班级', value: data?.classCount ?? 0, hint: '按权限范围统计' },
{ label: '学生人数', value: data?.studentCount ?? 0, hint: '所辖班级学生' },
{ label: '课程数量', value: data?.courseCount ?? 0, hint: '含全部课程' },
{ label: '课表条目', value: data?.scheduleCount ?? 0, hint: '全部周次' },
{ section: 'classes', label: '管理班级', value: data?.classCount ?? 0, hint: '按权限范围统计' },
{ section: 'students', label: '学生人数', value: data?.studentCount ?? 0, hint: '所辖班级学生' },
{ section: 'courses', label: '课程数量', value: data?.courseCount ?? 0, hint: '含全部课程' },
{ section: 'schedules', label: '课表条目', value: data?.scheduleCount ?? 0, hint: '全部周次' },
{
section: 'homeworks',
label: '作业总数',
value: data?.homeworkCount ?? 0,
hint: `未完成/待完成 ${data?.pendingHomeworkCount ?? 0}`,
},
{ label: '通知总数', value: data?.notificationCount ?? 0, hint: `未读 ${unreadCount.value}` },
{ label: '成绩记录', value: data?.gradeCount ?? 0, hint: '已发布成绩' },
];
{
section: 'notifications',
label: '通知总数',
value: data?.notificationCount ?? 0,
hint: `未读 ${unreadCount.value}`,
},
{ section: 'grades', label: '成绩记录', value: data?.gradeCount ?? 0, hint: '已发布成绩' },
].filter((card) => data?.sections[card.section as keyof DashboardSummary['sections']]);
});
/**
@@ -76,7 +94,7 @@ async function load(): Promise<void> {
loading.value = true;
try {
summary.value = await dashboardApi.summary();
const result = await notificationApi.unreadCount();
const result = auth.can('notification:read') ? await notificationApi.unreadCount() : { count: 0 };
unreadCount.value = result.count;
} finally {
loading.value = false;
@@ -158,7 +176,7 @@ function openNotification(item: NotificationDto): void {
<el-row :gutter="12" class="mt-16">
<el-col :xs="24" :md="24">
<el-card shadow="never">
<el-card v-if="summary?.sections.notifications" shadow="never">
<template #header>
<div class="card-header">
<span>最新通知</span>
@@ -184,7 +202,7 @@ function openNotification(item: NotificationDto): void {
</el-col>
</el-row>
<el-card shadow="never" class="mt-16 table-card">
<el-card v-if="summary?.sections.homeworks" shadow="never" class="mt-16 table-card">
<template #header>
<div class="card-header">
<span>最近作业</span>
+18 -9
View File
@@ -35,7 +35,7 @@ const auth = useAuthStore();
* (查 `Course.teacherId`),越权一律 403。因此这里只要是教师就放行按钮,
* 让老师能看到入口并在被拒时得到明确的后端提示,而不是"按钮凭空消失、不知道为什么"。
*/
const canManageGrades = computed(() => auth.role === 'ADMIN' || auth.role === 'TEACHER');
const canManageGrades = computed(() => auth.can('grade:write'));
const realtime = useRealtimeStore();
const loading = ref(false);
@@ -221,10 +221,7 @@ const ownCourses = computed(() =>
);
/** 能不能在该班新建科目(与课表管理同权限:管理员或本班班主任) */
const canCreateCourse = computed(() => {
if (auth.isAdmin) return true;
return classes.value.find((item) => item.id === filter.classId)?.teacherId === auth.user?.id;
});
const canCreateCourse = computed(() => auth.canManageClass('schedule:write', filter.classId));
/** 录入弹窗的默认科目:优先沿用页面筛选,但筛的是别人的科目时退回"我的科目"第一条 */
function defaultFormCourseId(): string {
@@ -489,6 +486,12 @@ onMounted(async () => {
onUnmounted(() => {
realtime.off(SOCKET_EVENTS.gradeUpdated, onGradeEvent);
});
function canEditContent(row: GradeDto): boolean {
if (!auth.canForClass('grade:write', row.classId)) return false;
if (auth.isAdmin) return true;
if (!row.courseId) return classes.value.find((c) => c.id === row.classId)?.teacher?.id === auth.user?.id;
return courses.value.some((c) => c.id === row.courseId && c.teacherId === auth.user?.id);
}
</script>
<template>
@@ -519,7 +522,7 @@ onUnmounted(() => {
<el-button v-if="canManageGrades" type="success" :icon="'Upload'" @click="openBulk">
批量录入
</el-button>
<el-button v-if="canManageGrades" type="warning" :icon="'Document'" @click="openImport">
<el-button v-if="auth.can('grade:import')" type="warning" :icon="'Document'" @click="openImport">
导入表格
</el-button>
<el-button v-if="canManageGrades" :icon="'Sort'" @click="openLevelBatch">批量改等级</el-button>
@@ -537,7 +540,11 @@ onUnmounted(() => {
<!-- 考试口径:这次考试要不要把所有科目相加做总分排名(按「班级 + 考试名」记,
客户端据此显示总分榜)。导入成绩时顺手就能定下来。 -->
<el-card v-if="canManageGrades && filter.classId && filter.examName" shadow="never" class="mt-12">
<el-card
v-if="auth.canManageClass('grade:write', filter.classId) && filter.examName"
shadow="never"
class="mt-12"
>
<div class="toolbar">
<span class="exam-label">考试口径</span>
<el-tag effect="plain">{{ filter.examName }}</el-tag>
@@ -615,7 +622,7 @@ onUnmounted(() => {
</el-tag>
<!-- 单个编辑:percent 口径下相当于手动覆盖自动换算的结果 -->
<el-select
v-if="canManageGrades"
v-if="canEditContent(row)"
:model-value="row.level"
size="small"
style="width: 88px"
@@ -639,7 +646,9 @@ onUnmounted(() => {
</el-table-column>
<el-table-column label="操作" width="90" fixed="right">
<template #default="{ row }">
<el-button v-if="canManageGrades" link type="danger" @click="removeGrade(row)">删除</el-button>
<el-button v-if="canEditContent(row)" link type="danger" @click="removeGrade(row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
+17 -9
View File
@@ -78,6 +78,7 @@ const notSubmittedNames = computed(() =>
/** 打开详情时顺带读一次未交名单(教师/管理员都有权限) */
async function loadSubmissions(homeworkId: string): Promise<boolean> {
if (!current.value || !canEditContent(current.value)) return false;
const requestId = ++submissionsRequestId;
submissionsLoading.value = true;
try {
@@ -237,10 +238,7 @@ const ownFormCourses = computed(() =>
);
/** 该班里能不能新建科目:与课表管理同权限(管理员或本班班主任) */
const canCreateCourse = computed(() => {
if (auth.isAdmin) return true;
return classes.value.find((item) => item.id === form.classId)?.teacherId === auth.user?.id;
});
const canCreateCourse = computed(() => auth.canManageClass('schedule:write', form.classId));
async function loadFormCourses(): Promise<void> {
const classId = form.classId;
@@ -500,6 +498,12 @@ onUnmounted(() => {
realtime.off(SOCKET_EVENTS.homeworkUpdated, onHomeworkEvent);
realtime.off(SOCKET_EVENTS.homeworkStatus, onHomeworkEvent);
});
function canEditContent(row: HomeworkDto): boolean {
if (!auth.canForClass('homework:write', row.classId)) return false;
if (auth.isAdmin) return true;
if (!row.courseId) return classes.value.find((c) => c.id === row.classId)?.teacher?.id === auth.user?.id;
return courses.value.some((c) => c.id === row.courseId && c.teacherId === auth.user?.id);
}
</script>
<template>
@@ -545,8 +549,10 @@ onUnmounted(() => {
@keyup.enter="loadHomeworks"
@clear="loadHomeworks"
/>
<el-button type="primary" :icon="'Plus'" @click="openCreate">发布作业</el-button>
<el-button @click="openBulk">批量布置</el-button>
<el-button v-if="auth.can('homework:write')" type="primary" :icon="'Plus'" @click="openCreate">
发布作业
</el-button>
<el-button v-if="auth.can('homework:write')" @click="openBulk">批量布置</el-button>
</div>
</div>
@@ -565,8 +571,10 @@ onUnmounted(() => {
<el-table-column label="操作" width="210" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openDetail(row)">详情</el-button>
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
<el-button link type="danger" @click="removeHomework(row)">删除</el-button>
<el-button v-if="canEditContent(row)" link type="primary" @click="openEdit(row)">编辑</el-button>
<el-button v-if="canEditContent(row)" link type="danger" @click="removeHomework(row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
@@ -715,7 +723,7 @@ onUnmounted(() => {
已完成 {{ completedMap.done }} / {{ completedMap.total }} 人({{ completedMap.rate }}%)
<el-progress :percentage="completedMap.rate" :stroke-width="10" class="mt-12" />
</el-descriptions-item>
<el-descriptions-item label="未交名单" :span="2">
<el-descriptions-item v-if="current && canEditContent(current)" label="未交名单" :span="2">
<div v-loading="submissionsLoading" class="submission-summary">
<template v-if="submissionStudents.length === 0">该班还没有学生账号</template>
<template v-else-if="notSubmittedNames.length === 0">
@@ -0,0 +1,15 @@
<script setup lang="ts">
import { useAuthStore } from '@/stores/auth';
import { useRouter } from 'vue-router';
const auth = useAuthStore();
const router = useRouter();
async function logout() {
await auth.logout();
await router.replace('/login');
}
</script>
<template>
<el-result icon="info" title="暂无可访问的功能" sub-title="请联系管理员检查模板与分配范围。">
<template #extra><el-button @click="logout">退出登录</el-button></template>
</el-result>
</template>
File diff suppressed because it is too large. Load diff
@@ -29,12 +29,16 @@ const importVisible = ref(false),
search = ref(''),
activePoint = ref('');
const fileInput = ref<HTMLInputElement>();
const canTime = computed(() => auth.permissions.includes('timetable:manage'));
const canWrite = computed(
const canCreateTime = computed(
() =>
auth.can('schedule:write') &&
(auth.role === 'ADMIN' || classes.value.find((c) => c.id === classId.value)?.teacherId === auth.user?.id),
auth.can('timetable:manage') ||
(auth.can('schedule:write') &&
classes.value.find((c) => c.id === classId.value)?.teacher?.id === auth.user?.id),
);
const canTime = computed(
() => auth.can('timetable:manage') || (canCreateTime.value && layout.value?.classId === classId.value),
);
const canWrite = computed(() => auth.canManageClass('schedule:write', classId.value));
const classes = ref<ClassDto[]>([]),
courses = ref<CourseDto[]>([]);
const classId = ref(''),
@@ -172,11 +176,11 @@ watch(
{ flush: 'sync' },
);
async function newLayout() {
if (!canTime.value || saving.value) return;
if (!canCreateTime.value || saving.value) return;
const { value } = await ElMessageBox.prompt('输入时间表名称', '新时间表');
const result = await api.post<TimeLayoutDetailDto>('/time-layouts', {
name: value,
classId: classId.value,
classId: classId.value || null,
points: [],
});
selectedLayout.value = result.id;
@@ -974,10 +978,18 @@ function selectPlanItem(id: string) {
</el-button>
</template>
<div class="transfer-menu">
<el-button text :disabled="!canWrite || !classId" @click="openImport">
<el-button
text
:disabled="!canWrite || !auth.can('import:schedule') || !classId"
@click="openImport"
>
导入 ClassIsland 档案
</el-button>
<el-button text :disabled="!canWrite || !classId" @click="fileInput?.click()">
<el-button
text
:disabled="!canWrite || !auth.can('import:schedule') || !classId"
@click="fileInput?.click()"
>
导入 JSON
</el-button>
<el-button text :disabled="!classId" @click="exportArchive">导出完整档案</el-button>
@@ -1427,6 +1439,7 @@ function selectPlanItem(id: string) {
:layouts="layouts"
:courses="courses"
:can-edit="canTime"
:can-create="canCreateTime"
:saving="saving"
:plan-count="layoutPlanCount"
@select-layout="selectedLayout = $event"
@@ -0,0 +1,15 @@
<script setup lang="ts">
import PromotionSettings from '@/components/PromotionSettings.vue';
</script>
<template>
<div class="page">
<div class="page-header">
<div>
<h2 class="page-title">学年升级</h2>
<p class="page-subtitle">维护年度升级计划与学段轨道,执行前先预览影响并确认备份。</p>
</div>
</div>
<PromotionSettings />
</div>
</template>
@@ -31,12 +31,7 @@ const realtime = useRealtimeStore();
* 课表管理权限:管理员,或"所选班级的班主任"(Class.teacherId === 当前用户)。
* 与后端 assertCanManageSchedule 使用同一权限矩阵;科任老师不显示写入口。
*/
const canManageSchedule = computed(() => {
if (auth.role === 'ADMIN') return true;
if (auth.role !== 'TEACHER') return false;
const current = classes.value.find((item) => item.id === query.classId);
return Boolean(current && current.teacherId === auth.user?.id);
});
const canManageSchedule = computed(() => auth.canManageClass('schedule:write', query.classId));
const loading = ref(false);
const classes = ref<ClassDto[]>([]);
+37 -11
View File
@@ -20,7 +20,11 @@ import { callApi, classApi, studentApi } from '@/api';
import TableImportDialog from '@/components/TableImportDialog.vue';
const auth = useAuthStore();
const readOnly = computed(() => !auth.can('roster:manage'));
const readOnly = computed(() =>
filter.classId
? !auth.canManageClass('roster:manage', filter.classId)
: !classes.value.some((row) => auth.canManageClass('roster:manage', row.id)),
);
const loading = ref(false);
const students = ref<StudentDto[]>([]);
const classes = ref<ClassDto[]>([]);
@@ -54,6 +58,12 @@ function exportRoster() {
URL.revokeObjectURL(url);
}
const classOptions = computed(() => classes.value);
const managedClassOptions = computed(() =>
classes.value.filter((row) => auth.canManageClass('roster:manage', row.id)),
);
function canSelectStudent(row: StudentDto): boolean {
return auth.canManageClass('roster:manage', row.classId) && row.status === 'active' && !row.archivedYearId;
}
async function loadClasses(): Promise<void> {
classes.value = auth.can('class:read') ? await classApi.list() : [];
@@ -404,7 +414,7 @@ async function submitCall(): Promise<void> {
<el-button v-if="!readOnly" @click="openIn()">转入学生</el-button>
<el-button v-if="!readOnly" type="primary" :icon="'Plus'" @click="openCreate">新建学生</el-button>
<el-button
v-if="!readOnly && auth.can('import:students')"
v-if="!readOnly && filter.classId && auth.canManageClass('import:students', filter.classId)"
type="warning"
:icon="'Document'"
@click="openImport"
@@ -448,7 +458,7 @@ async function submitCall(): Promise<void> {
empty-text="暂无学生数据"
@selection-change="(rows: StudentDto[]) => (selection = rows)"
>
<el-table-column v-if="!readOnly" type="selection" width="46" />
<el-table-column v-if="!readOnly" type="selection" width="46" :selectable="canSelectStudent" />
<el-table-column prop="studentNo" label="学号" width="130" />
<el-table-column prop="name" label="姓名" width="110" />
<el-table-column label="性别" width="80">
@@ -478,7 +488,7 @@ async function submitCall(): Promise<void> {
<el-table-column label="操作" width="370" fixed="right">
<template #default="{ row }">
<el-button
v-if="auth.can('call:create') && row.status === 'active'"
v-if="auth.canForClass('call:create', row.classId) && row.status === 'active'"
link
type="success"
@click="openCall(row)"
@@ -486,7 +496,11 @@ async function submitCall(): Promise<void> {
叫人
</el-button>
<el-button
v-if="!readOnly && row.status === 'active' && !row.archivedYearId"
v-if="
auth.canManageClass('roster:manage', row.classId) &&
row.status === 'active' &&
!row.archivedYearId
"
link
type="primary"
@click="openTransfer([row])"
@@ -495,7 +509,11 @@ async function submitCall(): Promise<void> {
</el-button>
<el-button link @click="openHistory(row)">生命周期历史</el-button>
<el-button
v-if="!readOnly && ['active', 'inactive'].includes(row.status) && !row.archivedYearId"
v-if="
auth.canManageClass('roster:manage', row.classId) &&
['active', 'inactive'].includes(row.status) &&
!row.archivedYearId
"
link
type="primary"
@click="openEdit(row)"
@@ -503,7 +521,11 @@ async function submitCall(): Promise<void> {
编辑
</el-button>
<el-button
v-if="!readOnly && row.status === 'active' && !row.archivedYearId"
v-if="
auth.canManageClass('roster:manage', row.classId) &&
row.status === 'active' &&
!row.archivedYearId
"
link
type="danger"
@click="removeStudent(row)"
@@ -521,7 +543,11 @@ async function submitCall(): Promise<void> {
删除空记录
</el-button>
<el-button
v-if="!readOnly && ['transferred', 'inactive'].includes(row.status) && !row.archivedYearId"
v-if="
auth.canManageClass('roster:manage', row.classId) &&
['transferred', 'inactive'].includes(row.status) &&
!row.archivedYearId
"
link
type="primary"
@click="openIn(row)"
@@ -556,7 +582,7 @@ async function submitCall(): Promise<void> {
style="width: 100%"
>
<el-option
v-for="item in classOptions"
v-for="item in managedClassOptions"
:key="item.id"
:label="`${item.name} / ${item.grade}`"
:value="item.id"
@@ -604,7 +630,7 @@ async function submitCall(): Promise<void> {
<el-form-item label="目标在读班级" required>
<el-select v-model="inForm.toClassId">
<el-option
v-for="item in classOptions"
v-for="item in managedClassOptions"
:key="item.id"
:label="`${item.name} / ${item.grade}`"
:value="item.id"
@@ -729,7 +755,7 @@ async function submitCall(): Promise<void> {
<el-form-item v-if="!editingId" label="班级">
<el-select v-model="form.classId" placeholder="暂不分班" clearable style="width: 100%">
<el-option
v-for="item in classOptions"
v-for="item in managedClassOptions"
:key="item.id"
:label="`${item.name} / ${item.grade}`"
:value="item.id"
+23 -19
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
/**
* 学期周次(仅管理员)。
* 学期周次(按班级范围读取,管理员或本班班主任维护)。
*
* 为什么要有这一页:课表、作业、成绩都按**教学周**组织,而"开学日期 + 每周七天"的
* 线性推算遇到法定节假日调休、周末补课、错峰开学就会**整体错位**(错一周全错)。
@@ -13,7 +13,6 @@
* `classId` 留空 = 全校默认;某个班有自己的配置时优先用它。
*/
import { useAuthStore } from '@/stores/auth';
import PromotionSettings from '@/components/PromotionSettings.vue';
import { computed, onMounted, ref } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import {
@@ -39,6 +38,13 @@ const weeks = ref<TermWeekDto[]>([]);
const termStartDate = ref('');
const maxTermWeeks = ref(getMaxTermWeeks());
const canEdit = computed(() =>
scope.value
? auth.canForClass('class:settings', scope.value) &&
(auth.isAdmin || classes.value.find((c) => c.id === scope.value)?.teacher?.id === auth.user?.id)
: auth.can('term:write'),
);
const scopeLabel = computed(() =>
scope.value ? (classes.value.find((item) => item.id === scope.value)?.name ?? scope.value) : '全校默认',
);
@@ -171,16 +177,20 @@ async function fetchHolidays(): Promise<void> {
}
onMounted(async () => {
classes.value = await classApi.list().catch(() => []);
classes.value = (await classApi.list().catch(() => [])).filter((row) =>
auth.canForClass('term:read', row.id),
);
if (!auth.can('term:write')) {
scope.value = classes.value.find((row) => auth.canForClass('class:settings', row.id))?.id ?? '';
}
await load();
});
</script>
<template>
<div class="page">
<PromotionSettings v-if="auth.can('promotion:manage')" />
<el-form-item v-if="!scope" label="全校学期总周数">
<el-input-number v-model="maxTermWeeks" :min="1" :max="60" />
<el-input-number v-model="maxTermWeeks" :min="1" :max="60" :disabled="!canEdit" />
</el-form-item>
<div class="page-header">
<div>
@@ -212,13 +222,7 @@ onMounted(async () => {
>
拉取放假安排
</el-button>
<el-button
v-if="auth.can('term:write')"
type="primary"
:loading="saving"
:disabled="!dirty"
@click="save"
>
<el-button v-if="canEdit" type="primary" :loading="saving" :disabled="!dirty" @click="save">
保存
</el-button>
</div>
@@ -240,12 +244,13 @@ onMounted(async () => {
<span class="label">学期开始日期</span>
<el-date-picker
v-model="termStartDate"
:disabled="!canEdit"
type="date"
value-format="YYYY-MM-DD"
placeholder="第 1 教学周的周一"
style="width: 200px"
/>
<el-button v-if="auth.can('term:write')" type="primary" plain :loading="saving" @click="autoGenerate">
<el-button v-if="canEdit" type="primary" plain :loading="saving" @click="autoGenerate">
按开学日期生成
</el-button>
<span class="hint">先生成一份标准学期,再把调休那几周改掉 —— 比一周周填快得多</span>
@@ -272,9 +277,7 @@ onMounted(async () => {
<template #header>
<div class="card-header">
<span>逐周日期区间({{ weeks.length }} 周)</span>
<el-button v-if="auth.can('term:write')" size="small" :icon="'Plus'" @click="addWeek">
添加一周
</el-button>
<el-button v-if="canEdit" size="small" :icon="'Plus'" @click="addWeek">添加一周</el-button>
</div>
</template>
@@ -283,6 +286,7 @@ onMounted(async () => {
<template #default="{ row }">
<el-input-number
v-model="row.weekNumber"
:disabled="!canEdit"
:min="1"
:max="60"
size="small"
@@ -296,6 +300,7 @@ onMounted(async () => {
el-date-picker 的 daterange 给的是一个数组,直接 v-model 会写到一个不存在的 range 上 -->
<el-date-picker
:model-value="row.startDate && row.endDate ? [row.startDate, row.endDate] : null"
:disabled="!canEdit"
type="daterange"
value-format="YYYY-MM-DD"
range-separator="→"
@@ -316,6 +321,7 @@ onMounted(async () => {
<template #default="{ row }">
<el-input
v-model="row.note"
:disabled="!canEdit"
size="small"
maxlength="64"
placeholder="例如:国庆调休、期中考试周"
@@ -324,9 +330,7 @@ onMounted(async () => {
</el-table-column>
<el-table-column label="操作" width="90">
<template #default="{ $index }">
<el-button v-if="auth.can('term:write')" link type="danger" @click="removeWeek($index)">
删除
</el-button>
<el-button v-if="canEdit" link type="danger" @click="removeWeek($index)">删除</el-button>
</template>
</el-table-column>
</el-table>