feat(authz): 重写权限模板分配与班级授权边界
拆分能力、分配范围和业务关系,固定管理员能力并收紧组织级写入口。 补齐内容、课表、设备会话和实时通道授权,兼容旧库分配及历史快照。 重写权限分配预览,按实际可操作范围展示维护入口,合并学生入口并独立学年升级页。 增加授权攻击矩阵及静态门禁,更新隔离回归与开发契约。 验证:授权矩阵 23 段通过,Node 24 下 Web 63/63,类型检查、lint 与文档严格构建通过。
This commit is contained in:
1 parent
682818dd4a
commit
66be3a63b1
115 files changed
+4463
-2747
No files matched your search
@@ -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");
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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(
|
||||
'备份包含课件、可见范围、推送和回执',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -1,379 +1,129 @@
|
||||
import type { PermissionKey as ContractPermissionKey } from '@classhelper/shared';
|
||||
import {
|
||||
canManageGrades,
|
||||
canManageRoster,
|
||||
canManageSchedule,
|
||||
canManageSubjectContent,
|
||||
canPublishNotification,
|
||||
resolveClassRole,
|
||||
type ClassRole,
|
||||
} from '@classhelper/shared';
|
||||
import { teacherScopeWhere } from './permission-scope.js';
|
||||
import type { Prisma } from '../generated/prisma/client.js';
|
||||
import type { PermissionKey, ClassRole } from '@classhelper/shared';
|
||||
import { resolveClassRole } from '@classhelper/shared';
|
||||
import { prisma } from './db.js';
|
||||
import { ApiError } from './http.js';
|
||||
import { assertPermission, currentRequestPermission } from './permissions.js';
|
||||
import { hasAllScope } from './permission-scope.js';
|
||||
import type { TokenPayload } from './jwt.js';
|
||||
|
||||
/**
|
||||
* 权限(RBAC)规则。
|
||||
*
|
||||
* | 角色 | 范围 | 能做什么 |
|
||||
* | ---- | ---- | -------- |
|
||||
* | ADMIN | 全局 | 班级/教师/学生名单/调班/归档/全部科目的作业与成绩 |
|
||||
* | HEAD 班主任 | 本班 | 本班学生名单、课表、本班汇总;**作业与成绩仍按科目收口** |
|
||||
* | SUBJECT 科任 | 班级 + 科目 | 只能发布/修改/删除**自己任教科目**的作业与成绩 |
|
||||
* | CLASS_DEVICE | 绑定班级 | ClassHelper 班级端:代本班读写个人数据、按开关查成绩明细 |
|
||||
* | 学生 | 无账号 | 仅作为 `Student` 名单记录,不登录、无权限 |
|
||||
*
|
||||
* 三条铁律(都是这次重构的落点):
|
||||
* 1. **任课关系的唯一来源是 `Course`**(班级 + 科目 + 教师),没有第二张关系表;
|
||||
* 2. **班主任不自动拥有本班所有科目的作业成绩编辑权** —— 只有同时是该科科任老师时才有;
|
||||
* 3. **所有权限都在后端判**,前端只做展示控制。
|
||||
*
|
||||
* 判定逻辑与前端共用 `@classhelper/shared/permissions`,避免前后端口径漂移。
|
||||
*/
|
||||
|
||||
export function isAdmin(user: TokenPayload): boolean {
|
||||
return user.role === 'ADMIN';
|
||||
}
|
||||
|
||||
export function isTeacher(user: TokenPayload): boolean {
|
||||
return user.role === 'TEACHER';
|
||||
}
|
||||
|
||||
/** 教师与管理员(staff):能以自己的身份操作班级内容 */
|
||||
export function isStaff(user: TokenPayload): boolean {
|
||||
return user.role === 'ADMIN' || user.role === 'TEACHER';
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否是 ClassHelper 班级端(教室机器上那台绑定到某个班的客户端)。
|
||||
* 它代全班读写个人数据(作业完成、通知已读),也能按开关查本班学生成绩明细。
|
||||
*/
|
||||
export function isClassDevice(user: TokenPayload): boolean {
|
||||
return user.role === 'CLASS_DEVICE';
|
||||
}
|
||||
|
||||
/** 班级端代录内容归属班主任账号,班级主体不能写入 User 外键。 */
|
||||
import {
|
||||
assertAdmin,
|
||||
assertPermission,
|
||||
assertClassRead,
|
||||
assertManage,
|
||||
classIdsFor,
|
||||
classWhere,
|
||||
classDeviceClassId,
|
||||
isHead,
|
||||
hasOrganizationalGrant,
|
||||
} from './authz/index.js';
|
||||
export { classDeviceClassId } from './authz/relations.js';
|
||||
export const isAdmin = (user: TokenPayload) => user.role === 'ADMIN';
|
||||
export const isTeacher = (user: TokenPayload) => user.role === 'TEACHER';
|
||||
export const isStaff = (user: TokenPayload) => user.role === 'ADMIN' || user.role === 'TEACHER';
|
||||
export const isClassDevice = (user: TokenPayload) => user.role === 'CLASS_DEVICE';
|
||||
export async function resolveContentCreator(user: TokenPayload, classId: string) {
|
||||
if (!isClassDevice(user)) return user.sub;
|
||||
const record = await prisma.class.findUnique({ where: { id: classId }, select: { teacherId: true } });
|
||||
if (!record?.teacherId) throw ApiError.conflict('请先为班级配置班主任,再在班级端发布内容');
|
||||
if (!record?.teacherId) throw ApiError.conflict('请先为班级配置班主任');
|
||||
return record.teacherId;
|
||||
}
|
||||
|
||||
/** ClassHelper 班级端绑定的班级 id;不是班级端会话时返回 null */
|
||||
export function classDeviceClassId(user: TokenPayload): string | null {
|
||||
return isClassDevice(user) ? (user.classId ?? user.sub) : null;
|
||||
export function requireClassDeviceClassId(user: TokenPayload): string {
|
||||
const id = classDeviceClassId(user);
|
||||
if (!id) throw ApiError.forbidden('当前会话未绑定班级');
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 班级访问的判定条件:班主任,**或在该班有任课关系**(`Course.teacherId`)。
|
||||
*
|
||||
* 这里就是「科任老师」的判定源 —— 以前查的是没有科目的 `ClassTeacher` 协作表,
|
||||
* 现在查 `Course`,于是"能不能进这个班"与"能不能改这一科的作业成绩"用的是同一份事实。
|
||||
*/
|
||||
/** 当前用户可访问的全部班级 id */
|
||||
export async function getAccessibleClassIds(
|
||||
user: TokenPayload,
|
||||
permission?: ContractPermissionKey,
|
||||
): Promise<string[]> {
|
||||
if (isAdmin(user)) {
|
||||
const classes = await prisma.class.findMany({ select: { id: true } });
|
||||
return classes.map((item) => item.id);
|
||||
}
|
||||
|
||||
if (isTeacher(user)) {
|
||||
const classes = await prisma.class.findMany({
|
||||
where: await teacherScopeWhere(user, permission),
|
||||
select: { id: true },
|
||||
});
|
||||
return classes.map((item) => item.id);
|
||||
}
|
||||
|
||||
const classId = classDeviceClassId(user);
|
||||
return classId ? [classId] : [];
|
||||
}
|
||||
|
||||
export const getAccessibleClassIds = classIdsFor;
|
||||
export async function canAccessClass(
|
||||
user: TokenPayload,
|
||||
classId: string,
|
||||
permission?: ContractPermissionKey,
|
||||
key: PermissionKey,
|
||||
): Promise<boolean> {
|
||||
if (isAdmin(user)) return true;
|
||||
if (isTeacher(user)) {
|
||||
const count = await prisma.class.count({
|
||||
where: { AND: [{ id: classId }, await teacherScopeWhere(user, permission)] },
|
||||
});
|
||||
return count > 0;
|
||||
}
|
||||
return classDeviceClassId(user) === classId;
|
||||
if (isClassDevice(user)) return classDeviceClassId(user) === classId;
|
||||
return !!(await prisma.class.count({ where: { AND: [{ id: classId }, await classWhere(user, key)] } }));
|
||||
}
|
||||
|
||||
/** 读权限断言 */
|
||||
/** 兼容窄封装,键必须由调用点显式提供。 */
|
||||
export async function assertClassAccess(
|
||||
user: TokenPayload,
|
||||
classId: string,
|
||||
permission?: ContractPermissionKey,
|
||||
key: PermissionKey,
|
||||
): Promise<void> {
|
||||
const exists = await prisma.class.findUnique({ where: { id: classId }, select: { id: true } });
|
||||
if (!exists) throw ApiError.notFound('班级不存在');
|
||||
if (!(await canAccessClass(user, classId, permission))) throw ApiError.forbidden('无权访问该班级的数据');
|
||||
await assertClassRead(user, key, classId, { allowClassDevice: true });
|
||||
}
|
||||
|
||||
/** 写权限断言:无账号的学生不在会话里,因此这里只放行 staff 与 ClassHelper 班级端 */
|
||||
export async function assertClassWritable(user: TokenPayload, classId: string): Promise<void> {
|
||||
if (!isStaff(user) && !isClassDevice(user)) throw ApiError.forbidden('当前身份没有管理权限');
|
||||
await assertClassAccess(user, classId);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 班级内角色 */
|
||||
|
||||
/** 解析当前用户在某班级里的角色(ADMIN / HEAD / SUBJECT / CLASS_DEVICE / NONE) */
|
||||
export async function resolveUserClassRole(user: TokenPayload, classId: string): Promise<ClassRole> {
|
||||
if (user.role === 'ADMIN') return 'ADMIN';
|
||||
if (user.role === 'CLASS_DEVICE') return (await canAccessClass(user, classId)) ? 'CLASS_DEVICE' : 'NONE';
|
||||
|
||||
const record = await prisma.class.findUnique({
|
||||
where: { id: classId },
|
||||
select: {
|
||||
teacherId: true,
|
||||
courses: { where: { teacherId: user.sub }, select: { id: true } },
|
||||
},
|
||||
});
|
||||
if (!record) return 'NONE';
|
||||
if (isAdmin(user)) return 'ADMIN';
|
||||
if (isClassDevice(user)) return classDeviceClassId(user) === classId ? 'CLASS_DEVICE' : 'NONE';
|
||||
return resolveClassRole({
|
||||
role: user.role,
|
||||
isHeadTeacher: record.teacherId === user.sub,
|
||||
teachesSubjects: record.courses.length > 0,
|
||||
isHeadTeacher: await isHead(user, classId),
|
||||
teachesSubjects: !!(await prisma.course.count({ where: { classId, teacherId: user.sub } })),
|
||||
});
|
||||
}
|
||||
|
||||
/** 班级本身(增删改)与人员分配:仅管理员 */
|
||||
export async function assertCanManageClasses(user: TokenPayload, classId?: string): Promise<void> {
|
||||
await assertPermission(user, 'class:manage');
|
||||
if (classId) await assertClassInScope(user, classId, 'class:manage');
|
||||
else if (!(await hasAllScope(user, 'class:manage'))) throw ApiError.forbidden('创建或删除班级需要全校范围');
|
||||
if (
|
||||
classId &&
|
||||
(await prisma.class.findUnique({ where: { id: classId }, select: { archivedYearId: true } }))
|
||||
?.archivedYearId
|
||||
)
|
||||
throw ApiError.conflict('该班级已归档,请先撤销归档');
|
||||
export async function assertCanManageClasses(user: TokenPayload, classId?: string) {
|
||||
assertAdmin(user);
|
||||
if (classId && (await prisma.class.findUnique({ where: { id: classId } }))?.archivedYearId)
|
||||
throw ApiError.conflict('该班级已归档');
|
||||
}
|
||||
|
||||
export async function assertGlobalPermission(user: TokenPayload, key: ContractPermissionKey) {
|
||||
export const assertCanAssignTeachers = assertCanManageClasses;
|
||||
export async function assertGlobalPermission(user: TokenPayload, key: PermissionKey) {
|
||||
assertAdmin(user);
|
||||
await assertPermission(user, key);
|
||||
if (!(await hasAllScope(user, key))) throw ApiError.forbidden('此操作影响全校,需要对应功能的全校范围');
|
||||
}
|
||||
|
||||
/** 班主任 / 各科科任老师的分配:仅管理员 */
|
||||
export async function assertCanAssignTeachers(user: TokenPayload, classId?: string): Promise<void> {
|
||||
await assertCanManageClasses(user, classId);
|
||||
export async function assertClassInScope(user: TokenPayload, classId: string, key: PermissionKey) {
|
||||
await assertClassRead(user, key, classId);
|
||||
}
|
||||
|
||||
export async function assertClassInScope(user: TokenPayload, classId: string, key: ContractPermissionKey) {
|
||||
await assertPermission(user, key);
|
||||
if (isAdmin(user)) return;
|
||||
const count = await prisma.class.count({
|
||||
where: { AND: [{ id: classId }, await teacherScopeWhere(user, key)] },
|
||||
});
|
||||
if (!count) throw ApiError.forbidden('目标班级不在此功能的授权范围内');
|
||||
}
|
||||
export async function assertClassesInScope(user: TokenPayload, ids: string[], key: ContractPermissionKey) {
|
||||
export async function assertClassesInScope(user: TokenPayload, ids: string[], key: PermissionKey) {
|
||||
for (const id of new Set(ids)) await assertClassInScope(user, id, key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 学生名单(增删学生 / 导入名单 / 调班):管理员,或**本班班主任**。
|
||||
*
|
||||
* @param classId 目标班级;不传表示"全局名单操作"(例如未分班的学生),此时仅管理员
|
||||
*/
|
||||
export async function assertCanManageRoster(user: TokenPayload, classId?: string): Promise<void> {
|
||||
await assertPermission(user, 'roster:manage');
|
||||
export async function assertCanManageRoster(
|
||||
user: TokenPayload,
|
||||
classId?: string,
|
||||
db: Prisma.TransactionClient = prisma,
|
||||
) {
|
||||
if (!classId) {
|
||||
if (!(await hasAllScope(user, 'roster:manage')))
|
||||
throw ApiError.forbidden('未分班学生操作需要全校名单范围');
|
||||
await assertPermission(user, 'roster:manage');
|
||||
if (!(await hasOrganizationalGrant(user, 'roster:manage')))
|
||||
throw ApiError.forbidden('未分班学生需要全校名单范围');
|
||||
return;
|
||||
}
|
||||
await assertClassInScope(user, classId, 'roster:manage');
|
||||
if (await hasAllScope(user, 'roster:manage')) return;
|
||||
const classRole = await resolveUserClassRole(user, classId);
|
||||
if (!canManageRoster(classRole)) {
|
||||
throw ApiError.forbidden(
|
||||
classRole === 'SUBJECT'
|
||||
? '科任老师不能管理学生名单,请联系班主任或管理员'
|
||||
: '只能管理自己担任班主任的班级的学生名单',
|
||||
);
|
||||
}
|
||||
await assertManage(user, 'roster:manage', classId, db);
|
||||
}
|
||||
|
||||
/** 课表管理(含时间配置导入):管理员或本班班主任 */
|
||||
export async function assertCanManageSchedule(user: TokenPayload, classId: string): Promise<void> {
|
||||
await assertClassAccess(user, classId);
|
||||
const classRole = await resolveUserClassRole(user, classId);
|
||||
if (!canManageSchedule(classRole)) {
|
||||
throw ApiError.forbidden(
|
||||
classRole === 'SUBJECT' || classRole === 'CLASS_DEVICE'
|
||||
? '科任老师与班级端不能管理课表,请联系班主任或管理员'
|
||||
: '无权管理该班级的课表',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** 导入需同时覆盖导入能力和对应写能力,范围不能从另一项能力借用。 */
|
||||
export async function assertCanImportSchedule(
|
||||
export async function assertCanManageSchedule(
|
||||
user: TokenPayload,
|
||||
classId: string,
|
||||
writeKey: ContractPermissionKey = 'schedule:write',
|
||||
): Promise<void> {
|
||||
await assertClassInScope(user, classId, 'import:schedule');
|
||||
await assertClassInScope(user, classId, writeKey);
|
||||
db: Prisma.TransactionClient = prisma,
|
||||
) {
|
||||
await assertManage(user, 'schedule:write', classId, db);
|
||||
}
|
||||
export async function assertCanImportSchedule(user: TokenPayload, classId: string) {
|
||||
await assertManage(user, 'import:schedule', classId);
|
||||
await assertCanManageSchedule(user, classId);
|
||||
}
|
||||
|
||||
/** 通知 / 叫人:管理员、班主任、科任老师、ClassHelper 班级端(面向全班,不分科目) */
|
||||
export async function assertCanPublishNotification(user: TokenPayload, classId: string): Promise<void> {
|
||||
await assertClassAccess(user, classId);
|
||||
const classRole = await resolveUserClassRole(user, classId);
|
||||
if (!canPublishNotification(classRole)) {
|
||||
throw ApiError.forbidden('当前账号没有发布通知或叫人的权限');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 成绩录入 / 修改 / 导入的**班级维度**准入:管理员,或本班班主任。
|
||||
*
|
||||
* @param classId 目标成绩所属班级;不传时按"全局"判定(仅管理员可用,例如空白模板下载)
|
||||
*
|
||||
* 这只是第一道门:具体某一条成绩能不能动,还要过 `assertCanManageSubjectContent` 的**科目**那道门。
|
||||
*/
|
||||
export async function assertCanManageGrades(user: TokenPayload, classId?: string): Promise<void> {
|
||||
if (!classId) {
|
||||
if (!isAdmin(user)) throw ApiError.forbidden('该操作需要指定班级,只有管理员可以全局执行');
|
||||
return;
|
||||
}
|
||||
|
||||
await assertClassAccess(user, classId);
|
||||
const classRole = await resolveUserClassRole(user, classId);
|
||||
if (canManageGrades(classRole)) return;
|
||||
|
||||
throw ApiError.forbidden(
|
||||
classRole === 'SUBJECT' || classRole === 'CLASS_DEVICE'
|
||||
? '科任老师与班级端不能代录全班成绩,请在自己的科目里录入'
|
||||
: '只能录入自己担任班主任的班级的成绩',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* **科目维度的写权限**(作业与成绩共用的那道细门)—— 本次重构的核心规则。
|
||||
*
|
||||
* | 情形 | 谁可以写 |
|
||||
* | ---- | -------- |
|
||||
* | 管理员 | 全部 |
|
||||
* | ClassHelper 班级端 | 仅当 `allowClassDevice`(作业允许、成绩不允许) |
|
||||
* | 挂了科目(courseId 有值) | **该科的任课老师**(`Course.teacherId`) |
|
||||
* | 没挂科目(courseId 为空) | 本班**班主任** |
|
||||
*
|
||||
* 于是:班主任若不教这一科,就改不了这一科的作业与成绩 —— 这正是需求
|
||||
* 「班主任不自动拥有所有科目作业成绩编辑权,只有同时是该科科任老师时才有该科权限」。
|
||||
*
|
||||
* @param courseId 目标内容挂在哪门课(科目)上;null/undefined 表示"不指定科目"
|
||||
* @param action 给用户看的动作名,例如「布置作业」「录入成绩」
|
||||
* @param options.allowClassDevice 是否允许教室机器代写(作业为 true,成绩为 false)
|
||||
*/
|
||||
export async function assertCanManageSubjectContent(
|
||||
user: TokenPayload,
|
||||
classId: string,
|
||||
courseId: string | null | undefined,
|
||||
action: string,
|
||||
options: { allowClassDevice?: boolean } = {},
|
||||
): Promise<void> {
|
||||
await assertClassAccess(user, classId);
|
||||
const classRole = await resolveUserClassRole(user, classId);
|
||||
const allowClassDevice = options.allowClassDevice === true;
|
||||
|
||||
// 管理员不受科目限制,提前返回省掉一次课程查询
|
||||
if (classRole === 'ADMIN') return;
|
||||
if (classRole === 'CLASS_DEVICE') {
|
||||
if (allowClassDevice) return;
|
||||
throw ApiError.forbidden(`ClassHelper 班级端不能${action},请由任课老师在 Web 端操作`);
|
||||
}
|
||||
|
||||
let isSubjectTeacherOfCourse = false;
|
||||
let courseName = '';
|
||||
if (courseId) {
|
||||
const course = await prisma.course.findUnique({
|
||||
where: { id: courseId },
|
||||
select: { classId: true, teacherId: true, name: true },
|
||||
});
|
||||
if (!course) throw ApiError.badRequest('课程不存在');
|
||||
if (course.classId !== classId) throw ApiError.badRequest('该课程不属于所选班级');
|
||||
courseName = course.name;
|
||||
isSubjectTeacherOfCourse = course.teacherId === user.sub;
|
||||
}
|
||||
|
||||
const allowed = canManageSubjectContent({
|
||||
classRole,
|
||||
hasSubject: Boolean(courseId),
|
||||
isSubjectTeacherOfCourse,
|
||||
allowClassDevice,
|
||||
});
|
||||
if (allowed) return;
|
||||
|
||||
throw ApiError.forbidden(
|
||||
courseId
|
||||
? `「${courseName}」不是你任教的科目,不能${action}该科目的内容`
|
||||
: `未指定科目的内容只有班主任或管理员可以${action}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** 学生的主班级(ClassHelper 班级端会话);未绑定班级时抛 403 */
|
||||
export function requireClassDeviceClassId(user: TokenPayload): string {
|
||||
const classId = classDeviceClassId(user);
|
||||
if (!classId) throw ApiError.forbidden('当前会话未绑定班级,请联系管理员');
|
||||
return classId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询范围解析:
|
||||
* - 传入 classId:校验后只查该班级
|
||||
* - 未传:教师只能查自己可访问的班级;管理员不加限制
|
||||
*/
|
||||
export type ClassScope = { mode: 'all' } | { mode: 'in'; classIds: string[] };
|
||||
|
||||
export async function resolveClassScope(
|
||||
user: TokenPayload,
|
||||
requestedClassId?: string,
|
||||
permission?: ContractPermissionKey,
|
||||
requestedClassId: string | undefined,
|
||||
key: PermissionKey,
|
||||
): Promise<ClassScope> {
|
||||
if (requestedClassId) {
|
||||
await assertClassAccess(user, requestedClassId, permission);
|
||||
await assertClassAccess(user, requestedClassId, key);
|
||||
return { mode: 'in', classIds: [requestedClassId] };
|
||||
}
|
||||
if (isAdmin(user)) return { mode: 'all' };
|
||||
const scopePermission = permission ?? currentRequestPermission(user);
|
||||
if (isTeacher(user) && scopePermission && (await hasAllScope(user, scopePermission)))
|
||||
return { mode: 'all' };
|
||||
|
||||
const classIds = await getAccessibleClassIds(user, permission);
|
||||
// 没有任何班级时不抛错,返回空集合让列表接口给出空数组,避免新教师首次登录报错
|
||||
return { mode: 'in', classIds };
|
||||
if (isClassDevice(user)) return { mode: 'in', classIds: [requireClassDeviceClassId(user)] };
|
||||
await assertPermission(user, key);
|
||||
if (Object.keys(await classWhere(user, key)).length === 0) return { mode: 'all' };
|
||||
// 其余范围显式枚举班级,避免空集合被下游当成全量。
|
||||
return { mode: 'in', classIds: await classIdsFor(user, key) };
|
||||
}
|
||||
|
||||
/** 把 ClassScope 转换成 Prisma 的 where 片段(子表:classId 字段) */
|
||||
export function classScopeWhere(scope: ClassScope): { classId?: { in: string[] } } {
|
||||
return scope.mode === 'all' ? {} : { classId: { in: scope.classIds } };
|
||||
}
|
||||
|
||||
/** Class 表自身的范围过滤:注意这里过滤的是主键 id,而不是子表的 classId */
|
||||
export function classScopeIdFilter(scope: ClassScope): { id?: { in: string[] } } {
|
||||
return scope.mode === 'all' ? {} : { id: { in: scope.classIds } };
|
||||
}
|
||||
|
||||
/** 单班级场景:班级端强制取自己绑定的班级,其他角色沿用请求参数 */
|
||||
export function resolveEffectiveClassId(user: TokenPayload, requestedClassId?: string): string | undefined {
|
||||
if (isClassDevice(user)) return requireClassDeviceClassId(user);
|
||||
if (isClassDevice(user)) {
|
||||
const id = requireClassDeviceClassId(user);
|
||||
if (requestedClassId && requestedClassId !== id) throw ApiError.forbidden('不能访问其他班级');
|
||||
return id;
|
||||
}
|
||||
return requestedClassId;
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { Prisma } from '../../generated/prisma/client.js';
|
||||
import { PERMISSION_LABELS, type PermissionKey } from '@classhelper/shared';
|
||||
import { prisma } from '../db.js';
|
||||
import { ApiError } from '../http.js';
|
||||
import type { TokenPayload } from '../jwt.js';
|
||||
import { loadAuthPrincipal } from './principal.js';
|
||||
import { assertClassInScope, hasOrganizationalGrant } from './scope.js';
|
||||
import { classDeviceClassId, isHead, isTeaching } from './relations.js';
|
||||
|
||||
export function assertAdmin(user: TokenPayload): void {
|
||||
if (user.role !== 'ADMIN' || user.classSession) throw ApiError.forbidden('此操作仅限管理员');
|
||||
}
|
||||
export async function assertPermission(user: TokenPayload, key: PermissionKey): Promise<void> {
|
||||
if (!(await loadAuthPrincipal(user)).permissions.has(key))
|
||||
throw ApiError.forbidden(`缺少「${PERMISSION_LABELS[key]}」权限`);
|
||||
}
|
||||
export async function assertAnyPermission(user: TokenPayload, keys: PermissionKey[]): Promise<void> {
|
||||
const auth = await loadAuthPrincipal(user);
|
||||
if (!keys.some((key) => auth.permissions.has(key))) throw ApiError.forbidden('缺少此操作所需权限');
|
||||
}
|
||||
export function assertClassDevice(user: TokenPayload, classId: string): void {
|
||||
if (classDeviceClassId(user) !== classId) throw ApiError.forbidden('仅限绑定本班的班级端会话');
|
||||
}
|
||||
export async function assertPermissionOrClassDevice(user: TokenPayload, key: PermissionKey): Promise<void> {
|
||||
if (user.role !== 'CLASS_DEVICE') await assertPermission(user, key);
|
||||
}
|
||||
export async function assertClassRead(
|
||||
user: TokenPayload,
|
||||
key: PermissionKey,
|
||||
classId: string,
|
||||
options: { allowClassDevice?: boolean; db?: Prisma.TransactionClient } = {},
|
||||
): Promise<void> {
|
||||
if (user.role === 'CLASS_DEVICE' && options.allowClassDevice) {
|
||||
assertClassDevice(user, classId);
|
||||
return;
|
||||
}
|
||||
await assertPermission(user, key);
|
||||
await assertClassInScope(user, key, classId, options.db);
|
||||
}
|
||||
export async function assertHeadOnly(
|
||||
user: TokenPayload,
|
||||
key: PermissionKey,
|
||||
classId: string,
|
||||
db: Prisma.TransactionClient = prisma,
|
||||
): Promise<void> {
|
||||
await assertClassRead(user, key, classId, { db });
|
||||
if (user.role !== 'ADMIN' && !(await isHead(user, classId, db)))
|
||||
throw ApiError.forbidden('此操作仅限本班班主任或管理员');
|
||||
}
|
||||
export async function assertManage(
|
||||
user: TokenPayload,
|
||||
key: PermissionKey,
|
||||
classId: string,
|
||||
db: Prisma.TransactionClient = prisma,
|
||||
): Promise<void> {
|
||||
await assertClassRead(user, key, classId, { db });
|
||||
if (
|
||||
user.role !== 'ADMIN' &&
|
||||
!(await isHead(user, classId, db)) &&
|
||||
!(await hasOrganizationalGrant(user, key, classId, db))
|
||||
)
|
||||
throw ApiError.forbidden('此操作需要班主任关系或该功能的年级、部、全校范围');
|
||||
}
|
||||
export async function assertContentWrite(
|
||||
user: TokenPayload,
|
||||
key: PermissionKey,
|
||||
classId: string,
|
||||
courseId: string | null | undefined,
|
||||
action: string,
|
||||
options: { allowClassDevice?: boolean; db?: Prisma.TransactionClient } = {},
|
||||
): Promise<void> {
|
||||
await assertClassRead(user, key, classId, options);
|
||||
if (courseId) {
|
||||
const course = await (options.db ?? prisma).course.findUnique({ where: { id: courseId } });
|
||||
if (!course || course.classId !== classId) throw ApiError.badRequest('课程不属于目标班级');
|
||||
if (
|
||||
user.role === 'ADMIN' ||
|
||||
(user.role === 'CLASS_DEVICE' && options.allowClassDevice) ||
|
||||
course.teacherId === user.sub
|
||||
)
|
||||
return;
|
||||
} else if (
|
||||
user.role === 'ADMIN' ||
|
||||
(user.role === 'CLASS_DEVICE' && options.allowClassDevice) ||
|
||||
(await isHead(user, classId, options.db))
|
||||
)
|
||||
return;
|
||||
throw ApiError.forbidden(`只能${action}本人任教科目或班主任班未指定科目的内容`);
|
||||
}
|
||||
export async function assertPublish(
|
||||
user: TokenPayload,
|
||||
key: PermissionKey,
|
||||
classId: string,
|
||||
options: { allowClassDevice?: boolean; db?: Prisma.TransactionClient } = {},
|
||||
): Promise<void> {
|
||||
await assertClassRead(user, key, classId, options);
|
||||
if (
|
||||
user.role === 'ADMIN' ||
|
||||
(user.role === 'CLASS_DEVICE' && options.allowClassDevice) ||
|
||||
(await isTeaching(user, classId, options.db))
|
||||
)
|
||||
return;
|
||||
throw ApiError.forbidden('只有班主任或本班任课老师可以发布');
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './principal.js';
|
||||
export * from './scope.js';
|
||||
export * from './relations.js';
|
||||
export * from './assert.js';
|
||||
@@ -0,0 +1,99 @@
|
||||
import {
|
||||
PERMISSION_KEYS,
|
||||
PERMISSION_META,
|
||||
parsePermissionScope,
|
||||
type PermissionKey,
|
||||
type PermissionScope,
|
||||
} from '@classhelper/shared';
|
||||
import { prisma } from '../db.js';
|
||||
import type { TokenPayload } from '../jwt.js';
|
||||
import { ensureDefaultGroups } from '../permissions.js';
|
||||
|
||||
export interface Grant {
|
||||
scope: PermissionScope;
|
||||
scopeValue: string;
|
||||
groupId: string;
|
||||
}
|
||||
export interface AuthPrincipal {
|
||||
permissions: ReadonlySet<PermissionKey>;
|
||||
grants: ReadonlyMap<PermissionKey, readonly Grant[]>;
|
||||
fromGroups: boolean;
|
||||
}
|
||||
export interface AssignmentRow {
|
||||
groupId: string;
|
||||
scope: string;
|
||||
scopeValue: string;
|
||||
group: { defaultRole: string | null; items: { key: string }[] };
|
||||
}
|
||||
let requests = new WeakMap<TokenPayload, Promise<AuthPrincipal>>();
|
||||
|
||||
export function invalidateAuthPrincipals() {
|
||||
requests = new WeakMap();
|
||||
}
|
||||
|
||||
/** 缓存仅附着于一个请求的主体;没有可变的“当前权限键”。 */
|
||||
export function loadAuthPrincipal(user: TokenPayload): Promise<AuthPrincipal> {
|
||||
let principal = requests.get(user);
|
||||
if (!principal) {
|
||||
principal = load(user);
|
||||
requests.set(user, principal);
|
||||
}
|
||||
return principal;
|
||||
}
|
||||
|
||||
async function load(user: TokenPayload): Promise<AuthPrincipal> {
|
||||
if (user.role !== 'TEACHER') return principalForAssignments(user, []);
|
||||
await ensureDefaultGroups();
|
||||
const assignments = await prisma.userPermissionGroup.findMany({
|
||||
where: { userId: user.sub },
|
||||
include: { group: { include: { items: true } } },
|
||||
});
|
||||
return principalForAssignments(user, assignments);
|
||||
}
|
||||
|
||||
/** 分配预览与运行时共用求值,不写数据库。 */
|
||||
export async function principalForAssignments(
|
||||
user: TokenPayload,
|
||||
assignments: AssignmentRow[],
|
||||
defaultGroup?: AssignmentRow['group'] & { id: string },
|
||||
): Promise<AuthPrincipal> {
|
||||
const permissions = new Set<PermissionKey>();
|
||||
const grants = new Map<PermissionKey, Grant[]>();
|
||||
if (user.role === 'CLASS_DEVICE' || user.classSession) return { permissions, grants, fromGroups: false };
|
||||
if (user.role === 'ADMIN') {
|
||||
for (const key of PERMISSION_KEYS) {
|
||||
permissions.add(key);
|
||||
grants.set(key, [{ scope: 'ALL', scopeValue: '', groupId: '' }]);
|
||||
}
|
||||
return { permissions, grants, fromGroups: false };
|
||||
}
|
||||
const fromGroups = assignments.length > 0;
|
||||
if (!fromGroups) {
|
||||
const group =
|
||||
defaultGroup ??
|
||||
(await prisma.permissionGroup.findUnique({
|
||||
where: { defaultRole: 'TEACHER' },
|
||||
include: { items: true },
|
||||
}));
|
||||
assignments = group ? [{ groupId: group.id, scope: 'TEACHING', scopeValue: '', group }] : [];
|
||||
}
|
||||
for (const assignment of assignments) {
|
||||
if (assignment.group.defaultRole === 'ADMIN') continue;
|
||||
for (const { key: raw } of assignment.group.items) {
|
||||
if (!(PERMISSION_KEYS as readonly string[]).includes(raw)) continue;
|
||||
const key = raw as PermissionKey;
|
||||
if (PERMISSION_META[key].kind === 'admin') continue;
|
||||
permissions.add(key);
|
||||
try {
|
||||
const parsed = parsePermissionScope(assignment.scope as PermissionScope, assignment.scopeValue);
|
||||
grants.set(key, [
|
||||
...(grants.get(key) ?? []),
|
||||
{ scope: parsed.scope, scopeValue: parsed.scopeValue, groupId: assignment.groupId },
|
||||
]);
|
||||
} catch {
|
||||
/* 非法旧分配不授予班级。 */
|
||||
}
|
||||
}
|
||||
}
|
||||
return { permissions, grants, fromGroups };
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { Prisma } from '../../generated/prisma/client.js';
|
||||
import { prisma } from '../db.js';
|
||||
import type { TokenPayload } from '../jwt.js';
|
||||
|
||||
export function classDeviceClassId(user: TokenPayload): string | null {
|
||||
return user.role === 'CLASS_DEVICE' && user.classSession ? (user.classId ?? user.sub) : null;
|
||||
}
|
||||
export async function isHead(
|
||||
user: TokenPayload,
|
||||
classId: string,
|
||||
db: Prisma.TransactionClient = prisma,
|
||||
): Promise<boolean> {
|
||||
return !!(await db.class.count({ where: { id: classId, teacherId: user.sub } }));
|
||||
}
|
||||
export async function isTeaching(
|
||||
user: TokenPayload,
|
||||
classId: string,
|
||||
db: Prisma.TransactionClient = prisma,
|
||||
): Promise<boolean> {
|
||||
return (
|
||||
(await isHead(user, classId, db)) ||
|
||||
!!(await db.course.count({ where: { classId, teacherId: user.sub } }))
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { PERMISSION_META, parsePermissionScope, type PermissionKey } from '@classhelper/shared';
|
||||
import type { Prisma } from '../../generated/prisma/client.js';
|
||||
import { prisma } from '../db.js';
|
||||
import { ApiError } from '../http.js';
|
||||
import type { TokenPayload } from '../jwt.js';
|
||||
import { loadAuthPrincipal, type AuthPrincipal } from './principal.js';
|
||||
|
||||
/** 返回 Class 表过滤条件;无键、无授权、组织只读键均不产生班级集合。 */
|
||||
export async function classWhere(
|
||||
user: TokenPayload,
|
||||
key: PermissionKey,
|
||||
principal?: AuthPrincipal,
|
||||
): Promise<Prisma.ClassWhereInput> {
|
||||
if (!key) return { id: { in: [] } };
|
||||
const auth = principal ?? (await loadAuthPrincipal(user));
|
||||
if (!auth.permissions.has(key)) return { id: { in: [] } };
|
||||
if (user.role === 'ADMIN') return {};
|
||||
if (PERMISSION_META[key].kind !== 'class') return { id: { in: [] } };
|
||||
const alternatives: Prisma.ClassWhereInput[] = [];
|
||||
for (const grant of auth.grants.get(key) ?? []) {
|
||||
if (grant.scope === 'ALL') return PERMISSION_META[key].relation === 'head' ? { teacherId: user.sub } : {};
|
||||
if (grant.scope === 'HEAD') alternatives.push({ teacherId: user.sub });
|
||||
else if (grant.scope === 'TEACHING')
|
||||
alternatives.push({ OR: [{ teacherId: user.sub }, { courses: { some: { teacherId: user.sub } } }] });
|
||||
else alternatives.push({ grade: { in: parsePermissionScope(grant.scope, grant.scopeValue).grades } });
|
||||
}
|
||||
// 班主任专属键仍须满足分配范围与关系的交集。
|
||||
if (PERMISSION_META[key].relation === 'head') {
|
||||
return { AND: [{ OR: alternatives }, { teacherId: user.sub }] };
|
||||
}
|
||||
return { OR: alternatives };
|
||||
}
|
||||
|
||||
export async function classIdsFor(
|
||||
user: TokenPayload,
|
||||
key: PermissionKey,
|
||||
principal?: AuthPrincipal,
|
||||
db: Prisma.TransactionClient = prisma,
|
||||
): Promise<string[]> {
|
||||
const rows = await db.class.findMany({
|
||||
where: await classWhere(user, key, principal),
|
||||
select: { id: true },
|
||||
});
|
||||
return rows.map((row) => row.id);
|
||||
}
|
||||
/** 名单、日课表、导入及考试配置的班级管理范围,供界面准确显示操作入口。 */
|
||||
export async function manageClassIdsFor(
|
||||
user: TokenPayload,
|
||||
key: PermissionKey,
|
||||
principal: AuthPrincipal,
|
||||
): Promise<string[]> {
|
||||
if (user.role === 'ADMIN') return classIdsFor(user, key, principal);
|
||||
const grants = (principal.grants.get(key) ?? []).filter((g) =>
|
||||
['GRADE', 'DIVISION', 'ALL'].includes(g.scope),
|
||||
);
|
||||
const broad = await classWhere(user, key, { ...principal, grants: new Map([[key, grants]]) });
|
||||
const rows = await prisma.class.findMany({
|
||||
where: { AND: [await classWhere(user, key, principal), { OR: [{ teacherId: user.sub }, broad] }] },
|
||||
select: { id: true },
|
||||
});
|
||||
return rows.map((row) => row.id);
|
||||
}
|
||||
/** 每项输入都携带权限键;关系一次读取,输出仍逐键与各自授权范围求交。 */
|
||||
export async function actionScopesFor(
|
||||
user: TokenPayload,
|
||||
scopes: ReadonlyArray<readonly [PermissionKey, readonly string[]]>,
|
||||
management: ReadonlyArray<readonly [PermissionKey, readonly string[]]>,
|
||||
): Promise<Partial<Record<PermissionKey, readonly string[]>>> {
|
||||
const result = { ...Object.fromEntries(scopes), ...Object.fromEntries(management) };
|
||||
const related = scopes.filter(([key]) =>
|
||||
['subject', 'publish', 'creator'].includes(PERMISSION_META[key].relation),
|
||||
);
|
||||
if (user.role === 'ADMIN' || !related.length) return result;
|
||||
const rows = await prisma.class.findMany({
|
||||
where: { id: { in: [...new Set(related.flatMap(([, ids]) => [...ids]))] } },
|
||||
select: {
|
||||
id: true,
|
||||
teacherId: true,
|
||||
courses: { where: { teacherId: user.sub }, select: { id: true }, take: 1 },
|
||||
notifications: { where: { createdBy: user.sub }, select: { id: true }, take: 1 },
|
||||
},
|
||||
});
|
||||
const relations = new Map(rows.map((row) => [row.id, row]));
|
||||
for (const [key, ids] of related) {
|
||||
const allowed = ids.filter((id) => {
|
||||
const row = relations.get(id);
|
||||
return (
|
||||
row &&
|
||||
(row.teacherId === user.sub ||
|
||||
(PERMISSION_META[key].relation === 'creator'
|
||||
? row.notifications.length > 0
|
||||
: row.courses.length > 0))
|
||||
);
|
||||
});
|
||||
// 成绩页还包含考试口径/查询开关,显式组织范围可维护这些配置。
|
||||
result[key] =
|
||||
key === 'grade:write'
|
||||
? [...new Set([...allowed, ...(management.find(([k]) => k === key)?.[1] ?? [])])]
|
||||
: allowed;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
export async function assertClassInScope(
|
||||
user: TokenPayload,
|
||||
key: PermissionKey,
|
||||
classId: string,
|
||||
db: Prisma.TransactionClient = prisma,
|
||||
): Promise<void> {
|
||||
if (!(await db.class.count({ where: { AND: [{ id: classId }, await classWhere(user, key)] } })))
|
||||
throw ApiError.forbidden('目标班级不在此功能的授权范围内');
|
||||
}
|
||||
/** 管理动作只有显式年级/部/全校分配可越过班主任关系。 */
|
||||
export async function hasOrganizationalGrant(
|
||||
user: TokenPayload,
|
||||
key: PermissionKey,
|
||||
classId?: string,
|
||||
db: Prisma.TransactionClient = prisma,
|
||||
): Promise<boolean> {
|
||||
if (user.role === 'ADMIN') return true;
|
||||
const auth = await loadAuthPrincipal(user);
|
||||
const grants = (auth.grants.get(key) ?? []).filter((g) => ['GRADE', 'DIVISION', 'ALL'].includes(g.scope));
|
||||
if (!classId) return grants.some((g) => g.scope === 'ALL');
|
||||
return (await classIdsFor(user, key, { ...auth, grants: new Map([[key, grants]]) }, db)).includes(classId);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { UserRole } from '@classhelper/shared';
|
||||
import { prisma } from '../db.js';
|
||||
import { ApiError } from '../http.js';
|
||||
import type { TokenPayload } from '../jwt.js';
|
||||
export async function loadSessionUser(payload: TokenPayload): Promise<TokenPayload> {
|
||||
if (payload.classSession && payload.role === 'CLASS_DEVICE') {
|
||||
const cls = await prisma.class.findUnique({ where: { id: payload.classId ?? payload.sub } });
|
||||
if (!cls || cls.archivedYearId) throw ApiError.unauthorized('班级不存在或已归档');
|
||||
return {
|
||||
sub: cls.id,
|
||||
username: cls.code,
|
||||
name: cls.name,
|
||||
role: 'CLASS_DEVICE',
|
||||
classId: cls.id,
|
||||
classSession: true,
|
||||
classCode: cls.code,
|
||||
};
|
||||
}
|
||||
if (payload.role === 'CLASS_DEVICE') throw ApiError.unauthorized('无效班级会话');
|
||||
const row = await prisma.user.findUnique({ where: { id: payload.sub } });
|
||||
if (!row || !['ADMIN', 'TEACHER'].includes(row.role)) throw ApiError.unauthorized('账号不存在');
|
||||
return { sub: row.id, username: row.username, name: row.name, role: row.role as UserRole, classId: null };
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import { prisma } from './db.js';
|
||||
import type { TokenPayload } from './jwt.js';
|
||||
import type { Prisma } from '../generated/prisma/client.js';
|
||||
import { parsePermissionScope, type PermissionScope, type PermissionKey } from '@classhelper/shared';
|
||||
import { currentRequestPermission } from './permissions.js';
|
||||
|
||||
/** 范围与功能键正交;显式分组覆盖角色默认,不在范围内一律不放行。 */
|
||||
export async function teacherScopeWhere(
|
||||
user: TokenPayload,
|
||||
permission?: PermissionKey,
|
||||
): Promise<Prisma.ClassWhereInput> {
|
||||
permission ??= currentRequestPermission(user);
|
||||
const assignments = await prisma.userPermissionGroup.findMany({
|
||||
where: { userId: user.sub },
|
||||
include: { group: { include: { items: true } } },
|
||||
});
|
||||
const own = { OR: [{ teacherId: user.sub }, { courses: { some: { teacherId: user.sub } } }] };
|
||||
const groups = assignments.length
|
||||
? assignments.map(({ group }) => group)
|
||||
: await prisma.permissionGroup.findMany({ where: { defaultRole: user.role }, include: { items: true } });
|
||||
const alternatives: Prisma.ClassWhereInput[] = [];
|
||||
for (const group of groups) {
|
||||
if (permission && !group.items.some((item) => item.key === permission)) continue;
|
||||
try {
|
||||
const parsed = parsePermissionScope(group.scope as PermissionScope, group.scopeValue);
|
||||
if (parsed.scope === 'ALL') return {};
|
||||
if (parsed.scope === 'SELF_CLASS')
|
||||
alternatives.push(parsed.scopeValue === 'HEAD' ? { teacherId: user.sub } : own);
|
||||
else alternatives.push({ grade: { in: parsed.grades } });
|
||||
} catch {
|
||||
/* 旧岗位组未配置或范围非法时不授予任何班级。 */
|
||||
}
|
||||
}
|
||||
return { OR: alternatives };
|
||||
}
|
||||
|
||||
export async function hasAllScope(user: TokenPayload, permission: PermissionKey) {
|
||||
if (user.role === 'ADMIN') return true;
|
||||
const links = await prisma.userPermissionGroup.findMany({
|
||||
where: { userId: user.sub },
|
||||
select: { groupId: true },
|
||||
});
|
||||
return (
|
||||
(await prisma.permissionGroup.count({
|
||||
where: {
|
||||
...(links.length ? { id: { in: links.map((link) => link.groupId) } } : { defaultRole: user.role }),
|
||||
scope: 'ALL',
|
||||
items: { some: { key: permission } },
|
||||
},
|
||||
})) > 0
|
||||
);
|
||||
}
|
||||
@@ -1,94 +1,11 @@
|
||||
import {
|
||||
BUILTIN_TEACHER_GROUPS,
|
||||
PERMISSION_KEYS,
|
||||
PERMISSION_LABELS,
|
||||
ROLE_DEFAULT_PERMISSIONS,
|
||||
defaultPermissionsForRole,
|
||||
type PermissionKey,
|
||||
} from '@classhelper/shared';
|
||||
import { prisma } from './db.js';
|
||||
import type { TokenPayload } from './jwt.js';
|
||||
import { ApiError } from './http.js';
|
||||
|
||||
// 仅复用当前请求对象上的权限,绝不跨请求缓存。
|
||||
const requestPermissions = new WeakMap<TokenPayload, ReadonlySet<PermissionKey>>();
|
||||
const requestPermission = new WeakMap<TokenPayload, PermissionKey>();
|
||||
export function bindRequestPermission(user: TokenPayload, key: PermissionKey) {
|
||||
requestPermission.set(user, key);
|
||||
}
|
||||
export function currentRequestPermission(user: TokenPayload) {
|
||||
return requestPermission.get(user);
|
||||
}
|
||||
export function bindRequestPermissions(user: TokenPayload, permissions: ReadonlySet<PermissionKey>) {
|
||||
requestPermissions.set(user, permissions);
|
||||
}
|
||||
export async function hasEffectivePermission(user: TokenPayload, key: PermissionKey) {
|
||||
return (requestPermissions.get(user) ?? (await loadEffectivePermissions(user))).has(key);
|
||||
}
|
||||
export async function assertPermission(user: TokenPayload, key: PermissionKey): Promise<void> {
|
||||
if (!(await hasEffectivePermission(user, key)))
|
||||
throw ApiError.forbidden(`缺少「${PERMISSION_LABELS[key]}」权限`);
|
||||
bindRequestPermission(user, key);
|
||||
}
|
||||
export async function assertAnyPermission(user: TokenPayload, keys: PermissionKey[]): Promise<void> {
|
||||
const permissions = requestPermissions.get(user) ?? (await loadEffectivePermissions(user));
|
||||
if (!keys.some((key) => permissions.has(key))) throw ApiError.forbidden('缺少该操作所需权限');
|
||||
const matched = keys.find((key) => permissions.has(key));
|
||||
if (matched) bindRequestPermission(user, matched);
|
||||
}
|
||||
export async function assertStaffPermission(user: TokenPayload, key: PermissionKey): Promise<void> {
|
||||
if (user.role !== 'CLASS_DEVICE') await assertPermission(user, key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生效权限集合的**唯一加载入口**(权限组功能的全部运行时逻辑都收在这里)。
|
||||
*
|
||||
* 语义(与 shared 的 `PERMISSION_DEFAULT_ROLES` 一一对应):
|
||||
* - `ADMIN`:按权限组计算,另外**永远**保留 {@link ADMIN_UNREMOVABLE_PERMISSIONS},避免管理员
|
||||
* 把自己(或最后一个管理员)锁在权限页之外;
|
||||
* - `CLASS_DEVICE`(ClassHelper 班级端):**空集,且不受权限组影响** —— 班级端的会话主体是
|
||||
* 班级而非账号,它的能力由班级维度断言(`canManageSubjectContent(..., { allowClassDevice })`
|
||||
* 等)决定,给某个班级端"分配权限组"没有意义;
|
||||
* - 其他账号:**有分组**时取这些组的键并集(因此可以比默认更少 ⇒ 也能用来收回权限);
|
||||
* **没有任何分组**时使用数据库中可编辑的角色默认组(逐条镜像权限组上线前各路由的 `requireRole`)。
|
||||
*
|
||||
* 每次请求实时查询(不缓存):权限改动**立即生效**,代价是每请求多一条 `findMany`
|
||||
* (账号规模很小,这个代价换"改了立刻生效"是值的)。
|
||||
*/
|
||||
export async function loadEffectivePermissions(user: TokenPayload): Promise<Set<PermissionKey>> {
|
||||
const permissions = new Set<PermissionKey>();
|
||||
|
||||
if (user.classSession || user.role === 'CLASS_DEVICE') return permissions;
|
||||
|
||||
await ensureDefaultGroups();
|
||||
|
||||
const assignments = await prisma.userPermissionGroup.findMany({
|
||||
where: { userId: user.sub },
|
||||
select: { group: { select: { items: { select: { key: true } } } } },
|
||||
});
|
||||
|
||||
if (assignments.length === 0) {
|
||||
const group = await prisma.permissionGroup.findUnique({
|
||||
where: { defaultRole: user.role },
|
||||
include: { items: true },
|
||||
});
|
||||
for (const key of group ? group.items.map((item) => item.key) : defaultPermissionsForRole(user.role)) {
|
||||
if ((PERMISSION_KEYS as readonly string[]).includes(key)) permissions.add(key as PermissionKey);
|
||||
}
|
||||
if (user.role === 'ADMIN') for (const key of ADMIN_UNREMOVABLE_PERMISSIONS) permissions.add(key);
|
||||
return permissions;
|
||||
}
|
||||
|
||||
const known = new Set<string>(PERMISSION_KEYS);
|
||||
for (const assignment of assignments) {
|
||||
for (const item of assignment.group.items) {
|
||||
// 库里可能留着已下线的键(旧版本写入的),这里静默忽略,避免整账号权限异常
|
||||
if (known.has(item.key)) permissions.add(item.key as PermissionKey);
|
||||
}
|
||||
}
|
||||
if (user.role === 'ADMIN') for (const key of ADMIN_UNREMOVABLE_PERMISSIONS) permissions.add(key);
|
||||
return permissions;
|
||||
}
|
||||
|
||||
/** 首次升级创建默认组;后续启动保留管理员的编辑结果(包括空权限组)。 */
|
||||
let defaultGroupsReady: Promise<void> | null = null;
|
||||
@@ -114,10 +31,10 @@ async function createDefaultGroups(): Promise<void> {
|
||||
},
|
||||
});
|
||||
}
|
||||
for (const role of ['ADMIN', 'TEACHER'] as const) {
|
||||
for (const role of ['TEACHER'] as const) {
|
||||
if (await prisma.permissionGroup.findUnique({ where: { defaultRole: role }, select: { id: true } }))
|
||||
continue;
|
||||
const baseName = `${role === 'ADMIN' ? '管理员' : '教师'}默认权限`;
|
||||
const baseName = '教师默认权限';
|
||||
let name = baseName;
|
||||
let suffix = 1;
|
||||
// 旧库可能已有同名自定义组,保留它并为角色默认组选一个不冲突的名称。
|
||||
@@ -132,18 +49,22 @@ async function createDefaultGroups(): Promise<void> {
|
||||
defaultRole: role,
|
||||
name,
|
||||
builtin: true,
|
||||
scope: role === 'ADMIN' ? 'ALL' : 'SELF_CLASS',
|
||||
description: '未分配其他权限组时自动生效;可调整权限。管理员始终保留权限与系统管理入口。',
|
||||
scope: 'TEACHING',
|
||||
description: '未显式分配时使用此模板与固定任教班级范围。',
|
||||
items: { create: ROLE_DEFAULT_PERMISSIONS[role].map((key) => ({ key })) },
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** 管理员不可被剥夺的权限键(防锁死) */
|
||||
export const ADMIN_UNREMOVABLE_PERMISSIONS: readonly PermissionKey[] = ['permission:manage', 'system:manage'];
|
||||
|
||||
/** 把权限键翻译成中文提示(403 文案与权限页共用) */
|
||||
export function permissionLabel(key: PermissionKey): string {
|
||||
return PERMISSION_LABELS[key];
|
||||
}
|
||||
|
||||
export { loadAuthPrincipal } from './authz/principal.js';
|
||||
export { assertPermission, assertAnyPermission, assertPermissionOrClassDevice } from './authz/assert.js';
|
||||
import { loadAuthPrincipal } from './authz/principal.js';
|
||||
export async function hasEffectivePermission(user: TokenPayload, key: PermissionKey) {
|
||||
return (await loadAuthPrincipal(user)).permissions.has(key);
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
import { refreshClassSubscriptions } from '../realtime/bus.js';
|
||||
import { invalidateAuthPrincipals } from './authz/principal.js';
|
||||
import { migratePermissionScope } from '@classhelper/shared';
|
||||
import type { PrismaClient } from '../generated/prisma/client.js';
|
||||
import type { DatabaseProvider } from './db.js';
|
||||
|
||||
@@ -183,6 +186,24 @@ export async function restoreSnapshot(
|
||||
}
|
||||
for (const table of SNAPSHOT_TABLES) {
|
||||
const rows = (snapshot.data[table] ?? []).map((row) => {
|
||||
if (table === 'PermissionGroup') {
|
||||
const mapped = migratePermissionScope(
|
||||
String(row.scope ?? 'TEACHING'),
|
||||
String(row.scopeValue ?? ''),
|
||||
);
|
||||
return {
|
||||
...row,
|
||||
...mapped,
|
||||
...(row.defaultRole === 'TEACHER' ? { scope: 'TEACHING', scopeValue: '' } : {}),
|
||||
};
|
||||
}
|
||||
if (table === 'UserPermissionGroup' && row.scope === undefined) {
|
||||
const group = snapshot.data.PermissionGroup.find((g) => g.id === row.groupId);
|
||||
return {
|
||||
...row,
|
||||
...migratePermissionScope(String(group?.scope ?? 'TEACHING'), String(group?.scopeValue ?? '')),
|
||||
};
|
||||
}
|
||||
if (table === 'StudentClassTransfer') {
|
||||
const { mode, ...rest } = row;
|
||||
return {
|
||||
@@ -209,6 +230,8 @@ export async function restoreSnapshot(
|
||||
} finally {
|
||||
await setForeignKeys(prisma, provider, true).catch(() => undefined);
|
||||
}
|
||||
invalidateAuthPrincipals();
|
||||
refreshClassSubscriptions();
|
||||
return { counts };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Prisma } from '../generated/prisma/client.js';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { currentAcademicYearStart, SOCKET_EVENTS, type StudentLifecycleKind } from '@classhelper/shared';
|
||||
import { prisma } from './db.js';
|
||||
@@ -31,17 +32,20 @@ export async function changeStudentLifecycle(
|
||||
status?: 'active' | 'inactive';
|
||||
};
|
||||
},
|
||||
transaction?: Prisma.TransactionClient,
|
||||
) {
|
||||
const batchId = randomUUID();
|
||||
const ids = [...new Set(input.studentIds)];
|
||||
const existing = ids.length ? await prisma.student.findMany({ where: { id: { in: ids } } }) : [];
|
||||
const existing = ids.length
|
||||
? await (transaction ?? prisma).student.findMany({ where: { id: { in: ids } } })
|
||||
: [];
|
||||
if (existing.length !== ids.length) throw ApiError.notFound('部分学生不存在,请刷新后重试');
|
||||
for (const cls of new Set(existing.map((student) => student.classId)))
|
||||
await assertCanManageRoster(user, cls ?? undefined);
|
||||
if (input.toClassId) await assertCanManageRoster(user, input.toClassId);
|
||||
else if (!existing.length) await assertCanManageRoster(user);
|
||||
await assertCanManageRoster(user, cls ?? undefined, transaction);
|
||||
if (input.toClassId) await assertCanManageRoster(user, input.toClassId, transaction);
|
||||
else if (!existing.length) await assertCanManageRoster(user, undefined, transaction);
|
||||
const changedClasses: (string | null)[] = [];
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
const work = async (tx: Prisma.TransactionClient) => {
|
||||
const target = input.toClassId ? await tx.class.findUnique({ where: { id: input.toClassId } }) : null;
|
||||
if (input.toClassId && !target) throw ApiError.badRequest('目标班级不存在');
|
||||
if (target?.archivedYearId) throw ApiError.conflict('目标班级已归档,不能调入');
|
||||
@@ -160,7 +164,8 @@ export async function changeStudentLifecycle(
|
||||
transfers: records.map(toStudentTransferDto),
|
||||
studentId: students[0].id,
|
||||
};
|
||||
});
|
||||
notifyStudentClasses(changedClasses);
|
||||
};
|
||||
const result = transaction ? await work(transaction) : await prisma.$transaction(work);
|
||||
if (!transaction) notifyStudentClasses(changedClasses);
|
||||
return result;
|
||||
}
|
||||
@@ -1,13 +1,9 @@
|
||||
import type { Request, RequestHandler } from 'express';
|
||||
import type { PermissionKey, UserRole } from '@classhelper/shared';
|
||||
import { PERMISSION_LABELS } from '@classhelper/shared';
|
||||
import { prisma } from '../lib/db.js';
|
||||
import { ApiError } from '../lib/http.js';
|
||||
import {
|
||||
bindRequestPermission,
|
||||
bindRequestPermissions,
|
||||
loadEffectivePermissions,
|
||||
} from '../lib/permissions.js';
|
||||
import { loadAuthPrincipal } from '../lib/authz/index.js';
|
||||
import { loadSessionUser } from '../lib/authz/session.js';
|
||||
import { extractBearerToken, verifyToken, type TokenPayload } from '../lib/jwt.js';
|
||||
|
||||
/**
|
||||
@@ -25,44 +21,8 @@ export function authenticate(): RequestHandler {
|
||||
|
||||
const payload = verifyToken(token);
|
||||
|
||||
// ClassHelper 班级端:主体是**班级**而不是某个账号,回查 Class 表
|
||||
if (payload.classSession) {
|
||||
const record = await prisma.class.findUnique({
|
||||
where: { id: payload.classId ?? payload.sub },
|
||||
select: { id: true, name: true, code: true },
|
||||
});
|
||||
if (!record) throw ApiError.unauthorized('班级不存在或已被删除');
|
||||
|
||||
const user: TokenPayload = {
|
||||
sub: record.id,
|
||||
username: record.code,
|
||||
name: record.name,
|
||||
role: 'CLASS_DEVICE',
|
||||
classId: record.id,
|
||||
classSession: true,
|
||||
classCode: record.code,
|
||||
};
|
||||
req.auth = { token, user, permissions: await loadEffectivePermissions(user) };
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// 账号(教师 / 管理员):`User` 是纯账号表,学生不在里面
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: payload.sub },
|
||||
select: { id: true, username: true, name: true, role: true },
|
||||
});
|
||||
if (!user) throw ApiError.unauthorized('账号不存在或已被删除');
|
||||
|
||||
const authUser: TokenPayload = {
|
||||
sub: user.id,
|
||||
username: user.username,
|
||||
name: user.name,
|
||||
role: user.role as UserRole,
|
||||
classId: null,
|
||||
};
|
||||
req.auth = { token, user: authUser, permissions: await loadEffectivePermissions(authUser) };
|
||||
bindRequestPermissions(authUser, req.auth.permissions!);
|
||||
const user = await loadSessionUser(payload);
|
||||
req.auth = { token, user, permissions: (await loadAuthPrincipal(user)).permissions };
|
||||
next();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -89,7 +49,7 @@ export function requireRole(...roles: UserRole[]): RequestHandler {
|
||||
/**
|
||||
* 权限守卫:要求当前登录主体拥有**全部**列出的权限键。
|
||||
*
|
||||
* 权限集合来自 {@link loadEffectivePermissions}(权限组并集,或角色默认模板)。
|
||||
* 权限集合来自 {@link loadAuthPrincipal}(管理员全集,或教师模板与分配)。
|
||||
* 提示文案用中文权限名,用户看到的是"缺少「导入成绩」权限"而不是一堆键名。
|
||||
*/
|
||||
export function requirePermission(...keys: PermissionKey[]): RequestHandler {
|
||||
@@ -105,13 +65,12 @@ export function requirePermission(...keys: PermissionKey[]): RequestHandler {
|
||||
next(ApiError.forbidden(`当前账号缺少${labels}权限,请联系管理员在「权限组」里分配`));
|
||||
return;
|
||||
}
|
||||
if (keys[0]) bindRequestPermission(req.auth.user, keys[0]);
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
/** 班级端按班级范围开放读取;教师/管理员仍按可编辑的功能权限校验。 */
|
||||
export function requireStaffPermission(...keys: PermissionKey[]): RequestHandler {
|
||||
export function requirePermissionOrClassDevice(...keys: PermissionKey[]): RequestHandler {
|
||||
const guard = requirePermission(...keys);
|
||||
return (req, res, next) => {
|
||||
if (req.auth?.user.role === 'CLASS_DEVICE') return next();
|
||||
@@ -139,8 +98,6 @@ export function requireAnyPermission(...keys: PermissionKey[]): RequestHandler {
|
||||
);
|
||||
return;
|
||||
}
|
||||
const matched = keys.find((key) => permissions.has(key));
|
||||
if (matched) bindRequestPermission(req.auth.user, matched);
|
||||
next();
|
||||
};
|
||||
}
|
||||
@@ -161,3 +118,5 @@ export function getAuthPermissions(req: Request): ReadonlySet<PermissionKey> {
|
||||
export function hasAuthPermission(req: Request, key: PermissionKey): boolean {
|
||||
return (req.auth?.permissions ?? new Set<PermissionKey>()).has(key);
|
||||
}
|
||||
|
||||
export const requireAdmin = (): RequestHandler => requireRole('ADMIN');
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Request, RequestHandler } from 'express';
|
||||
import type { ZodType } from 'zod';
|
||||
import { ZodError, type ZodType } from 'zod';
|
||||
import { ApiError } from '../lib/http.js';
|
||||
|
||||
interface ValidationTargets<B, Q, P> {
|
||||
body?: ZodType<B>;
|
||||
@@ -14,6 +15,7 @@ interface ValidationTargets<B, Q, P> {
|
||||
*/
|
||||
export function validate<B = unknown, Q = unknown, P = unknown>(
|
||||
targets: ValidationTargets<B, Q, P>,
|
||||
errorStatus: 400 | 422 = 422,
|
||||
): RequestHandler {
|
||||
return (req, _res, next) => {
|
||||
try {
|
||||
@@ -22,11 +24,21 @@ export function validate<B = unknown, Q = unknown, P = unknown>(
|
||||
if (targets.params) req.validatedParams = targets.params.parse(req.params) as unknown;
|
||||
next();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
next(
|
||||
errorStatus === 400 && error instanceof ZodError
|
||||
? ApiError.badRequest('请求参数不合法', error.issues)
|
||||
: error,
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function validateBadRequest<B = unknown, Q = unknown, P = unknown>(
|
||||
targets: ValidationTargets<B, Q, P>,
|
||||
): RequestHandler {
|
||||
return validate(targets, 400);
|
||||
}
|
||||
|
||||
/** 读取已校验的 query(类型由调用方用 zod schema 保证) */
|
||||
/** 取出校验后的请求体(与 validatedQuery/validatedParams 同一套约定) */
|
||||
export function validatedBody<T>(req: Request): T {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { PermissionKey } from '@classhelper/shared';
|
||||
import { assertAdmin, assertPermission, classIdsFor } from '../../lib/authz/index.js';
|
||||
import { activeStudentWhere } from '../../lib/students.js';
|
||||
import {
|
||||
dayKeyLocal,
|
||||
@@ -84,6 +86,7 @@ export async function updateAiSettings(
|
||||
user: TokenPayload,
|
||||
input: UpdateAiSettingsInput,
|
||||
): Promise<AiSettingsDto> {
|
||||
assertAdmin(user);
|
||||
if (input.enabled !== undefined) await setSetting(SETTING_KEYS.aiEnabled, input.enabled ? 'true' : 'false');
|
||||
if (input.provider !== undefined) await setSetting(SETTING_KEYS.aiProvider, input.provider);
|
||||
if (input.baseUrl !== undefined) await setSetting(SETTING_KEYS.aiBaseUrl, input.baseUrl.trim());
|
||||
@@ -105,17 +108,33 @@ interface AssistantContext {
|
||||
}
|
||||
|
||||
async function resolveContext(user: TokenPayload, classId?: string): Promise<AssistantContext> {
|
||||
if (classId) await assertClassAccess(user, classId);
|
||||
const classIds = await getAccessibleClassIds(user);
|
||||
if (classId) await assertClassAccess(user, classId, 'ai:use');
|
||||
await assertPermission(user, 'ai:use');
|
||||
const candidates = new Set(
|
||||
(
|
||||
await Promise.all(
|
||||
['notification:publish', 'homework:write', 'call:create'].map((key) =>
|
||||
classIdsFor(user, key as PermissionKey),
|
||||
),
|
||||
)
|
||||
).flat(),
|
||||
);
|
||||
const classIds = (await getAccessibleClassIds(user, 'ai:use')).filter((id) => candidates.has(id));
|
||||
const classes = await prisma.class.findMany({
|
||||
where: { id: { in: classIds } },
|
||||
where: {
|
||||
id: { in: classIds },
|
||||
...(user.role !== 'ADMIN'
|
||||
? { OR: [{ teacherId: user.sub }, { courses: { some: { teacherId: user.sub } } }] }
|
||||
: {}),
|
||||
},
|
||||
select: { id: true, name: true },
|
||||
orderBy: { name: 'asc' },
|
||||
take: 80,
|
||||
});
|
||||
const courses = classId
|
||||
? await prisma.course.findMany({ where: { classId }, select: { id: true, name: true } })
|
||||
: [];
|
||||
const courses =
|
||||
classId && classIds.includes(classId)
|
||||
? await prisma.course.findMany({ where: { classId }, select: { id: true, name: true } })
|
||||
: [];
|
||||
return { classes, courses };
|
||||
}
|
||||
|
||||
@@ -345,7 +364,7 @@ export async function interpret(user: TokenPayload, input: AiInterpretInput): Pr
|
||||
}
|
||||
|
||||
if (typeof params.classId === 'string') {
|
||||
await assertClassAccess(user, params.classId);
|
||||
await assertClassAccess(user, params.classId, 'ai:use');
|
||||
if (typeof params.courseId === 'string') {
|
||||
const course = await prisma.course.findFirst({
|
||||
where: { id: params.courseId, classId: params.classId },
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { requireAdmin } from '../../middleware/auth.js';
|
||||
import { restoreStudentSchema } from '../students/students.schemas.js';
|
||||
import { listTransferred, restoreTransferred } from './transferred.service.js';
|
||||
import { Router } from 'express';
|
||||
@@ -15,7 +16,7 @@ import {
|
||||
import * as archiveService from './archives.service.js';
|
||||
|
||||
const router = Router();
|
||||
router.use(authenticate());
|
||||
router.use(authenticate(), requireAdmin());
|
||||
router.get('/transferred', requirePermission('archive:manage'), async (req, res) =>
|
||||
sendOk(res, await listTransferred(getAuthUser(req))),
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Router } from 'express';
|
||||
import { sendCreated } from '../../lib/http.js';
|
||||
import { authenticate, getAuthUser, requireStaffPermission } from '../../middleware/auth.js';
|
||||
import { authenticate, getAuthUser, requirePermissionOrClassDevice } from '../../middleware/auth.js';
|
||||
import { validate } from '../../middleware/validate.js';
|
||||
import { defineModule } from '../module.types.js';
|
||||
import { createCallSchema, type CreateCallInput } from './calls.schemas.js';
|
||||
@@ -15,7 +15,7 @@ router.use(authenticate());
|
||||
*/
|
||||
router.post(
|
||||
'/',
|
||||
requireStaffPermission('call:create'),
|
||||
requirePermissionOrClassDevice('call:create'),
|
||||
validate({ body: createCallSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { assertStaffPermission } from '../../lib/permissions.js';
|
||||
import { assertPublish } from '../../lib/authz/index.js';
|
||||
import { assertPermissionOrClassDevice } from '../../lib/permissions.js';
|
||||
import { resolveContentCreator } from '../../lib/access.js';
|
||||
import { SOCKET_EVENTS, buildCallTitle, dayKeyLocal, type NotificationDto } from '@classhelper/shared';
|
||||
import { assertCanPublishNotification } from '../../lib/access.js';
|
||||
import { isActiveStudent } from '../../lib/students.js';
|
||||
import { prisma } from '../../lib/db.js';
|
||||
import { ApiError } from '../../lib/http.js';
|
||||
@@ -29,8 +29,8 @@ const creatorSelect = { select: { id: true, name: true, username: true } } as co
|
||||
* 全班都看得到在叫谁,这与需求「教室机器代全班」一致。
|
||||
*/
|
||||
export async function createCall(user: TokenPayload, input: CreateCallInput): Promise<NotificationDto> {
|
||||
await assertStaffPermission(user, 'call:create');
|
||||
await assertCanPublishNotification(user, input.classId);
|
||||
await assertPermissionOrClassDevice(user, 'call:create');
|
||||
await assertPublish(user, 'call:create', input.classId, { allowClassDevice: true });
|
||||
|
||||
const student = await prisma.student.findUnique({
|
||||
where: { id: input.studentId },
|
||||
|
||||
@@ -9,8 +9,9 @@ import { idParamSchema } from '../../lib/schemas.js';
|
||||
import {
|
||||
authenticate,
|
||||
getAuthUser,
|
||||
requireStaffPermission,
|
||||
requirePermissionOrClassDevice,
|
||||
requirePermission,
|
||||
requireAnyPermission,
|
||||
} from '../../middleware/auth.js';
|
||||
import { validate, validatedQuery } from '../../middleware/validate.js';
|
||||
import { defineModule } from '../module.types.js';
|
||||
@@ -21,7 +22,7 @@ const layouts = Router();
|
||||
layouts.use(authenticate());
|
||||
layouts.get(
|
||||
'/',
|
||||
requireStaffPermission('schedule:read'),
|
||||
requirePermissionOrClassDevice('schedule:read'),
|
||||
validate({ query: schemas.scopeQuery }),
|
||||
async (req, res) =>
|
||||
sendOk(
|
||||
@@ -31,26 +32,27 @@ layouts.get(
|
||||
);
|
||||
layouts.post(
|
||||
'/',
|
||||
requirePermission('timetable:manage'),
|
||||
requireAnyPermission('timetable:manage', 'schedule:write'),
|
||||
validate({ body: schemas.layoutSchema }),
|
||||
async (req, res) => sendCreated(res, await service.saveLayout(req.body)),
|
||||
async (req, res) => sendCreated(res, await service.saveLayout(getAuthUser(req), req.body)),
|
||||
);
|
||||
layouts.put(
|
||||
'/:id',
|
||||
requirePermission('timetable:manage'),
|
||||
requireAnyPermission('timetable:manage', 'schedule:write'),
|
||||
validate({ params: idParamSchema, body: schemas.layoutSchema }),
|
||||
async (req, res) => sendOk(res, await service.saveLayout(req.body, String(req.params.id))),
|
||||
async (req, res) =>
|
||||
sendOk(res, await service.saveLayout(getAuthUser(req), req.body, String(req.params.id))),
|
||||
);
|
||||
layouts.post(
|
||||
'/:id/clone',
|
||||
requirePermission('timetable:manage'),
|
||||
requireAnyPermission('timetable:manage', 'schedule:write'),
|
||||
validate({ params: idParamSchema, body: schemas.cloneSchema }),
|
||||
async (req, res) => {
|
||||
const current = (await service.listLayouts(getAuthUser(req))).find((p) => p.id === req.params.id);
|
||||
if (!current) throw ApiError.notFound('时间表不存在');
|
||||
sendCreated(
|
||||
res,
|
||||
await service.saveLayout({
|
||||
await service.saveLayout(getAuthUser(req), {
|
||||
...current,
|
||||
name: req.body.name || `${current.name} 副本`,
|
||||
points: current.points.map((p) => ({ ...p, id: undefined, timeType: p.timeType as 'LESSON' })),
|
||||
@@ -60,7 +62,7 @@ layouts.post(
|
||||
);
|
||||
layouts.post(
|
||||
'/:id/import',
|
||||
requirePermission('timetable:manage'),
|
||||
requireAnyPermission('timetable:manage', 'schedule:write'),
|
||||
validate({ params: idParamSchema, body: schemas.importLayoutSchema }),
|
||||
async (req, res) => {
|
||||
const all = await service.listLayouts(getAuthUser(req));
|
||||
@@ -70,6 +72,7 @@ layouts.post(
|
||||
sendOk(
|
||||
res,
|
||||
await service.saveLayout(
|
||||
getAuthUser(req),
|
||||
{
|
||||
...target,
|
||||
points: source.points.map((p) => ({ ...p, id: undefined, timeType: p.timeType as 'LESSON' })),
|
||||
@@ -81,21 +84,21 @@ layouts.post(
|
||||
);
|
||||
layouts.post(
|
||||
'/:id/points/:pointId/apply-default',
|
||||
requirePermission('timetable:manage'),
|
||||
requireAnyPermission('timetable:manage', 'schedule:write'),
|
||||
validate({ params: z.object({ id: z.string().min(1), pointId: z.string().min(1) }) }),
|
||||
async (req, res) =>
|
||||
sendOk(res, await service.applyDefaultCourse(String(req.params.id), String(req.params.pointId))),
|
||||
sendOk(
|
||||
res,
|
||||
await service.applyDefaultCourse(getAuthUser(req), String(req.params.id), String(req.params.pointId)),
|
||||
),
|
||||
);
|
||||
layouts.delete(
|
||||
'/:id',
|
||||
requirePermission('timetable:manage'),
|
||||
requireAnyPermission('timetable:manage', 'schedule:write'),
|
||||
validate({ params: idParamSchema }),
|
||||
async (req, res) => {
|
||||
const id = String(req.params.id);
|
||||
if (await prisma.classPlan.count({ where: { timeLayoutId: id } }))
|
||||
throw ApiError.conflict('仍有课表使用该时间表');
|
||||
await prisma.timeLayout.delete({ where: { id } });
|
||||
await service.notifyScheduleChanged();
|
||||
await service.deleteLayout(getAuthUser(req), id);
|
||||
sendOk(res, { id });
|
||||
},
|
||||
);
|
||||
@@ -104,7 +107,7 @@ const plans = Router();
|
||||
plans.use(authenticate());
|
||||
plans.get(
|
||||
'/profile',
|
||||
requireStaffPermission('schedule:read'),
|
||||
requirePermissionOrClassDevice('schedule:read'),
|
||||
validate({ query: z.object({ classId: z.string().min(1) }) }),
|
||||
async (req, res) =>
|
||||
sendOk(res, await exportProfile(getAuthUser(req), validatedQuery<{ classId: string }>(req).classId)),
|
||||
@@ -127,7 +130,7 @@ plans.post(
|
||||
);
|
||||
plans.get(
|
||||
'/',
|
||||
requireStaffPermission('schedule:read'),
|
||||
requirePermissionOrClassDevice('schedule:read'),
|
||||
validate({ query: schemas.scopeQuery }),
|
||||
async (req, res) => {
|
||||
const q = validatedQuery<{ classId?: string; week?: number }>(req);
|
||||
@@ -175,7 +178,7 @@ plans.post(
|
||||
);
|
||||
plans.get(
|
||||
'/:id/export',
|
||||
requireStaffPermission('schedule:read'),
|
||||
requirePermissionOrClassDevice('schedule:read'),
|
||||
validate({ params: idParamSchema }),
|
||||
async (req, res) => {
|
||||
const p = await service.requirePlan(getAuthUser(req), String(req.params.id), false);
|
||||
@@ -207,22 +210,22 @@ const groups = Router();
|
||||
groups.use(authenticate());
|
||||
groups.get(
|
||||
'/selection',
|
||||
requireStaffPermission('schedule:read'),
|
||||
requirePermissionOrClassDevice('schedule:read'),
|
||||
validate({ query: z.object({ classId: z.string().min(1) }) }),
|
||||
async (req, res) => {
|
||||
const { classId } = validatedQuery<{ classId: string }>(req);
|
||||
await assertClassAccess(getAuthUser(req), classId);
|
||||
await assertClassAccess(getAuthUser(req), classId, 'schedule:read');
|
||||
const value = await prisma.appSetting.findUnique({ where: { key: `schedule.group.${classId}` } });
|
||||
sendOk(res, value ? JSON.parse(value.value) : {});
|
||||
},
|
||||
);
|
||||
groups.get(
|
||||
'/',
|
||||
requireStaffPermission('schedule:read'),
|
||||
requirePermissionOrClassDevice('schedule:read'),
|
||||
validate({ query: z.object({ classId: z.string().min(1) }) }),
|
||||
async (req, res) => {
|
||||
const { classId } = validatedQuery<{ classId: string }>(req);
|
||||
await assertClassAccess(getAuthUser(req), classId);
|
||||
await assertClassAccess(getAuthUser(req), classId, 'schedule:read');
|
||||
sendOk(res, await prisma.classPlanGroup.findMany({ where: { classId } }));
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { assertAdmin, assertHeadOnly, assertPermissionOrClassDevice } from '../../lib/authz/index.js';
|
||||
import { SOCKET_EVENTS } from '@classhelper/shared';
|
||||
import { prisma } from '../../lib/db.js';
|
||||
import { assertCanManageSchedule, assertClassAccess, getAccessibleClassIds } from '../../lib/access.js';
|
||||
@@ -24,20 +25,24 @@ export async function notifyScheduleChanged(classId?: string | null) {
|
||||
for (const id of ids) emitToClass(id, SOCKET_EVENTS.scheduleUpdated, { classId: id, action: 'updated' });
|
||||
}
|
||||
export async function listLayouts(user: TokenPayload, classId?: string) {
|
||||
if (classId) await assertClassAccess(user, classId);
|
||||
const ids = classId ? [classId] : await getAccessibleClassIds(user);
|
||||
await assertPermissionOrClassDevice(user, 'schedule:read');
|
||||
if (classId) await assertClassAccess(user, classId, 'schedule:read');
|
||||
const ids = classId ? [classId] : await getAccessibleClassIds(user, 'schedule:read');
|
||||
const rows = await prisma.timeLayout.findMany({
|
||||
where: { OR: [{ classId: null }, { classId: { in: ids } }] },
|
||||
include: { points: { orderBy: { position: 'asc' } } },
|
||||
});
|
||||
return Promise.all(rows.map((row) => decorateLayout(row)));
|
||||
}
|
||||
export async function saveLayout(input: z.infer<typeof layoutSchema>, id?: string) {
|
||||
export async function saveLayout(user: TokenPayload, input: z.infer<typeof layoutSchema>, id?: string) {
|
||||
const existing = id
|
||||
? await prisma.timeLayout.findUnique({ where: { id }, select: { classId: true } })
|
||||
: null;
|
||||
if (id && !existing) throw ApiError.notFound('时间表不存在');
|
||||
const layoutClassId = existing ? existing.classId : (input.classId ?? null);
|
||||
await assertLayoutWrite(user, layoutClassId, id);
|
||||
if (existing && input.classId !== undefined && input.classId !== existing.classId)
|
||||
throw ApiError.forbidden('不能移动时间表到其他班级');
|
||||
// 默认课程属于具体班级:全校时间表不保留默认课程,非上课时间点也不保留。
|
||||
const points = input.points.map((point) => ({
|
||||
...point,
|
||||
@@ -83,12 +88,13 @@ export async function saveLayout(input: z.infer<typeof layoutSchema>, id?: strin
|
||||
* 把时间点的默认课程写入所有引用该时间表的日课表(ClassIsland 的「覆盖现有课程」)。
|
||||
* 只处理班级时间表:全校时间表的默认课程没有归属班级。
|
||||
*/
|
||||
export async function applyDefaultCourse(layoutId: string, pointId: string) {
|
||||
export async function applyDefaultCourse(user: TokenPayload, layoutId: string, pointId: string) {
|
||||
const layout = await prisma.timeLayout.findUnique({
|
||||
where: { id: layoutId },
|
||||
include: { points: true },
|
||||
});
|
||||
if (!layout) throw ApiError.notFound('时间表不存在');
|
||||
await assertLayoutWrite(user, layout.classId, layout.id);
|
||||
if (!layout.classId) throw ApiError.badRequest('全校时间表不能覆盖默认课程');
|
||||
const point = layout.points.find((p) => p.id === pointId);
|
||||
if (!point || point.timeType !== 'LESSON') throw ApiError.badRequest('时间点不是上课时间点');
|
||||
@@ -121,10 +127,11 @@ export async function applyDefaultCourse(layoutId: string, pointId: string) {
|
||||
return { plans: plans.length };
|
||||
}
|
||||
export async function listPlans(user: TokenPayload, classId?: string, week?: number) {
|
||||
if (classId) await assertClassAccess(user, classId);
|
||||
await assertPermissionOrClassDevice(user, 'schedule:read');
|
||||
if (classId) await assertClassAccess(user, classId, 'schedule:read');
|
||||
return prisma.classPlan.findMany({
|
||||
where: {
|
||||
classId: { in: classId ? [classId] : await getAccessibleClassIds(user) },
|
||||
classId: { in: classId ? [classId] : await getAccessibleClassIds(user, 'schedule:read') },
|
||||
...(week ? { weekStart: { lte: week }, weekEnd: { gte: week } } : {}),
|
||||
},
|
||||
include: planInclude,
|
||||
@@ -134,7 +141,8 @@ export async function listPlans(user: TokenPayload, classId?: string, week?: num
|
||||
export async function requirePlan(user: TokenPayload, id: string, write = true) {
|
||||
const p = await prisma.classPlan.findUnique({ where: { id }, include: planInclude });
|
||||
if (!p) throw ApiError.notFound('课表不存在');
|
||||
await (write ? assertCanManageSchedule : assertClassAccess)(user, p.classId);
|
||||
if (write) await assertCanManageSchedule(user, p.classId);
|
||||
else await assertClassAccess(user, p.classId, 'schedule:read');
|
||||
return p;
|
||||
}
|
||||
export async function savePlan(user: TokenPayload, input: z.infer<typeof planSchema>, id?: string) {
|
||||
@@ -285,3 +293,24 @@ export async function createOverlay(
|
||||
await notifyScheduleChanged(source.classId);
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function assertLayoutWrite(user: TokenPayload, classId: string | null, layoutId?: string) {
|
||||
if (classId) await assertHeadOnly(user, 'schedule:write', classId);
|
||||
else assertAdmin(user);
|
||||
if (layoutId) {
|
||||
const refs = await prisma.classPlan.findMany({
|
||||
where: { timeLayoutId: layoutId },
|
||||
select: { classId: true },
|
||||
});
|
||||
for (const id of new Set(refs.map((p) => p.classId))) await assertHeadOnly(user, 'schedule:write', id);
|
||||
}
|
||||
}
|
||||
export async function deleteLayout(user: TokenPayload, id: string) {
|
||||
const layout = await prisma.timeLayout.findUnique({ where: { id } });
|
||||
if (!layout) throw ApiError.notFound('时间表不存在');
|
||||
await assertLayoutWrite(user, layout.classId, id);
|
||||
if (await prisma.classPlan.count({ where: { timeLayoutId: id } }))
|
||||
throw ApiError.conflict('仍有课表使用该时间表');
|
||||
await prisma.timeLayout.delete({ where: { id } });
|
||||
await notifyScheduleChanged(layout.classId);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import type { TokenPayload } from '../../lib/jwt.js';
|
||||
|
||||
/** 按时间点外键生成完整档案,空课、停用课程与非上课时间点均保留。 */
|
||||
export async function exportProfile(user: TokenPayload, classId: string) {
|
||||
await assertClassAccess(user, classId);
|
||||
await assertClassAccess(user, classId, 'schedule:read');
|
||||
const [cls, layouts, plans, subjects, groups, scheduled, setting] = await Promise.all([
|
||||
prisma.class.findUniqueOrThrow({ where: { id: classId } }),
|
||||
prisma.timeLayout.findMany({
|
||||
|
||||
@@ -4,8 +4,9 @@ import { idParamSchema } from '../../lib/schemas.js';
|
||||
import {
|
||||
authenticate,
|
||||
getAuthUser,
|
||||
requireStaffPermission,
|
||||
requirePermissionOrClassDevice,
|
||||
requirePermission,
|
||||
requireAdmin,
|
||||
} from '../../middleware/auth.js';
|
||||
import { validate, validatedBody, validatedParams, validatedQuery } from '../../middleware/validate.js';
|
||||
import { defineModule } from '../module.types.js';
|
||||
@@ -19,6 +20,8 @@ import {
|
||||
promoteClassesSchema,
|
||||
updateClassAccountSchema,
|
||||
updateClassSchema,
|
||||
updateClassSettingsSchema,
|
||||
type UpdateClassSettingsInput,
|
||||
updateStudentGradeQuerySchema,
|
||||
type AddStudentInput,
|
||||
type AssignSubjectTeachersInput,
|
||||
@@ -42,7 +45,7 @@ router.use(authenticate());
|
||||
*/
|
||||
router.get(
|
||||
'/',
|
||||
requireStaffPermission('class:read'),
|
||||
requirePermissionOrClassDevice('class:read'),
|
||||
validate({ query: listClassesQuerySchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -59,6 +62,7 @@ router.get(
|
||||
*/
|
||||
router.post(
|
||||
'/promote',
|
||||
requireAdmin(),
|
||||
requirePermission('promotion:manage'),
|
||||
validate({ body: promoteClassesSchema }),
|
||||
async (req, res) => {
|
||||
@@ -71,7 +75,7 @@ router.post(
|
||||
/** GET /api/classes/:id - 班级详情(学生名单 + 课程 + 班主任 + 各科任课老师) */
|
||||
router.get(
|
||||
'/:id',
|
||||
requireStaffPermission('class:read'),
|
||||
requirePermissionOrClassDevice('class:read'),
|
||||
validate({ params: idParamSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -83,6 +87,7 @@ router.get(
|
||||
/** POST /api/classes - 创建班级(称呼由入学年份 + 班号生成,如「2026级1班」) */
|
||||
router.post(
|
||||
'/',
|
||||
requireAdmin(),
|
||||
requirePermission('class:manage'),
|
||||
validate({ body: createClassSchema }),
|
||||
async (req, res) => {
|
||||
@@ -94,6 +99,7 @@ router.post(
|
||||
/** PATCH /api/classes/:id - 编辑班级(年级 / 届别 / 教学周数),按功能权限及范围校验 */
|
||||
router.patch(
|
||||
'/:id',
|
||||
requireAdmin(),
|
||||
requirePermission('class:manage'),
|
||||
(req, _res, next) => {
|
||||
if (req.body && 'grade' in req.body) return next(ApiError.badRequest('年级请通过学年升级入口修改'));
|
||||
@@ -107,9 +113,25 @@ router.patch(
|
||||
},
|
||||
);
|
||||
|
||||
router.patch(
|
||||
'/:id/settings',
|
||||
requirePermission('class:settings'),
|
||||
validate({ params: idParamSchema, body: updateClassSettingsSchema }),
|
||||
async (req, res) => {
|
||||
sendOk(
|
||||
res,
|
||||
await classService.updateClassSettings(
|
||||
getAuthUser(req),
|
||||
String(req.params.id),
|
||||
validatedBody<UpdateClassSettingsInput>(req),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
/** DELETE /api/classes/:id - 删除班级 */
|
||||
router.delete(
|
||||
'/:id',
|
||||
requireAdmin(),
|
||||
requirePermission('class:manage'),
|
||||
validate({ params: idParamSchema }),
|
||||
async (req, res) => {
|
||||
@@ -126,7 +148,7 @@ router.delete(
|
||||
*/
|
||||
router.patch(
|
||||
'/:id/class-account',
|
||||
requirePermission('class:manage'),
|
||||
requirePermission('class:account'),
|
||||
validate({ params: idParamSchema, body: updateClassAccountSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -145,6 +167,7 @@ router.patch(
|
||||
*/
|
||||
router.patch(
|
||||
'/:id/head-teacher',
|
||||
requireAdmin(),
|
||||
requirePermission('class:manage'),
|
||||
validate({ params: idParamSchema, body: assignHeadTeacherSchema }),
|
||||
async (req, res) => {
|
||||
@@ -164,6 +187,7 @@ router.patch(
|
||||
*/
|
||||
router.put(
|
||||
'/:id/subject-teachers',
|
||||
requireAdmin(),
|
||||
requirePermission('class:manage'),
|
||||
validate({ params: idParamSchema, body: assignSubjectTeachersSchema }),
|
||||
async (req, res) => {
|
||||
@@ -183,7 +207,7 @@ router.put(
|
||||
*/
|
||||
router.get(
|
||||
'/:id/student-grade-query',
|
||||
requireStaffPermission('class:read'),
|
||||
requirePermissionOrClassDevice('class:read'),
|
||||
validate({ params: idParamSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -210,7 +234,7 @@ router.patch(
|
||||
/** GET /api/classes/:id/students - 班级学生名单 */
|
||||
router.get(
|
||||
'/:id/students',
|
||||
requireStaffPermission('roster:read'),
|
||||
requirePermissionOrClassDevice('roster:read'),
|
||||
validate({ params: idParamSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
|
||||
@@ -41,12 +41,15 @@ export const createClassSchema = z.object({
|
||||
* `enrollmentYear` / `classIndex` 是给"有名无实的旧数据"补届别用的,改了会重算班级称呼。
|
||||
*/
|
||||
export const updateClassSchema = z.strictObject({
|
||||
enrollmentYear: enrollmentYearSchema.optional(),
|
||||
classIndex: classIndexSchema.optional(),
|
||||
});
|
||||
|
||||
export const updateClassSettingsSchema = z.strictObject({
|
||||
/** 本班学期开始日期(第 1 教学周的周一);空串 = 回落到全局配置 */
|
||||
termStartDate: z
|
||||
.union([z.string().trim().refine(isDayKey, { message: '日期格式应为 YYYY-MM-DD' }), z.literal('')])
|
||||
.nullish(),
|
||||
enrollmentYear: enrollmentYearSchema.optional(),
|
||||
classIndex: classIndexSchema.optional(),
|
||||
termWeeks: z.coerce.number().int().min(1).max(60).optional(),
|
||||
});
|
||||
|
||||
@@ -125,3 +128,5 @@ export type UpdateClassAccountInput = z.infer<typeof updateClassAccountSchema>;
|
||||
export type UpdateStudentGradeQueryInput = z.infer<typeof updateStudentGradeQuerySchema>;
|
||||
export type AddStudentInput = z.infer<typeof addStudentSchema>;
|
||||
export type AssignSubjectTeachersInput = z.infer<typeof assignSubjectTeachersSchema>;
|
||||
|
||||
export type UpdateClassSettingsInput = z.infer<typeof updateClassSettingsSchema>;
|
||||
@@ -1,3 +1,6 @@
|
||||
import { disconnectUserSessions, refreshClassSubscriptions } from '../../realtime/bus.js';
|
||||
import { assertHeadOnly, assertManage } from '../../lib/authz/index.js';
|
||||
import { auditData } from '../permissions/permissions.service.js';
|
||||
import type { ClassDetailDto, ClassDto, StudentDto, SubjectTeacherDto } from '@classhelper/shared';
|
||||
import { SOCKET_EVENTS, formatClassName } from '@classhelper/shared';
|
||||
import { env } from '../../config/env.js';
|
||||
@@ -23,12 +26,17 @@ import type {
|
||||
AssignSubjectTeachersInput,
|
||||
CreateClassInput,
|
||||
UpdateClassInput,
|
||||
UpdateClassSettingsInput,
|
||||
UpdateStudentGradeQueryInput,
|
||||
} from './classes.schemas.js';
|
||||
import { loadPromotionPlan } from '../term/promotion.service.js';
|
||||
import { assertClassInScope, canAccessClass } from '../../lib/access.js';
|
||||
import { changeStudentLifecycle } from '../../lib/student-lifecycle.js';
|
||||
import { assertStaffPermission, assertPermission, hasEffectivePermission } from '../../lib/permissions.js';
|
||||
import {
|
||||
assertPermissionOrClassDevice,
|
||||
assertPermission,
|
||||
hasEffectivePermission,
|
||||
} from '../../lib/permissions.js';
|
||||
import { activeStudentWhere } from '../../lib/students.js';
|
||||
import { getMaxTermWeeks } from '../../lib/term.js';
|
||||
import { logger } from '../../lib/logger.js';
|
||||
@@ -79,7 +87,7 @@ function toClassDtoWithAccount(
|
||||
* 混在常规列表里会让老师误以为那届还在上课。需要看归档班级请用 `/api/archives`。
|
||||
*/
|
||||
export async function listClasses(user: TokenPayload, keyword?: string): Promise<ClassDto[]> {
|
||||
await assertStaffPermission(user, 'class:read');
|
||||
await assertPermissionOrClassDevice(user, 'class:read');
|
||||
const scope = await resolveClassScope(user, undefined, 'class:read');
|
||||
// Class 表按主键 id 过滤(classScopeWhere 针对的是带 classId 字段的子表)
|
||||
const where = {
|
||||
@@ -102,8 +110,8 @@ export async function listClasses(user: TokenPayload, keyword?: string): Promise
|
||||
|
||||
/** 班级详情:学生名单 + 课程 + 班主任 + 各科任课老师 */
|
||||
export async function getClassDetail(user: TokenPayload, classId: string): Promise<ClassDetailDto> {
|
||||
await assertStaffPermission(user, 'class:read');
|
||||
await assertClassAccess(user, classId);
|
||||
await assertPermissionOrClassDevice(user, 'class:read');
|
||||
await assertClassAccess(user, classId, 'class:read');
|
||||
|
||||
const item = await prisma.class.findUnique({
|
||||
where: { id: classId },
|
||||
@@ -147,7 +155,7 @@ export async function listSubjectTeachers(
|
||||
user: TokenPayload,
|
||||
classId: string,
|
||||
): Promise<{ headTeacher: SubjectTeacherDto | null; subjectTeachers: SubjectTeacherDto[] }> {
|
||||
await assertClassAccess(user, classId);
|
||||
await assertClassAccess(user, classId, 'class:read');
|
||||
const item = await prisma.class.findUnique({
|
||||
where: { id: classId },
|
||||
include: {
|
||||
@@ -206,6 +214,13 @@ export async function createClass(user: TokenPayload, input: CreateClassInput):
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.permissionAudit.create({
|
||||
data: auditData(user, 'class-create', 'class', created.id, null, {
|
||||
name: created.name,
|
||||
teacherId: created.teacherId,
|
||||
}),
|
||||
});
|
||||
refreshClassSubscriptions();
|
||||
return toClassDtoWithAccount(user, created);
|
||||
}
|
||||
|
||||
@@ -222,6 +237,10 @@ export async function updateHeadTeacher(
|
||||
teacherId: string,
|
||||
): Promise<ClassDto> {
|
||||
await assertCanAssignTeachers(user, classId);
|
||||
const before = await prisma.class.findUniqueOrThrow({
|
||||
where: { id: classId },
|
||||
select: { teacherId: true },
|
||||
});
|
||||
const targetId = await requireTeacher(teacherId, '指定的班主任不存在或角色不是教师');
|
||||
|
||||
const updated = await prisma.class.update({
|
||||
@@ -233,6 +252,10 @@ export async function updateHeadTeacher(
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.permissionAudit.create({
|
||||
data: auditData(user, 'class-head', 'class', classId, before, { teacherId: targetId }),
|
||||
});
|
||||
disconnectUserSessions([before.teacherId, targetId].filter((id): id is string => !!id));
|
||||
emitToClass(classId, SOCKET_EVENTS.classUpdated, { classId, action: 'head-teacher-changed' });
|
||||
return toClassDtoWithAccount(user, updated);
|
||||
}
|
||||
@@ -256,6 +279,10 @@ export async function assignSubjectTeachers(
|
||||
const targetClass = await prisma.class.findUnique({ where: { id: classId }, select: { id: true } });
|
||||
if (!targetClass) throw ApiError.notFound('班级不存在');
|
||||
|
||||
const before = await prisma.course.findMany({
|
||||
where: { classId },
|
||||
select: { id: true, name: true, teacherId: true },
|
||||
});
|
||||
for (const assignment of input.assignments) {
|
||||
const teacherId = assignment.teacherId
|
||||
? await requireTeacher(assignment.teacherId, `「${assignment.subjectName}」的任课老师不存在或不是教师`)
|
||||
@@ -290,6 +317,17 @@ export async function assignSubjectTeachers(
|
||||
where: { id: classId },
|
||||
include: { courses: { orderBy: { name: 'asc' }, include: { teacher: teacherBriefSelect } } },
|
||||
});
|
||||
await prisma.permissionAudit.create({
|
||||
data: auditData(
|
||||
user,
|
||||
'class-subjects',
|
||||
'class',
|
||||
classId,
|
||||
before,
|
||||
refreshed.courses.map((c) => ({ id: c.id, name: c.name, teacherId: c.teacherId })),
|
||||
),
|
||||
});
|
||||
disconnectUserSessions([...new Set([...before, ...refreshed.courses].map((c) => c.teacherId))]);
|
||||
return refreshed.courses.map((course) => ({
|
||||
courseId: course.id,
|
||||
subjectName: course.name,
|
||||
@@ -304,7 +342,8 @@ export async function updateClassAccount(
|
||||
classId: string,
|
||||
input: { code?: string; password?: string },
|
||||
): Promise<ClassDto> {
|
||||
await assertCanManageClasses(user, classId);
|
||||
await assertHeadOnly(user, 'class:account', classId);
|
||||
const before = await prisma.class.findUniqueOrThrow({ where: { id: classId }, select: { code: true } });
|
||||
if (
|
||||
'termWeeks' in input &&
|
||||
typeof input.termWeeks === 'number' &&
|
||||
@@ -322,6 +361,12 @@ export async function updateClassAccount(
|
||||
},
|
||||
});
|
||||
if (!updated) throw ApiError.notFound('班级不存在');
|
||||
await prisma.permissionAudit.create({
|
||||
data: auditData(user, 'class-account', 'class', classId, before, {
|
||||
code: updated.code,
|
||||
passwordChanged: input.password !== undefined,
|
||||
}),
|
||||
});
|
||||
return toClassDtoWithAccount(user, updated);
|
||||
}
|
||||
|
||||
@@ -340,19 +385,12 @@ export async function updateClass(
|
||||
if (!current) throw ApiError.notFound('班级不存在');
|
||||
|
||||
if ('grade' in input) throw ApiError.badRequest('修改年级请使用学年升级入口');
|
||||
if (input.termWeeks !== undefined && input.termWeeks > (await getMaxTermWeeks()))
|
||||
throw ApiError.badRequest('班级周数不能超过全校上限');
|
||||
const enrollmentYear = input.enrollmentYear ?? current.enrollmentYear;
|
||||
const classIndex = input.classIndex ?? current.classIndex;
|
||||
|
||||
const updated = await prisma.class.update({
|
||||
where: { id: classId },
|
||||
data: {
|
||||
...(input.termWeeks !== undefined ? { termWeeks: input.termWeeks } : {}),
|
||||
// 学期开始日期:空串/null 都表示"回落到全局配置"(如 .env 的 TERM_START_DATE)
|
||||
...(input.termStartDate !== undefined
|
||||
? { termStartDate: input.termStartDate ? input.termStartDate : null }
|
||||
: {}),
|
||||
...(input.enrollmentYear !== undefined ? { enrollmentYear } : {}),
|
||||
...(input.classIndex !== undefined ? { classIndex } : {}),
|
||||
// 届别齐了就把称呼重算一遍:班级称呼的唯一来源是「入学年份 + 班号」
|
||||
@@ -364,10 +402,45 @@ export async function updateClass(
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.permissionAudit.create({
|
||||
data: auditData(user, 'class-update', 'class', classId, current, {
|
||||
enrollmentYear: updated.enrollmentYear,
|
||||
classIndex: updated.classIndex,
|
||||
name: updated.name,
|
||||
}),
|
||||
});
|
||||
emitToClass(classId, SOCKET_EVENTS.classUpdated, { classId, action: 'updated' });
|
||||
return toClassDto(updated);
|
||||
}
|
||||
|
||||
export async function updateClassSettings(
|
||||
user: TokenPayload,
|
||||
classId: string,
|
||||
input: UpdateClassSettingsInput,
|
||||
): Promise<ClassDto> {
|
||||
await assertHeadOnly(user, 'class:settings', classId);
|
||||
if (input.termWeeks !== undefined && input.termWeeks > (await getMaxTermWeeks()))
|
||||
throw ApiError.badRequest('班级周数不能超过全校上限');
|
||||
const before = await prisma.class.findUniqueOrThrow({
|
||||
where: { id: classId },
|
||||
select: { termWeeks: true, termStartDate: true },
|
||||
});
|
||||
const updated = await prisma.class.update({
|
||||
where: { id: classId },
|
||||
data: {
|
||||
termWeeks: input.termWeeks,
|
||||
...(input.termStartDate !== undefined ? { termStartDate: input.termStartDate || null } : {}),
|
||||
},
|
||||
});
|
||||
await prisma.permissionAudit.create({
|
||||
data: auditData(user, 'class-settings', 'class', classId, before, {
|
||||
termWeeks: updated.termWeeks,
|
||||
termStartDate: updated.termStartDate,
|
||||
}),
|
||||
});
|
||||
emitToClass(classId, SOCKET_EVENTS.classUpdated, { classId, action: 'updated' });
|
||||
return toClassDto(updated);
|
||||
}
|
||||
/**
|
||||
* 学年升级(按升级权限与班级范围):把若干班级的年级改掉,**不归档、数据沿用**。
|
||||
*
|
||||
@@ -405,6 +478,7 @@ export async function promoteClasses(
|
||||
}
|
||||
});
|
||||
for (const classId of ids) emitToClass(classId, SOCKET_EVENTS.classUpdated, { classId, action: 'updated' });
|
||||
refreshClassSubscriptions();
|
||||
return { promoted: ids.length };
|
||||
}
|
||||
|
||||
@@ -414,13 +488,21 @@ export async function deleteClass(user: TokenPayload, classId: string): Promise<
|
||||
if (await prisma.student.count({ where: { classId } }))
|
||||
throw ApiError.conflict('班级仍有学生,请先通过名单生命周期入口移出或归档,不能级联清除归属');
|
||||
emitToClass(classId, SOCKET_EVENTS.classUpdated, { classId, action: 'deleted' });
|
||||
const before = await prisma.class.findUniqueOrThrow({
|
||||
where: { id: classId },
|
||||
select: { id: true, name: true, grade: true },
|
||||
});
|
||||
await prisma.class.delete({ where: { id: classId } });
|
||||
refreshClassSubscriptions();
|
||||
await prisma.permissionAudit.create({
|
||||
data: auditData(user, 'class-delete', 'class', classId, before, null),
|
||||
});
|
||||
}
|
||||
|
||||
/** 班级学生名单 */
|
||||
export async function listClassStudents(user: TokenPayload, classId: string): Promise<StudentDto[]> {
|
||||
await assertStaffPermission(user, 'roster:read');
|
||||
await assertClassAccess(user, classId);
|
||||
await assertPermissionOrClassDevice(user, 'roster:read');
|
||||
await assertClassAccess(user, classId, 'roster:read');
|
||||
const students = await prisma.student.findMany({
|
||||
where: { classId, ...activeStudentWhere() },
|
||||
include: { class: { select: { name: true, grade: true } } },
|
||||
@@ -472,7 +554,7 @@ export async function getStudentGradeQuery(
|
||||
user: TokenPayload,
|
||||
classId: string,
|
||||
): Promise<{ studentGradeQueryEnabled: boolean }> {
|
||||
await assertClassAccess(user, classId);
|
||||
await assertClassAccess(user, classId, 'class:read');
|
||||
const record = await prisma.class.findUnique({
|
||||
where: { id: classId },
|
||||
select: { studentGradeQueryEnabled: true },
|
||||
@@ -493,7 +575,7 @@ export async function updateStudentGradeQuery(
|
||||
classId: string,
|
||||
input: UpdateStudentGradeQueryInput,
|
||||
): Promise<{ studentGradeQueryEnabled: boolean }> {
|
||||
await assertClassAccess(user, classId);
|
||||
await assertManage(user, 'grade:write', classId);
|
||||
if (!isStaff(user)) {
|
||||
throw ApiError.forbidden('成绩查询开关由教师/管理员在 Web 端设置,班级端只读');
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Router } from 'express';
|
||||
import type { ClientUpdateProgressDto } from '@classhelper/shared';
|
||||
import { sendCreated, sendOk } from '../../lib/http.js';
|
||||
import { idParamSchema } from '../../lib/schemas.js';
|
||||
import { authenticate, getAuthUser, requireAnyPermission, requirePermission } from '../../middleware/auth.js';
|
||||
import { authenticate, getAuthUser, requireRole, requirePermission } from '../../middleware/auth.js';
|
||||
import { validate, validatedBody, validatedParams, validatedQuery } from '../../middleware/validate.js';
|
||||
import { defineModule } from '../module.types.js';
|
||||
import {
|
||||
@@ -22,14 +22,19 @@ import * as clientService from './clients.service.js';
|
||||
|
||||
const router = Router();
|
||||
router.use(authenticate());
|
||||
router.post('/update-progress', validate({ body: clientProgressSchema }), async (req, res) => {
|
||||
const input = validatedBody<{
|
||||
deviceKey: string;
|
||||
progress: ClientUpdateProgressDto;
|
||||
}>(req);
|
||||
await clientService.reportUpdateProgress(getAuthUser(req), input.deviceKey, input.progress);
|
||||
sendOk(res, null);
|
||||
});
|
||||
router.post(
|
||||
'/update-progress',
|
||||
requireRole('CLASS_DEVICE'),
|
||||
validate({ body: clientProgressSchema }),
|
||||
async (req, res) => {
|
||||
const input = validatedBody<{
|
||||
deviceKey: string;
|
||||
progress: ClientUpdateProgressDto;
|
||||
}>(req);
|
||||
await clientService.reportUpdateProgress(getAuthUser(req), input.deviceKey, input.progress);
|
||||
sendOk(res, null);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/clients/heartbeat - 客户端每 30 秒上报一次
|
||||
@@ -37,22 +42,28 @@ router.post('/update-progress', validate({ body: clientProgressSchema }), async
|
||||
* **不挂权限键**:上报方是教室里的班级端会话(角色 CLASS_DEVICE,权限集为空)。
|
||||
* 返回值里同时带回"要不要更新 + 安装包信息 + 一条待执行命令",客户端一次请求全拿到。
|
||||
*/
|
||||
router.post('/heartbeat', validate({ body: clientHeartbeatSchema }), async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const input = validatedBody<ClientHeartbeatInput>(req);
|
||||
const result = await clientService.heartbeat(user, input, req.ip ?? '');
|
||||
// 1.3.x 旧客户端误读顶层字段,提供兼容别名让它们仍能接收修复版更新。
|
||||
sendOk(res, result, '心跳已上报', 200, input.agentProtocol === 2 ? {} : result);
|
||||
});
|
||||
router.post(
|
||||
'/heartbeat',
|
||||
requireRole('CLASS_DEVICE'),
|
||||
validate({ body: clientHeartbeatSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const input = validatedBody<ClientHeartbeatInput>(req);
|
||||
const result = await clientService.heartbeat(user, input, req.ip ?? '');
|
||||
// 1.3.x 旧客户端误读顶层字段,提供兼容别名让它们仍能接收修复版更新。
|
||||
sendOk(res, result, '心跳已上报', 200, input.agentProtocol === 2 ? {} : result);
|
||||
},
|
||||
);
|
||||
|
||||
/** POST /api/clients/commands/:id/ack - 执行回执(同样不挂权限键,靠 deviceKey 归属校验) */
|
||||
router.post(
|
||||
'/commands/:id/ack',
|
||||
requireRole('CLASS_DEVICE'),
|
||||
validate({ params: idParamSchema, body: clientCommandAckSchema }),
|
||||
async (req, res) => {
|
||||
const { id } = validatedParams<{ id: string }>(req);
|
||||
const input = validatedBody<ClientCommandAckInput>(req);
|
||||
sendOk(res, await clientService.ackCommand(id, input), '回执已记录');
|
||||
sendOk(res, await clientService.ackCommand(getAuthUser(req), id, input), '回执已记录');
|
||||
},
|
||||
);
|
||||
|
||||
@@ -79,7 +90,7 @@ router.post(
|
||||
/** GET /api/clients/devices?classId= - Web 端设备列表(版本 / 在线 / 待执行命令) */
|
||||
router.get(
|
||||
'/devices',
|
||||
requireAnyPermission('client:read', 'client:manage', 'remote:control', 'client:update-push'),
|
||||
requirePermission('client:read'),
|
||||
validate({ query: listClientDevicesQuerySchema }),
|
||||
async (req, res) => {
|
||||
const query = validatedQuery<ListClientDevicesQuery>(req);
|
||||
@@ -94,7 +105,7 @@ router.get(
|
||||
/** POST /api/clients/updates - 统一下发客户端更新 */
|
||||
router.post(
|
||||
'/updates',
|
||||
requirePermission('client:update'),
|
||||
requirePermission('client:update-push'),
|
||||
validate({ body: pushClientUpdateSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { assertClassDevice } from '../../lib/authz/index.js';
|
||||
import {
|
||||
CLIENT_COMMAND_EXPIRE_MS,
|
||||
CLIENT_COMMAND_LABELS,
|
||||
@@ -136,6 +137,11 @@ export async function heartbeat(
|
||||
input: ClientHeartbeatInput,
|
||||
ip: string,
|
||||
): Promise<ClientHeartbeatResponse> {
|
||||
const boundClassId = resolveClassId(user);
|
||||
assertClassDevice(user, boundClassId ?? '');
|
||||
const previousDevice = await prisma.clientDevice.findUnique({ where: { deviceKey: input.deviceKey } });
|
||||
if (previousDevice && previousDevice.classId !== boundClassId)
|
||||
throw ApiError.forbidden('设备不属于当前班级');
|
||||
const now = new Date();
|
||||
const heartbeatAt = {
|
||||
classId: resolveClassId(user),
|
||||
@@ -149,11 +155,17 @@ export async function heartbeat(
|
||||
lastIp: ip,
|
||||
};
|
||||
|
||||
const device = (await prisma.clientDevice.upsert({
|
||||
where: { deviceKey: input.deviceKey },
|
||||
create: { deviceKey: input.deviceKey, ...heartbeatAt },
|
||||
update: heartbeatAt,
|
||||
})) as unknown as DeviceRow;
|
||||
const device = (await prisma.clientDevice
|
||||
.upsert({
|
||||
where: { deviceKey: input.deviceKey, classId: boundClassId },
|
||||
create: { deviceKey: input.deviceKey, ...heartbeatAt },
|
||||
update: heartbeatAt,
|
||||
})
|
||||
.catch(async (error: unknown) => {
|
||||
const current = await prisma.clientDevice.findUnique({ where: { deviceKey: input.deviceKey } });
|
||||
if (current && current.classId !== boundClassId) throw ApiError.forbidden('设备不属于当前班级');
|
||||
throw error;
|
||||
})) as unknown as DeviceRow;
|
||||
|
||||
const targetVersion = device.updateTargetVersion.trim();
|
||||
if (targetVersion && input.clientVersion && compareVersions(input.clientVersion, targetVersion) >= 0) {
|
||||
@@ -204,7 +216,7 @@ export async function reportUpdateProgress(
|
||||
progress: ClientUpdateProgressDto,
|
||||
) {
|
||||
const device = await prisma.clientDevice.findUnique({ where: { deviceKey } });
|
||||
// 教师/管理员电脑不绑定班级(会话与设备两侧都是 null),同样允许上报自己的进度。
|
||||
assertClassDevice(user, device?.classId ?? '');
|
||||
if (!device || device.classId !== resolveClassId(user)) throw ApiError.forbidden('设备不属于当前班级');
|
||||
// 已取消或更换目标的迟到进度不得覆盖当前任务。
|
||||
if (progress.version !== device.updateTargetVersion || progress.stage === 'completed') return;
|
||||
@@ -240,14 +252,16 @@ function readUpdateProgress(raw: string): ClientUpdateProgressDto | null {
|
||||
|
||||
/** POST /api/clients/commands/:id/ack - 客户端执行完远控命令后的回执 */
|
||||
export async function ackCommand(
|
||||
user: TokenPayload,
|
||||
commandId: string,
|
||||
input: ClientCommandAckRequest,
|
||||
): Promise<ClientCommandDto> {
|
||||
const command = (await prisma.clientCommand.findUnique({
|
||||
where: { id: commandId },
|
||||
include: { device: { select: { deviceKey: true, name: true } } },
|
||||
})) as (CommandRow & { device: { deviceKey: string; name: string } }) | null;
|
||||
include: { device: { select: { deviceKey: true, name: true, classId: true } } },
|
||||
})) as (CommandRow & { device: { deviceKey: string; name: string; classId: string | null } }) | null;
|
||||
if (!command) throw ApiError.notFound('命令不存在');
|
||||
assertClassDevice(user, command.device.classId ?? '');
|
||||
|
||||
if (command.device.deviceKey !== input.deviceKey) {
|
||||
throw ApiError.forbidden('这条命令不是发给这台电脑的');
|
||||
@@ -271,7 +285,7 @@ export async function listDevices(
|
||||
user: TokenPayload,
|
||||
options: { classId?: string } = {},
|
||||
): Promise<ClientDeviceDto[]> {
|
||||
const scope = await resolveClassScope(user, options.classId);
|
||||
const scope = await resolveClassScope(user, options.classId, 'client:read');
|
||||
const rows = (await prisma.clientDevice.findMany({
|
||||
where: classScopeWhere(scope),
|
||||
include: { class: { select: { id: true, name: true } } },
|
||||
@@ -394,7 +408,7 @@ export async function createCommands(
|
||||
|
||||
/** 更新推送的目标集合:优先设备,其次班级,最后"全部" */
|
||||
async function resolveUpdateTargets(user: TokenPayload, input: PushClientUpdateInput): Promise<DeviceRow[]> {
|
||||
const scope = classScopeWhere(await resolveClassScope(user, undefined, 'client:update'));
|
||||
const scope = classScopeWhere(await resolveClassScope(user, undefined, 'client:update-push'));
|
||||
const deviceIds = [...new Set(input.deviceIds ?? [])];
|
||||
if (deviceIds.length > 0) {
|
||||
const rows = (await prisma.clientDevice.findMany({
|
||||
|
||||
@@ -4,7 +4,7 @@ import { idParamSchema } from '../../lib/schemas.js';
|
||||
import {
|
||||
authenticate,
|
||||
getAuthUser,
|
||||
requireStaffPermission,
|
||||
requirePermissionOrClassDevice,
|
||||
requirePermission,
|
||||
} from '../../middleware/auth.js';
|
||||
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
|
||||
@@ -24,7 +24,7 @@ router.use(authenticate());
|
||||
/** GET /api/courses?classId= - 课程列表 */
|
||||
router.get(
|
||||
'/',
|
||||
requireStaffPermission('schedule:read'),
|
||||
requirePermissionOrClassDevice('schedule:read'),
|
||||
validate({ query: listCoursesQuerySchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { refreshClassSubscriptions } from '../../realtime/bus.js';
|
||||
import type { CourseDto } from '@classhelper/shared';
|
||||
import { assertCanManageSchedule, classScopeWhere, resolveClassScope } from '../../lib/access.js';
|
||||
import { prisma } from '../../lib/db.js';
|
||||
@@ -9,7 +10,7 @@ import type { CreateCourseInput, UpdateCourseInput } from './courses.schemas.js'
|
||||
const teacherSelect = { select: { id: true, name: true, username: true } } as const;
|
||||
|
||||
export async function listCourses(user: TokenPayload, classId?: string): Promise<CourseDto[]> {
|
||||
const scope = await resolveClassScope(user, classId);
|
||||
const scope = await resolveClassScope(user, classId, 'schedule:read');
|
||||
const courses = await prisma.course.findMany({
|
||||
where: classScopeWhere(scope),
|
||||
include: { teacher: teacherSelect },
|
||||
@@ -26,7 +27,7 @@ export async function createCourse(user: TokenPayload, input: CreateCourseInput)
|
||||
*
|
||||
* 这条不是"友好提示",是**权限护栏**:`course.create` 会把创建者写成这门课的任课老师,
|
||||
* 于是只要能再建一门同名的「数学」,创建者就凭空成了「数学」的老师 ——
|
||||
* 后面那道 `assertCanManageSubjectContent` 的科目门随即形同虚设
|
||||
* 后面那道 `` 的科目门随即形同虚设
|
||||
* (可以布置该科作业、录入该科成绩、删该科内容)。这就是"非该科科任老师
|
||||
* 能布置其他科作业"的入口。
|
||||
*
|
||||
@@ -82,4 +83,5 @@ export async function deleteCourse(user: TokenPayload, courseId: string): Promis
|
||||
if (!course) throw ApiError.notFound('课程不存在');
|
||||
await assertCanManageSchedule(user, course.classId);
|
||||
await prisma.course.delete({ where: { id: courseId } });
|
||||
refreshClassSubscriptions();
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
getAuthUser,
|
||||
requireRole,
|
||||
requirePermission,
|
||||
requireStaffPermission,
|
||||
requirePermissionOrClassDevice,
|
||||
} from '../../middleware/auth.js';
|
||||
import { validate, validatedBody, validatedQuery, validatedParams } from '../../middleware/validate.js';
|
||||
import { defineModule } from '../module.types.js';
|
||||
@@ -22,7 +22,7 @@ import * as service from './courseware.service.js';
|
||||
const router = Router();
|
||||
router.use(authenticate());
|
||||
const staff = [requireRole('ADMIN', 'TEACHER'), requirePermission('courseware:write')];
|
||||
router.get('/', requireStaffPermission('courseware:read'), async (req, res) =>
|
||||
router.get('/', requirePermissionOrClassDevice('courseware:read'), async (req, res) =>
|
||||
sendOk(res, await service.listCourseware(getAuthUser(req))),
|
||||
);
|
||||
router.get('/classes', ...staff, async (req, res) =>
|
||||
@@ -83,10 +83,14 @@ router.post('/upload', ...staff, validate({ query: coursewareUploadQuery }), asy
|
||||
);
|
||||
router.get(
|
||||
'/:id/download',
|
||||
requireStaffPermission('courseware:read'),
|
||||
requirePermissionOrClassDevice('courseware:read'),
|
||||
validate({ params: idParamSchema }),
|
||||
async (req, res, next) => {
|
||||
const row = await service.readable(getAuthUser(req), validatedParams<{ id: string }>(req).id);
|
||||
const row = await service.readable(
|
||||
getAuthUser(req),
|
||||
validatedParams<{ id: string }>(req).id,
|
||||
'courseware:read',
|
||||
);
|
||||
res.setHeader('Cache-Control', 'private, no-store');
|
||||
res.setHeader('Content-Type', 'application/octet-stream');
|
||||
res.download(row.storagePath, row.name, { root: service.coursewareRoot, dotfiles: 'allow' }, (error) => {
|
||||
@@ -127,7 +131,7 @@ router.delete('/:id', ...staff, validate({ params: idParamSchema }), async (req,
|
||||
});
|
||||
router.get(
|
||||
'/:id',
|
||||
requireStaffPermission('courseware:read'),
|
||||
requirePermissionOrClassDevice('courseware:read'),
|
||||
validate({ params: idParamSchema }),
|
||||
async (req, res) =>
|
||||
sendOk(res, await service.getCourseware(getAuthUser(req), validatedParams<{ id: string }>(req).id)),
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
import {
|
||||
assertContentWrite,
|
||||
assertPermission,
|
||||
assertPermissionOrClassDevice,
|
||||
classWhere,
|
||||
} from '../../lib/authz/index.js';
|
||||
import fs from 'node:fs';
|
||||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
@@ -17,7 +23,7 @@ import {
|
||||
import { env } from '../../config/env.js';
|
||||
import { prisma } from '../../lib/db.js';
|
||||
import { ApiError } from '../../lib/http.js';
|
||||
import { assertCanManageSubjectContent, requireClassDeviceClassId } from '../../lib/access.js';
|
||||
import { requireClassDeviceClassId } from '../../lib/access.js';
|
||||
import type { TokenPayload } from '../../lib/jwt.js';
|
||||
import type { Prisma } from '../../generated/prisma/client.js';
|
||||
import { emitToClass } from '../../realtime/bus.js';
|
||||
@@ -47,13 +53,20 @@ function dto(row: RecordWithRelations): CoursewareDto {
|
||||
};
|
||||
}
|
||||
export async function teachingClasses(user: TokenPayload) {
|
||||
await assertPermission(user, 'courseware:write');
|
||||
return prisma.class.findMany({
|
||||
where: { archivedYearId: null, courses: { some: { teacherId: user.sub } } },
|
||||
where: {
|
||||
AND: [
|
||||
{ archivedYearId: null, courses: { some: { teacherId: user.sub } } },
|
||||
await classWhere(user, 'courseware:write'),
|
||||
],
|
||||
},
|
||||
select: { id: true, name: true },
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
}
|
||||
async function validateClasses(user: TokenPayload, ids: string[]) {
|
||||
await assertPermission(user, 'courseware:write');
|
||||
const classIds = [...new Set(ids)];
|
||||
const allowed = new Set((await teachingClasses(user)).map((item) => item.id));
|
||||
if (classIds.some((id) => !allowed.has(id))) throw ApiError.forbidden('只能选择自己任教的在读班级');
|
||||
@@ -62,11 +75,12 @@ async function validateClasses(user: TokenPayload, ids: string[]) {
|
||||
where: { classId, teacherId: user.sub },
|
||||
select: { id: true },
|
||||
});
|
||||
await assertCanManageSubjectContent(user, classId, course?.id, '发布课件');
|
||||
await assertContentWrite(user, 'courseware:write', classId, course?.id, '发布课件');
|
||||
}
|
||||
return classIds;
|
||||
}
|
||||
export async function readable(user: TokenPayload, id: string) {
|
||||
export async function readable(user: TokenPayload, id: string, key: 'courseware:read' | 'courseware:write') {
|
||||
await assertPermissionOrClassDevice(user, key);
|
||||
const row = await prisma.courseware.findUnique({ where: { id }, include });
|
||||
if (!row) throw ApiError.notFound('课件不存在');
|
||||
if (user.role === 'CLASS_DEVICE') {
|
||||
@@ -77,17 +91,19 @@ export async function readable(user: TokenPayload, id: string) {
|
||||
return row;
|
||||
}
|
||||
async function owned(user: TokenPayload, id: string) {
|
||||
const row = await readable(user, id);
|
||||
await assertPermission(user, 'courseware:write');
|
||||
const row = await readable(user, id, 'courseware:write');
|
||||
if (row.ownerId !== user.sub) throw ApiError.forbidden('只能修改自己的课件');
|
||||
return row;
|
||||
}
|
||||
export async function getCourseware(user: TokenPayload, id: string) {
|
||||
const item = dto(await readable(user, id));
|
||||
const item = dto(await readable(user, id, 'courseware:read'));
|
||||
if (user.role === 'CLASS_DEVICE')
|
||||
item.classes = item.classes.filter((c) => c.id === requireClassDeviceClassId(user));
|
||||
return item;
|
||||
}
|
||||
export async function listCourseware(user: TokenPayload) {
|
||||
await assertPermissionOrClassDevice(user, 'courseware:read');
|
||||
const classId = user.role === 'CLASS_DEVICE' ? requireClassDeviceClassId(user) : null;
|
||||
const rows = await prisma.courseware.findMany({
|
||||
where: classId
|
||||
@@ -177,6 +193,7 @@ async function assertFolder(user: TokenPayload, folderPath: string) {
|
||||
throw ApiError.badRequest('请选择自己创建的文件夹');
|
||||
}
|
||||
export async function listFolders(user: TokenPayload) {
|
||||
await assertPermission(user, 'courseware:write');
|
||||
const rows = await prisma.coursewareFolder.findMany({
|
||||
where: { ownerId: user.sub },
|
||||
orderBy: { path: 'asc' },
|
||||
@@ -184,6 +201,7 @@ export async function listFolders(user: TokenPayload) {
|
||||
return rows.map((row) => ({ id: row.id, path: row.path }));
|
||||
}
|
||||
export async function createFolder(user: TokenPayload, folderPath: string) {
|
||||
await assertPermission(user, 'courseware:write');
|
||||
const row = await prisma.coursewareFolder.upsert({
|
||||
where: { ownerId_path: { ownerId: user.sub, path: folderPath } },
|
||||
create: { ownerId: user.sub, path: folderPath },
|
||||
@@ -259,7 +277,7 @@ export async function acknowledgeTransfer(user: TokenPayload, pushId: string, de
|
||||
const device = await deviceFor(user, deviceKey);
|
||||
const push = await prisma.coursewarePush.findFirst({ where: { id: pushId, classId: device.classId } });
|
||||
if (!push) throw ApiError.notFound('传输任务不存在');
|
||||
await readable(user, push.coursewareId);
|
||||
await readable(user, push.coursewareId, 'courseware:read');
|
||||
await prisma.coursewareReceipt.upsert({
|
||||
where: { pushId_deviceId: { pushId, deviceId: device.id } },
|
||||
create: { pushId, deviceId: device.id },
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Router } from 'express';
|
||||
import { sendOk } from '../../lib/http.js';
|
||||
import { resolveCurrentWeek, loadTermContext } from '../../lib/term.js';
|
||||
import { authenticate, getAuthUser, requireStaffPermission } from '../../middleware/auth.js';
|
||||
import { authenticate, getAuthUser, requirePermissionOrClassDevice } from '../../middleware/auth.js';
|
||||
import { defineModule } from '../module.types.js';
|
||||
import * as dashboardService from './dashboard.service.js';
|
||||
|
||||
@@ -9,7 +9,7 @@ const router = Router();
|
||||
router.use(authenticate());
|
||||
|
||||
/** GET /api/dashboard/summary - 首页汇总(按角色权限收敛数据范围) */
|
||||
router.get('/summary', requireStaffPermission('dashboard:read'), async (req, res) => {
|
||||
router.get('/summary', requirePermissionOrClassDevice('dashboard:read'), async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
sendOk(res, await dashboardService.getDashboardSummary(user), '获取仪表盘数据成功');
|
||||
});
|
||||
@@ -21,7 +21,7 @@ router.get('/summary', requireStaffPermission('dashboard:read'), async (req, res
|
||||
* 这样周次选择器不会再出现"实际 20 周却给到 30 周"的情况;不带 classId 时退回全局默认值。
|
||||
* 带 classId 时会先校验班级访问权限(见 resolveTermWeeks)。
|
||||
*/
|
||||
router.get('/term', requireStaffPermission('term:read'), async (req, res) => {
|
||||
router.get('/term', requirePermissionOrClassDevice('term:read'), async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const classId = typeof req.query.classId === 'string' && req.query.classId ? req.query.classId : undefined;
|
||||
const maxWeek = await dashboardService.resolveTermWeeks(user, classId);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { PermissionKey } from '@classhelper/shared';
|
||||
import { classIdsFor, loadAuthPrincipal } from '../../lib/authz/index.js';
|
||||
import { activeStudentWhere } from '../../lib/students.js';
|
||||
import type { DashboardSummary, HomeworkDto, NotificationDto } from '@classhelper/shared';
|
||||
import {
|
||||
assertClassAccess,
|
||||
classScopeIdFilter,
|
||||
classScopeWhere,
|
||||
isClassDevice,
|
||||
resolveClassScope,
|
||||
requireClassDeviceClassId,
|
||||
@@ -25,7 +25,7 @@ const creatorSelect = { select: { id: true, name: true, username: true } } as co
|
||||
* - 都不传:全局默认(getMaxTermWeeks 兜底)。
|
||||
*/
|
||||
export async function resolveTermWeeks(user: TokenPayload, classId?: string): Promise<number> {
|
||||
if (classId) await assertClassAccess(user, classId);
|
||||
if (classId) await assertClassAccess(user, classId, 'term:read');
|
||||
const targetClassId = classId ?? (isClassDevice(user) ? requireClassDeviceClassId(user) : undefined);
|
||||
if (!targetClassId) return getMaxTermWeeks();
|
||||
const record = await prisma.class.findUnique({
|
||||
@@ -42,10 +42,38 @@ export async function resolveTermWeeks(user: TokenPayload, classId?: string): Pr
|
||||
export async function getDashboardSummary(user: TokenPayload): Promise<DashboardSummary> {
|
||||
const device = isClassDevice(user);
|
||||
// 班级端强制限定在自己班级,避免越权统计
|
||||
const scope = await resolveClassScope(user, device ? requireClassDeviceClassId(user) : undefined);
|
||||
const scope = await resolveClassScope(
|
||||
user,
|
||||
device ? requireClassDeviceClassId(user) : undefined,
|
||||
'dashboard:read',
|
||||
);
|
||||
// 子表按 classId 过滤;Class 表本身按主键 id 过滤
|
||||
const where = classScopeWhere(scope);
|
||||
const classWhere = classScopeIdFilter(scope);
|
||||
const allowed = scope.mode === 'in' ? scope.classIds : await classIdsFor(user, 'dashboard:read');
|
||||
const auth = await loadAuthPrincipal(user);
|
||||
async function segment(key: PermissionKey) {
|
||||
const ids = device ? allowed : (await classIdsFor(user, key)).filter((id) => allowed.includes(id));
|
||||
return { classId: { in: ids } };
|
||||
}
|
||||
const [classes, students, courses, schedules, homeworks, notifications, grades] = await Promise.all(
|
||||
[
|
||||
'class:read',
|
||||
'roster:read',
|
||||
'schedule:read',
|
||||
'schedule:read',
|
||||
'homework:read',
|
||||
'notification:read',
|
||||
'grade:read',
|
||||
].map((key) => segment(key as PermissionKey)),
|
||||
);
|
||||
const sections = {
|
||||
classes: device || auth.permissions.has('class:read'),
|
||||
students: device || auth.permissions.has('roster:read'),
|
||||
courses: device || auth.permissions.has('schedule:read'),
|
||||
schedules: device || auth.permissions.has('schedule:read'),
|
||||
homeworks: device || auth.permissions.has('homework:read'),
|
||||
notifications: device || auth.permissions.has('notification:read'),
|
||||
grades: device || auth.permissions.has('grade:read'),
|
||||
};
|
||||
// ClassHelper 班级端以全班学生为范围
|
||||
const personalIds = await resolvePersonalIds(user);
|
||||
const personalWhere = personalIdWhere(personalIds);
|
||||
@@ -64,27 +92,27 @@ export async function getDashboardSummary(user: TokenPayload): Promise<Dashboard
|
||||
recentHomeworks,
|
||||
] = await Promise.all([
|
||||
// 学生数只算在读的:已毕业/转出的学生不属于"本班现在有多少人"
|
||||
prisma.class.count({ where: classWhere }),
|
||||
prisma.student.count({ where: { ...where, ...activeStudentWhere(), archivedYearId: null } }),
|
||||
prisma.course.count({ where }),
|
||||
prisma.classPlan.count({ where }),
|
||||
prisma.homework.count({ where }),
|
||||
prisma.notification.count({ where }),
|
||||
prisma.grade.count({ where: { ...where, student: activeStudentWhere() } }),
|
||||
prisma.notification.count({ where: { ...where, reads: { none: personalWhere } } }),
|
||||
prisma.class.count({ where: { id: classes.classId } }),
|
||||
prisma.student.count({ where: { ...students, ...activeStudentWhere(), archivedYearId: null } }),
|
||||
prisma.course.count({ where: courses }),
|
||||
prisma.classPlan.count({ where: schedules }),
|
||||
prisma.homework.count({ where: homeworks }),
|
||||
prisma.notification.count({ where: notifications }),
|
||||
prisma.grade.count({ where: { ...grades, student: activeStudentWhere() } }),
|
||||
prisma.notification.count({ where: { ...notifications, reads: { none: personalWhere } } }),
|
||||
device
|
||||
? prisma.homework.count({
|
||||
where: { ...where, statuses: { none: { ...personalWhere, completed: true } } },
|
||||
where: { ...homeworks, statuses: { none: { ...personalWhere, completed: true } } },
|
||||
})
|
||||
: prisma.homework.count({ where }),
|
||||
: prisma.homework.count({ where: homeworks }),
|
||||
prisma.notification.findMany({
|
||||
where,
|
||||
where: notifications,
|
||||
include: { creator: creatorSelect, reads: { where: personalWhere } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 5,
|
||||
}),
|
||||
prisma.homework.findMany({
|
||||
where,
|
||||
where: homeworks,
|
||||
include: {
|
||||
course: courseSelect,
|
||||
creator: creatorSelect,
|
||||
@@ -96,6 +124,7 @@ export async function getDashboardSummary(user: TokenPayload): Promise<Dashboard
|
||||
]);
|
||||
|
||||
return {
|
||||
sections,
|
||||
classCount,
|
||||
studentCount,
|
||||
courseCount,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { requireAdmin } from '../../middleware/auth.js';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { Router } from 'express';
|
||||
import { sendCreated, sendOk } from '../../lib/http.js';
|
||||
import { authenticate, requirePermission } from '../../middleware/auth.js';
|
||||
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
|
||||
import { validate, validatedBody, validatedParams } from '../../middleware/validate.js';
|
||||
import { defineModule } from '../module.types.js';
|
||||
import {
|
||||
@@ -30,46 +31,52 @@ import * as databaseService from './database.service.js';
|
||||
*/
|
||||
const router = Router();
|
||||
|
||||
router.use(authenticate());
|
||||
router.use(authenticate(), requireAdmin());
|
||||
router.use(requirePermission('database:manage'));
|
||||
|
||||
/** GET /api/database/status - 当前数据库状态(连接/版本/大小/各表行数/备份与定时配置) */
|
||||
router.get('/status', async (_req, res) => {
|
||||
sendOk(res, await databaseService.getDatabaseStatus());
|
||||
router.get('/status', async (req, res) => {
|
||||
sendOk(res, await databaseService.forAdministrator(getAuthUser(req)).getDatabaseStatus());
|
||||
});
|
||||
|
||||
/** POST /api/database/test-connection - 测试任意目标库连通性(切换前的前置检查) */
|
||||
router.post('/test-connection', validate({ body: testConnectionSchema }), async (req, res) => {
|
||||
const result = await databaseService.testConnection(validatedBody<TestConnectionInput>(req));
|
||||
const result = await databaseService
|
||||
.forAdministrator(getAuthUser(req))
|
||||
.testConnection(validatedBody<TestConnectionInput>(req));
|
||||
sendOk(res, result, result.ok ? '连接成功' : '连接失败');
|
||||
});
|
||||
|
||||
/** GET /api/database/backups - 备份列表 */
|
||||
router.get('/backups', (_req, res) => {
|
||||
sendOk(res, databaseService.listBackups());
|
||||
router.get('/backups', (req, res) => {
|
||||
sendOk(res, databaseService.forAdministrator(getAuthUser(req)).listBackups());
|
||||
});
|
||||
|
||||
/** POST /api/database/backups - 立即创建一份手动备份 */
|
||||
router.post('/backups', async (_req, res) => {
|
||||
sendCreated(res, await databaseService.createBackup('manual'), '备份已创建');
|
||||
router.post('/backups', async (req, res) => {
|
||||
sendCreated(
|
||||
res,
|
||||
await databaseService.forAdministrator(getAuthUser(req)).createBackup('manual'),
|
||||
'备份已创建',
|
||||
);
|
||||
});
|
||||
|
||||
/** POST /api/database/backups/:name/restore - 从备份恢复(整库覆盖) */
|
||||
router.post('/backups/:name/restore', validate({ params: backupNameParamSchema }), async (req, res) => {
|
||||
const { name } = validatedParams<{ name: string }>(req);
|
||||
sendOk(res, await databaseService.restoreBackup(name), '已从备份恢复');
|
||||
sendOk(res, await databaseService.forAdministrator(getAuthUser(req)).restoreBackup(name), '已从备份恢复');
|
||||
});
|
||||
|
||||
/** DELETE /api/database/backups/:name - 删除备份 */
|
||||
router.delete('/backups/:name', validate({ params: backupNameParamSchema }), async (req, res) => {
|
||||
const { name } = validatedParams<{ name: string }>(req);
|
||||
databaseService.deleteBackup(name);
|
||||
databaseService.forAdministrator(getAuthUser(req)).deleteBackup(name);
|
||||
sendOk(res, { name }, '备份已删除');
|
||||
});
|
||||
|
||||
/** GET /api/database/export - 下载当前库的 JSON 快照 */
|
||||
router.get('/export', async (_req, res) => {
|
||||
const json = await databaseService.exportSnapshotJson();
|
||||
router.get('/export', async (req, res) => {
|
||||
const json = await databaseService.forAdministrator(getAuthUser(req)).exportSnapshotJson();
|
||||
const filename = `classhelper-snapshot-${new Date().toISOString().slice(0, 19).replaceAll(':', '')}.json`;
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
@@ -77,8 +84,8 @@ router.get('/export', async (_req, res) => {
|
||||
});
|
||||
|
||||
/** GET /api/database/sqlite-file - 下载数据库文件(仅 SQLite 模式) */
|
||||
router.get('/sqlite-file', (_req, res) => {
|
||||
const filePath = databaseService.sqliteFilePathForDownload();
|
||||
router.get('/sqlite-file', (req, res) => {
|
||||
const filePath = databaseService.forAdministrator(getAuthUser(req)).sqliteFilePathForDownload();
|
||||
res.setHeader('Content-Type', 'application/octet-stream');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${path.basename(filePath)}"`);
|
||||
res.status(200).send(fs.readFileSync(filePath));
|
||||
@@ -87,33 +94,41 @@ router.get('/sqlite-file', (_req, res) => {
|
||||
/** POST /api/database/import - 导入快照(整库覆盖;base64 随 JSON 请求体) */
|
||||
router.post('/import', validate({ body: importSnapshotSchema }), async (req, res) => {
|
||||
const { data } = validatedBody<ImportSnapshotInput>(req);
|
||||
sendOk(res, await databaseService.importSnapshotBase64(data), '快照已导入(原数据已被覆盖)');
|
||||
sendOk(
|
||||
res,
|
||||
await databaseService.forAdministrator(getAuthUser(req)).importSnapshotBase64(data),
|
||||
'快照已导入(原数据已被覆盖)',
|
||||
);
|
||||
});
|
||||
|
||||
/** GET /api/database/backup-schedule - 定时备份配置 */
|
||||
router.get('/backup-schedule', (_req, res) => {
|
||||
sendOk(res, databaseService.getBackupSchedule());
|
||||
router.get('/backup-schedule', (req, res) => {
|
||||
sendOk(res, databaseService.forAdministrator(getAuthUser(req)).getBackupSchedule());
|
||||
});
|
||||
|
||||
/** PUT /api/database/backup-schedule - 更新定时备份配置(立即生效,重启后仍有效) */
|
||||
router.put('/backup-schedule', validate({ body: backupScheduleSchema }), async (req, res) => {
|
||||
sendOk(
|
||||
res,
|
||||
databaseService.saveSchedulePatch(validatedBody<BackupScheduleInput>(req)),
|
||||
databaseService
|
||||
.forAdministrator(getAuthUser(req))
|
||||
.saveSchedulePatch(validatedBody<BackupScheduleInput>(req)),
|
||||
'定时备份配置已保存',
|
||||
);
|
||||
});
|
||||
|
||||
/** POST /api/database/switch - 一键切换数据库(异步任务,返回 jobId) */
|
||||
router.post('/switch', validate({ body: switchDatabaseSchema }), async (req, res) => {
|
||||
const job = databaseService.startSwitch(validatedBody<SwitchDatabaseInput>(req));
|
||||
const job = databaseService
|
||||
.forAdministrator(getAuthUser(req))
|
||||
.startSwitch(validatedBody<SwitchDatabaseInput>(req));
|
||||
sendCreated(res, { jobId: job.id }, '切换任务已启动');
|
||||
});
|
||||
|
||||
/** GET /api/database/switch/jobs/:id - 查询切换任务进度 */
|
||||
router.get('/switch/jobs/:id', validate({ params: jobIdParamSchema }), async (req, res) => {
|
||||
const { id } = validatedParams<{ id: string }>(req);
|
||||
const job = databaseService.getSwitchJob(id);
|
||||
const job = databaseService.forAdministrator(getAuthUser(req)).getSwitchJob(id);
|
||||
sendOk(res, job, job ? 'ok' : '任务不存在');
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { TokenPayload } from '../../lib/jwt.js';
|
||||
import { assertAdmin } from '../../lib/authz/index.js';
|
||||
import { spawn } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
@@ -795,3 +797,23 @@ async function runSwitch(job: SwitchJob, target: TargetConnectionInput): Promise
|
||||
job.finishedAt = new Date().toISOString();
|
||||
}
|
||||
}
|
||||
|
||||
/** 请求入口必须携带 actor;后台调度只调用模块内部维护函数。 */
|
||||
export function forAdministrator(actor: TokenPayload) {
|
||||
assertAdmin(actor);
|
||||
return {
|
||||
getDatabaseStatus,
|
||||
testConnection,
|
||||
listBackups,
|
||||
createBackup,
|
||||
restoreBackup,
|
||||
deleteBackup,
|
||||
exportSnapshotJson,
|
||||
importSnapshotBase64,
|
||||
sqliteFilePathForDownload,
|
||||
getBackupSchedule,
|
||||
saveSchedulePatch,
|
||||
startSwitch,
|
||||
getSwitchJob,
|
||||
};
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Router } from 'express';
|
||||
import { sendCreated, sendOk } from '../../lib/http.js';
|
||||
import { ApiError, sendCreated, sendOk } from '../../lib/http.js';
|
||||
import { idParamSchema } from '../../lib/schemas.js';
|
||||
import {
|
||||
authenticate,
|
||||
getAuthUser,
|
||||
requirePermission,
|
||||
requireStaffPermission,
|
||||
requirePermissionOrClassDevice,
|
||||
} from '../../middleware/auth.js';
|
||||
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
|
||||
import { defineModule } from '../module.types.js';
|
||||
@@ -31,9 +31,10 @@ import * as gradeService from './grades.service.js';
|
||||
const router = Router();
|
||||
router.use(authenticate());
|
||||
|
||||
router.get('/overview', async (req, res) => {
|
||||
router.get('/overview', requirePermissionOrClassDevice('grade:read'), async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const classId = user.classId ?? user.sub;
|
||||
const classId = user.role === 'CLASS_DEVICE' ? (user.classId ?? user.sub) : String(req.query.classId ?? '');
|
||||
if (!classId) throw ApiError.badRequest('请指定班级');
|
||||
const cls = await (await import('../../lib/db.js')).prisma.class.findUnique({ where: { id: classId } });
|
||||
sendOk(
|
||||
res,
|
||||
@@ -42,14 +43,19 @@ router.get('/overview', async (req, res) => {
|
||||
: await gradeService.collectiveAnalysis(user, classId),
|
||||
);
|
||||
});
|
||||
router.get('/collective', validate({ query: examListQuerySchema }), async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const { classId } = validatedQuery<{ classId: string }>(req);
|
||||
sendOk(res, await gradeService.collectiveAnalysis(user, classId));
|
||||
});
|
||||
router.get(
|
||||
'/collective',
|
||||
requirePermissionOrClassDevice('grade:read'),
|
||||
validate({ query: examListQuerySchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const { classId } = validatedQuery<{ classId: string }>(req);
|
||||
sendOk(res, await gradeService.collectiveAnalysis(user, classId));
|
||||
},
|
||||
);
|
||||
|
||||
/** GET /api/grades/my - ClassHelper 班级端查看本班成绩(教师/管理员返回空数组) */
|
||||
router.get('/my', async (req, res) => {
|
||||
router.get('/my', requirePermissionOrClassDevice('grade:read'), async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
sendOk(res, await gradeService.listMyGrades(user), '获取本班成绩成功');
|
||||
});
|
||||
@@ -65,7 +71,7 @@ router.get('/my', async (req, res) => {
|
||||
*/
|
||||
router.get(
|
||||
'/student/:studentNo',
|
||||
requireStaffPermission('student:grade-query'),
|
||||
requirePermissionOrClassDevice('student:grade-query'),
|
||||
validate({ params: studentGradeParamSchema, query: studentGradeQuerySchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -93,11 +99,16 @@ router.get(
|
||||
* **刻意不挂权限键**:ClassHelper 班级端要用它列出可选考试(班级端的有效权限是空集,
|
||||
* 挂 `grade:read` 会把教室机器挡在门外)。边界在 service 里:`assertClassAccess` + 班级开关。
|
||||
*/
|
||||
router.get('/exams', validate({ query: examListQuerySchema }), async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const { classId } = validatedQuery<{ classId: string }>(req);
|
||||
sendOk(res, await gradeService.listExams(user, classId), '获取考试列表成功');
|
||||
});
|
||||
router.get(
|
||||
'/exams',
|
||||
requirePermissionOrClassDevice('grade:read'),
|
||||
validate({ query: examListQuerySchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const { classId } = validatedQuery<{ classId: string }>(req);
|
||||
sendOk(res, await gradeService.listExams(user, classId), '获取考试列表成功');
|
||||
},
|
||||
);
|
||||
|
||||
/** PUT /api/grades/exams - 设置"这次考试是否把所有科目相加做总分排名"(教师/管理员) */
|
||||
router.put(
|
||||
@@ -121,11 +132,16 @@ router.put(
|
||||
* 同样不挂权限键(班级端要看),边界在 service 里 —— 与按学号查明细共用
|
||||
* `canQueryStudentGrades`:班级端受按班开关控制,教师端不受影响。
|
||||
*/
|
||||
router.get('/ranking', validate({ query: examRankingQuerySchema }), async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const { classId, examName } = validatedQuery<{ classId: string; examName: string }>(req);
|
||||
sendOk(res, await gradeService.getExamRanking(user, classId, examName), '获取考试排名成功');
|
||||
});
|
||||
router.get(
|
||||
'/ranking',
|
||||
requirePermissionOrClassDevice('grade:read'),
|
||||
validate({ query: examRankingQuerySchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const { classId, examName } = validatedQuery<{ classId: string; examName: string }>(req);
|
||||
sendOk(res, await gradeService.getExamRanking(user, classId, examName), '获取考试排名成功');
|
||||
},
|
||||
);
|
||||
|
||||
/** GET /api/grades?classId=&courseId=&studentId=&examName= - 成绩列表(教师) */
|
||||
router.get(
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { assertManage, assertPermissionOrClassDevice } from '../../lib/authz/index.js';
|
||||
import { assertContentWrite } from '../../lib/authz/index.js';
|
||||
import type { CollectiveGradeAnalysisDto as ContractCollectiveGradeAnalysisDto } from '@classhelper/shared';
|
||||
import {
|
||||
SOCKET_EVENTS,
|
||||
@@ -14,7 +16,6 @@ import {
|
||||
type StudentGradeDetailDto,
|
||||
} from '@classhelper/shared';
|
||||
import {
|
||||
assertCanManageSubjectContent,
|
||||
assertClassAccess,
|
||||
classScopeWhere,
|
||||
isClassDevice,
|
||||
@@ -57,16 +58,10 @@ const classSelect = { select: { name: true } } as const;
|
||||
const include = { course: courseSelect, student: studentSelect, class: classSelect } as const;
|
||||
|
||||
async function assertDetailQuery(user: TokenPayload, classId: string): Promise<void> {
|
||||
await assertClassAccess(user, classId);
|
||||
await assertClassAccess(user, classId, 'grade:read');
|
||||
const cls = await prisma.class.findUniqueOrThrow({ where: { id: classId } });
|
||||
if (
|
||||
!canQueryStudentGrades({
|
||||
classRole: await resolveUserClassRole(user, classId),
|
||||
gradeQueryEnabled: cls.studentGradeQueryEnabled,
|
||||
})
|
||||
) {
|
||||
if (isClassDevice(user) && !cls.studentGradeQueryEnabled)
|
||||
throw ApiError.forbidden('本班只开放集体分析,未开放个人成绩');
|
||||
}
|
||||
}
|
||||
|
||||
/** 服务端聚合;不返回极值、个人标识或可由补集反推的稀疏分段。 */
|
||||
@@ -74,7 +69,7 @@ export async function collectiveAnalysis(
|
||||
user: TokenPayload,
|
||||
classId: string,
|
||||
): Promise<ContractCollectiveGradeAnalysisDto> {
|
||||
await assertClassAccess(user, classId);
|
||||
await assertClassAccess(user, classId, 'grade:read');
|
||||
const rows = await prisma.grade.findMany({
|
||||
where: { classId, student: activeStudentWhere() },
|
||||
select: {
|
||||
@@ -139,12 +134,17 @@ export interface ListGradeOptions {
|
||||
* 教师/管理员调用时返回空数组(他们没有"自己的成绩"),请用 `listGrades`。
|
||||
*/
|
||||
export async function listMyGrades(user: TokenPayload): Promise<GradeDto[]> {
|
||||
await assertPermissionOrClassDevice(user, 'grade:read');
|
||||
if (isClassDevice(user)) await assertDetailQuery(user, user.classId ?? user.sub);
|
||||
const studentIds = await resolvePersonalIds(user);
|
||||
if (studentIds.length === 0) return [];
|
||||
|
||||
const grades = await prisma.grade.findMany({
|
||||
where: { studentId: { in: studentIds }, student: activeStudentWhere() },
|
||||
where: {
|
||||
classId: user.classId ?? user.sub,
|
||||
studentId: { in: studentIds },
|
||||
student: activeStudentWhere(),
|
||||
},
|
||||
include,
|
||||
orderBy: [{ publishedAt: 'desc' }, { examName: 'asc' }],
|
||||
take: 1000,
|
||||
@@ -154,7 +154,7 @@ export async function listMyGrades(user: TokenPayload): Promise<GradeDto[]> {
|
||||
|
||||
/** 教师 / 管理员查看成绩(按可访问班级过滤) */
|
||||
export async function listGrades(user: TokenPayload, options: ListGradeOptions): Promise<GradeDto[]> {
|
||||
const scope = await resolveClassScope(user, options.classId);
|
||||
const scope = await resolveClassScope(user, options.classId, 'grade:read');
|
||||
if (isClassDevice(user)) await assertDetailQuery(user, user.classId ?? user.sub);
|
||||
const grades = await prisma.grade.findMany({
|
||||
where: {
|
||||
@@ -220,11 +220,11 @@ export async function getGradeStats(user: TokenPayload, options: ListGradeOption
|
||||
/**
|
||||
* 录入一条成绩。
|
||||
*
|
||||
* 权限走 `assertCanManageSubjectContent`:**科任老师只能录自己任教科目**的成绩,
|
||||
* 权限走 ``:**科任老师只能录自己任教科目**的成绩,
|
||||
* 班主任若不教这一科同样被拒;不指定科目(courseId 为空)时只有班主任/管理员可录。
|
||||
*/
|
||||
export async function createGrade(user: TokenPayload, input: CreateGradeInput): Promise<GradeDto> {
|
||||
await assertCanManageSubjectContent(user, input.classId, input.courseId ?? null, '录入成绩');
|
||||
await assertContentWrite(user, 'grade:write', input.classId, input.courseId ?? null, '录入成绩');
|
||||
await assertStudentInClass(input.studentId, input.classId);
|
||||
if (input.courseId) await assertCourseInClass(input.courseId, input.classId);
|
||||
|
||||
@@ -249,7 +249,7 @@ export async function createGrade(user: TokenPayload, input: CreateGradeInput):
|
||||
|
||||
/** 批量录入:同一个考试一次提交整班成绩 */
|
||||
export async function bulkCreateGrades(user: TokenPayload, input: BulkCreateGradeInput): Promise<GradeDto[]> {
|
||||
await assertCanManageSubjectContent(user, input.classId, input.courseId ?? null, '录入成绩');
|
||||
await assertContentWrite(user, 'grade:write', input.classId, input.courseId ?? null, '录入成绩');
|
||||
if (input.courseId) await assertCourseInClass(input.courseId, input.classId);
|
||||
|
||||
const publishedAt = parseOptionalDate(input.publishedAt) ?? new Date();
|
||||
@@ -292,8 +292,11 @@ export async function updateGrade(
|
||||
if (!current) throw ApiError.notFound('成绩记录不存在');
|
||||
|
||||
// 改的是哪条成绩,就按那条成绩所属的班级 + 科目判权限
|
||||
await assertContentWrite(user, 'grade:write', current.classId, current.courseId, '修改成绩', {
|
||||
allowClassDevice: false,
|
||||
});
|
||||
const nextCourseId = input.courseId === undefined ? current.courseId : (input.courseId ?? null);
|
||||
await assertCanManageSubjectContent(user, current.classId, nextCourseId, '修改成绩');
|
||||
await assertContentWrite(user, 'grade:write', current.classId, nextCourseId, '修改成绩');
|
||||
if (nextCourseId) await assertCourseInClass(nextCourseId, current.classId);
|
||||
|
||||
const totalScore = input.totalScore ?? current.totalScore;
|
||||
@@ -331,7 +334,7 @@ export async function updateGrade(
|
||||
export async function deleteGrade(user: TokenPayload, gradeId: string): Promise<void> {
|
||||
const current = await prisma.grade.findFirst({ where: { id: gradeId, student: activeStudentWhere() } });
|
||||
if (!current) throw ApiError.notFound('成绩记录不存在');
|
||||
await assertCanManageSubjectContent(user, current.classId, current.courseId, '删除成绩');
|
||||
await assertContentWrite(user, 'grade:write', current.classId, current.courseId, '删除成绩');
|
||||
|
||||
await prisma.grade.delete({ where: { id: gradeId } });
|
||||
await emitGradeUpdated(current.classId, {
|
||||
@@ -369,7 +372,7 @@ export async function updateGradeLevels(
|
||||
// 逐条判权限:可能跨多个班级/科目(管理员在成绩页多选修改时会这样)
|
||||
for (const item of input.items) {
|
||||
const record = byId.get(item.id)!;
|
||||
await assertCanManageSubjectContent(user, record.classId, record.courseId, '修改成绩等级');
|
||||
await assertContentWrite(user, 'grade:write', record.classId, record.courseId, '修改成绩等级');
|
||||
}
|
||||
|
||||
await prisma.$transaction(
|
||||
@@ -387,7 +390,7 @@ export async function updateGradeLevels(
|
||||
|
||||
// 整批重算
|
||||
const classId = input.classId!;
|
||||
await assertCanManageSubjectContent(user, classId, input.courseId ?? null, '调整成绩等级');
|
||||
await assertContentWrite(user, 'grade:write', classId, input.courseId ?? null, '调整成绩等级');
|
||||
const levelType = input.levelType!;
|
||||
if (levelType === 'custom') throw ApiError.badRequest('自定义等级不能由分数推算,请逐条填写等级');
|
||||
const records = await prisma.grade.findMany({
|
||||
@@ -433,7 +436,7 @@ export async function getStudentGradeDetail(
|
||||
requestedClassId?: string,
|
||||
): Promise<StudentGradeDetailDto> {
|
||||
if (isClassDevice(user)) await assertDetailQuery(user, user.classId ?? user.sub);
|
||||
const scope = await resolveClassScope(user, requestedClassId);
|
||||
const scope = await resolveClassScope(user, requestedClassId, 'student:grade-query');
|
||||
|
||||
const student = await prisma.student.findFirst({
|
||||
where: { ...activeStudentWhere(), studentNo: normalizeStudentNo(studentNo), ...classScopeWhere(scope) },
|
||||
@@ -458,7 +461,7 @@ export async function getStudentGradeDetail(
|
||||
}
|
||||
|
||||
const grades = await prisma.grade.findMany({
|
||||
where: { studentId: student.id },
|
||||
where: { studentId: student.id, ...classScopeWhere(scope) },
|
||||
include,
|
||||
orderBy: [{ publishedAt: 'desc' }, { examName: 'asc' }],
|
||||
take: 500,
|
||||
@@ -488,7 +491,7 @@ export async function getStudentGradeDetailById(
|
||||
});
|
||||
if (!student || ('status' in student && !isActiveStudent(student as { status: string })))
|
||||
throw ApiError.notFound('学生不存在');
|
||||
await assertClassAccess(user, student.classId ?? '');
|
||||
await assertClassAccess(user, student.classId ?? '', 'student:grade-query');
|
||||
return getStudentGradeDetail(user, student.studentNo);
|
||||
}
|
||||
|
||||
@@ -579,7 +582,8 @@ async function assertCourseInClass(courseId: string, classId: string): Promise<v
|
||||
* 只列 `Exam` 会让刚导进去的那次考试在界面上根本找不到。
|
||||
*/
|
||||
export async function listExams(user: TokenPayload, classId: string): Promise<ExamDto[]> {
|
||||
await assertClassAccess(user, classId);
|
||||
if (isClassDevice(user)) await assertDetailQuery(user, classId);
|
||||
await assertClassAccess(user, classId, 'grade:read');
|
||||
const [groups, exams] = await Promise.all([
|
||||
prisma.grade.groupBy({
|
||||
by: ['examName'],
|
||||
@@ -619,7 +623,7 @@ export async function setExamCountInTotal(
|
||||
name: string,
|
||||
countInTotal: boolean,
|
||||
): Promise<ExamDto> {
|
||||
await assertClassAccess(user, classId);
|
||||
await assertManage(user, 'grade:write', classId);
|
||||
if (!isStaff(user)) throw ApiError.forbidden('只有教师/管理员可以设置考试口径');
|
||||
|
||||
const row = await prisma.exam.upsert({
|
||||
@@ -666,7 +670,7 @@ export async function getExamRanking(
|
||||
classId: string,
|
||||
examName: string,
|
||||
): Promise<ExamRankingDto> {
|
||||
await assertClassAccess(user, classId);
|
||||
await assertClassAccess(user, classId, 'grade:read');
|
||||
const classRecord = await prisma.class.findUnique({
|
||||
where: { id: classId },
|
||||
select: { name: true, studentGradeQueryEnabled: true },
|
||||
|
||||
@@ -4,7 +4,7 @@ import { idParamSchema } from '../../lib/schemas.js';
|
||||
import {
|
||||
authenticate,
|
||||
getAuthUser,
|
||||
requireStaffPermission,
|
||||
requirePermissionOrClassDevice,
|
||||
requirePermission,
|
||||
} from '../../middleware/auth.js';
|
||||
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
|
||||
@@ -32,7 +32,7 @@ router.use(authenticate());
|
||||
/** GET /api/homeworks?classId=&courseId=&pendingOnly=&keyword= - 作业列表 */
|
||||
router.get(
|
||||
'/',
|
||||
requireStaffPermission('homework:read'),
|
||||
requirePermissionOrClassDevice('homework:read'),
|
||||
validate({ query: listHomeworksQuerySchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -54,7 +54,7 @@ router.get(
|
||||
*/
|
||||
router.get(
|
||||
'/days',
|
||||
requireStaffPermission('homework:read'),
|
||||
requirePermissionOrClassDevice('homework:read'),
|
||||
validate({ query: listHomeworkDaysQuerySchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -65,7 +65,7 @@ router.get(
|
||||
|
||||
router.get(
|
||||
'/:id',
|
||||
requireStaffPermission('homework:read'),
|
||||
requirePermissionOrClassDevice('homework:read'),
|
||||
validate({ params: idParamSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -82,7 +82,7 @@ router.get(
|
||||
*/
|
||||
router.post(
|
||||
'/',
|
||||
requireStaffPermission('homework:write'),
|
||||
requirePermissionOrClassDevice('homework:write'),
|
||||
validate({ body: createHomeworkSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -99,7 +99,7 @@ router.post(
|
||||
*
|
||||
* 与 POST / 的区别:批量发布是 Web 管理端的"多班同发"工作流,因此这里挂 `homework:write`;
|
||||
* 教室机器的班级账号仍走单班录入(POST /,权限在服务层判,见 AGENTS.md 的现行工程约定)。
|
||||
* 每个班的科目归属依然由 createHomework 里的 assertCanManageSubjectContent 逐个判定 ——
|
||||
* 每个班的科目归属依然由 createHomework 里的 逐个判定 ——
|
||||
* 批量不是绕过科目门的后门(§5 第 5 条)。
|
||||
*/
|
||||
router.post(
|
||||
@@ -145,7 +145,7 @@ router.delete(
|
||||
/** PATCH /api/homeworks/:id/status - 标记完成 / 取消完成 */
|
||||
router.patch(
|
||||
'/:id/status',
|
||||
requireStaffPermission('homework:write'),
|
||||
requirePermissionOrClassDevice('homework:write'),
|
||||
validate({ params: idParamSchema, body: updateHomeworkStatusSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -162,7 +162,7 @@ router.patch(
|
||||
/** GET /api/homeworks/:id/submissions - 提交名单(未交名单功能) */
|
||||
router.get(
|
||||
'/:id/submissions',
|
||||
requireStaffPermission('homework:read'),
|
||||
requirePermissionOrClassDevice('homework:read'),
|
||||
validate({ params: idParamSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -175,7 +175,7 @@ router.get(
|
||||
/** PATCH /api/homeworks/:id/submissions - 勾选未交名单(其余学生一律视为已交) */
|
||||
router.patch(
|
||||
'/:id/submissions',
|
||||
requireStaffPermission('homework:write'),
|
||||
requirePermissionOrClassDevice('homework:write'),
|
||||
validate({ params: idParamSchema, body: updateHomeworkSubmissionsSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { assertStaffPermission } from '../../lib/permissions.js';
|
||||
import { assertContentWrite } from '../../lib/authz/index.js';
|
||||
import { assertPermissionOrClassDevice } from '../../lib/permissions.js';
|
||||
import {
|
||||
SOCKET_EVENTS,
|
||||
dayKeyLocal,
|
||||
@@ -12,13 +13,7 @@ import {
|
||||
type HomeworkSubmissionDto,
|
||||
type HomeworkSubmissionsDto,
|
||||
} from '@classhelper/shared';
|
||||
import {
|
||||
assertCanManageSubjectContent,
|
||||
assertClassAccess,
|
||||
classScopeWhere,
|
||||
isClassDevice,
|
||||
resolveClassScope,
|
||||
} from '../../lib/access.js';
|
||||
import { assertClassAccess, classScopeWhere, isClassDevice, resolveClassScope } from '../../lib/access.js';
|
||||
import { normalizeBulkClassIds, summarizeBulk, toBulkFailure } from '../../lib/bulk.js';
|
||||
import { activeStudentWhere, isActiveStudent } from '../../lib/students.js';
|
||||
import { prisma } from '../../lib/db.js';
|
||||
@@ -56,8 +51,8 @@ export async function listHomeworks(
|
||||
user: TokenPayload,
|
||||
options: ListHomeworkOptions,
|
||||
): Promise<HomeworkDto[]> {
|
||||
await assertStaffPermission(user, 'homework:read');
|
||||
const scope = await resolveClassScope(user, options.classId);
|
||||
await assertPermissionOrClassDevice(user, 'homework:read');
|
||||
const scope = await resolveClassScope(user, options.classId, 'homework:read');
|
||||
const device = isClassDevice(user);
|
||||
// ClassHelper 班级端以全班学生为范围;教师/管理员没有"自己的完成状态"
|
||||
const personalIds = device ? await resolvePersonalIds(user) : [];
|
||||
@@ -97,8 +92,8 @@ export async function listHomeworkDays(
|
||||
user: TokenPayload,
|
||||
options: { classId?: string; from?: string; to?: string; days?: number },
|
||||
): Promise<HomeworkDaysDto> {
|
||||
await assertStaffPermission(user, 'homework:read');
|
||||
const scope = await resolveClassScope(user, options.classId);
|
||||
await assertPermissionOrClassDevice(user, 'homework:read');
|
||||
const scope = await resolveClassScope(user, options.classId, 'homework:read');
|
||||
const today = dayKeyLocal(new Date());
|
||||
const to = options.to ?? today;
|
||||
const from = options.from ?? shiftDayKey(to, -(options.days ?? 60) + 1);
|
||||
@@ -122,7 +117,7 @@ export async function listHomeworkDays(
|
||||
}
|
||||
|
||||
export async function getHomework(user: TokenPayload, homeworkId: string): Promise<HomeworkDto> {
|
||||
await assertStaffPermission(user, 'homework:read');
|
||||
await assertPermissionOrClassDevice(user, 'homework:read');
|
||||
const device = isClassDevice(user);
|
||||
const personalIds = device ? await resolvePersonalIds(user) : [];
|
||||
const homework = await prisma.homework.findUnique({
|
||||
@@ -136,7 +131,7 @@ export async function getHomework(user: TokenPayload, homeworkId: string): Promi
|
||||
},
|
||||
});
|
||||
if (!homework) throw ApiError.notFound('作业不存在');
|
||||
await assertClassAccess(user, homework.classId);
|
||||
await assertClassAccess(user, homework.classId, 'homework:read');
|
||||
|
||||
return toHomeworkDto(homework, {
|
||||
studentIds: device ? personalIds : [],
|
||||
@@ -149,15 +144,15 @@ export async function getHomework(user: TokenPayload, homeworkId: string): Promi
|
||||
/**
|
||||
* 发布作业并实时推送到班级房间。
|
||||
*
|
||||
* 权限走 `assertCanManageSubjectContent`(`allowClassDevice: true`):
|
||||
* 权限走 ``(`allowClassDevice: true`):
|
||||
* - **选了科目** → 必须是该科的任课老师(班主任不教这一科也发不了);
|
||||
* - **没选科目** → 本班班主任或管理员;
|
||||
* - **ClassHelper 班级端** → 可以录(老师在教室电脑上顺手记一条是常见需求,
|
||||
* 见 AGENTS.md 的现行工程约定),班级端录入的作业归属该班班主任。
|
||||
*/
|
||||
export async function createHomework(user: TokenPayload, input: CreateHomeworkInput): Promise<HomeworkDto> {
|
||||
await assertStaffPermission(user, 'homework:write');
|
||||
await assertCanManageSubjectContent(user, input.classId, input.courseId ?? null, '布置作业', {
|
||||
await assertPermissionOrClassDevice(user, 'homework:write');
|
||||
await assertContentWrite(user, 'homework:write', input.classId, input.courseId ?? null, '布置作业', {
|
||||
allowClassDevice: true,
|
||||
});
|
||||
if (input.courseId) await assertCourseInClass(input.courseId, input.classId);
|
||||
@@ -212,7 +207,7 @@ export async function bulkCreateHomeworks(
|
||||
user: TokenPayload,
|
||||
input: BulkCreateHomeworkRequest,
|
||||
): Promise<BulkPublishResult> {
|
||||
await assertStaffPermission(user, 'homework:write');
|
||||
await assertPermissionOrClassDevice(user, 'homework:write');
|
||||
const classIds = normalizeBulkClassIds(input.classIds);
|
||||
const results: BulkPublishClassResult[] = [];
|
||||
|
||||
@@ -261,13 +256,16 @@ export async function updateHomework(
|
||||
homeworkId: string,
|
||||
input: UpdateHomeworkInput,
|
||||
): Promise<HomeworkDto> {
|
||||
await assertStaffPermission(user, 'homework:write');
|
||||
await assertPermissionOrClassDevice(user, 'homework:write');
|
||||
const current = await prisma.homework.findUnique({ where: { id: homeworkId } });
|
||||
if (!current) throw ApiError.notFound('作业不存在');
|
||||
|
||||
// 改科目等于把这条作业挪到另一个科目的权限域下,两端都要判
|
||||
await assertContentWrite(user, 'homework:write', current.classId, current.courseId, '修改作业', {
|
||||
allowClassDevice: true,
|
||||
});
|
||||
const nextCourseId = input.courseId === undefined ? current.courseId : (input.courseId ?? null);
|
||||
await assertCanManageSubjectContent(user, current.classId, nextCourseId, '修改作业', {
|
||||
await assertContentWrite(user, 'homework:write', current.classId, nextCourseId, '修改作业', {
|
||||
allowClassDevice: true,
|
||||
});
|
||||
if (nextCourseId) await assertCourseInClass(nextCourseId, current.classId);
|
||||
@@ -294,13 +292,13 @@ export async function updateHomework(
|
||||
}
|
||||
|
||||
export async function deleteHomework(user: TokenPayload, homeworkId: string): Promise<void> {
|
||||
await assertStaffPermission(user, 'homework:write');
|
||||
await assertPermissionOrClassDevice(user, 'homework:write');
|
||||
const current = await prisma.homework.findUnique({
|
||||
where: { id: homeworkId },
|
||||
include: { course: courseSelect, creator: creatorSelect },
|
||||
});
|
||||
if (!current) throw ApiError.notFound('作业不存在');
|
||||
await assertCanManageSubjectContent(user, current.classId, current.courseId, '删除作业', {
|
||||
await assertContentWrite(user, 'homework:write', current.classId, current.courseId, '删除作业', {
|
||||
allowClassDevice: true,
|
||||
});
|
||||
|
||||
@@ -323,11 +321,13 @@ export async function updateHomeworkStatus(
|
||||
homeworkId: string,
|
||||
input: UpdateHomeworkStatusInput,
|
||||
): Promise<HomeworkStatusDto> {
|
||||
await assertStaffPermission(user, 'homework:write');
|
||||
await assertPermissionOrClassDevice(user, 'homework:write');
|
||||
const homework = await prisma.homework.findUnique({ where: { id: homeworkId } });
|
||||
if (!homework) throw ApiError.notFound('作业不存在');
|
||||
|
||||
await assertClassAccess(user, homework.classId);
|
||||
await assertContentWrite(user, 'homework:write', homework.classId, homework.courseId, '维护作业提交名单', {
|
||||
allowClassDevice: true,
|
||||
});
|
||||
|
||||
if (isClassDevice(user)) {
|
||||
const studentIds = await resolvePersonalIds(user);
|
||||
@@ -417,13 +417,15 @@ export async function listHomeworkSubmissions(
|
||||
user: TokenPayload,
|
||||
homeworkId: string,
|
||||
): Promise<HomeworkSubmissionsDto> {
|
||||
await assertStaffPermission(user, 'homework:read');
|
||||
await assertPermissionOrClassDevice(user, 'homework:write');
|
||||
const homework = await prisma.homework.findUnique({
|
||||
where: { id: homeworkId },
|
||||
select: { id: true, classId: true },
|
||||
select: { id: true, classId: true, courseId: true },
|
||||
});
|
||||
if (!homework) throw ApiError.notFound('作业不存在');
|
||||
await assertClassAccess(user, homework.classId);
|
||||
await assertContentWrite(user, 'homework:write', homework.classId, homework.courseId, '维护作业提交名单', {
|
||||
allowClassDevice: true,
|
||||
});
|
||||
assertCanManageSubmissions(user);
|
||||
return buildSubmissions(homework.classId, homeworkId);
|
||||
}
|
||||
@@ -437,13 +439,15 @@ export async function updateHomeworkSubmissions(
|
||||
homeworkId: string,
|
||||
input: UpdateHomeworkSubmissionsInput,
|
||||
): Promise<HomeworkSubmissionsDto> {
|
||||
await assertStaffPermission(user, 'homework:write');
|
||||
await assertPermissionOrClassDevice(user, 'homework:write');
|
||||
const homework = await prisma.homework.findUnique({
|
||||
where: { id: homeworkId },
|
||||
select: { id: true, classId: true },
|
||||
select: { id: true, classId: true, courseId: true },
|
||||
});
|
||||
if (!homework) throw ApiError.notFound('作业不存在');
|
||||
await assertClassAccess(user, homework.classId);
|
||||
await assertContentWrite(user, 'homework:write', homework.classId, homework.courseId, '维护作业提交名单', {
|
||||
allowClassDevice: true,
|
||||
});
|
||||
assertCanManageSubmissions(user);
|
||||
|
||||
const students = await prisma.student.findMany({
|
||||
|
||||
@@ -6,7 +6,7 @@ import { idParamSchema } from '../../lib/schemas.js';
|
||||
import {
|
||||
authenticate,
|
||||
getAuthUser,
|
||||
requireStaffPermission,
|
||||
requirePermissionOrClassDevice,
|
||||
requirePermission,
|
||||
requireAnyPermission,
|
||||
} from '../../middleware/auth.js';
|
||||
@@ -115,7 +115,7 @@ router.post(
|
||||
const input = validatedBody<TableCommitInput>(req);
|
||||
await assertImportKindAllowed(input.kind, getAuthUser(req));
|
||||
// 教师名单与班级无关;成绩/学生名单必须带班级,否则拒绝(避免写到"全局")
|
||||
if (input.kind !== 'teachers' && input.kind !== 'classTeachers' && !input.classId) {
|
||||
if (input.kind === 'grades' && !input.classId) {
|
||||
throw ApiError.badRequest('请先选择要导入的班级');
|
||||
}
|
||||
const result = await commitTable(getAuthUser(req), input);
|
||||
@@ -147,7 +147,7 @@ router.post(
|
||||
/** GET /api/imports/time-layout?classId= - 查看该班已导入的时间配置 */
|
||||
router.get(
|
||||
'/time-layout',
|
||||
requireStaffPermission('schedule:read'),
|
||||
requirePermissionOrClassDevice('schedule:read'),
|
||||
validate({ query: timeLayoutQuerySchema }),
|
||||
async (req, res) => {
|
||||
const { classId } = validatedQuery<{ classId: string }>(req);
|
||||
@@ -161,7 +161,7 @@ router.get(
|
||||
*/
|
||||
router.post(
|
||||
'/time-layout',
|
||||
requirePermission('timetable:manage', 'import:schedule'),
|
||||
requirePermission('schedule:write', 'import:schedule'),
|
||||
validate({ body: timeLayoutImportSchema }),
|
||||
async (req, res) => {
|
||||
const result = await importTimeLayout(getAuthUser(req), validatedBody<TimeLayoutImportInput>(req));
|
||||
@@ -178,7 +178,7 @@ router.post(
|
||||
/** DELETE /api/imports/time-layout/:id - 删除某份时间配置 */
|
||||
router.delete(
|
||||
'/time-layout/:id',
|
||||
requirePermission('timetable:manage'),
|
||||
requirePermission('import:schedule', 'schedule:write'),
|
||||
validate({ params: idParamSchema }),
|
||||
async (req, res) => {
|
||||
const { id } = validatedParams<{ id: string }>(req);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { refreshClassSubscriptions } from '../../realtime/bus.js';
|
||||
import { assertAdmin, assertManage, assertContentWrite } from '../../lib/authz/index.js';
|
||||
import { changeStudentLifecycle, notifyStudentClasses } from '../../lib/student-lifecycle.js';
|
||||
import { assertPermission } from '../../lib/permissions.js';
|
||||
import { assertCanManageRoster, assertCanAssignTeachers, assertClassAccess } from '../../lib/access.js';
|
||||
import { addStudent } from '../classes/classes.service.js';
|
||||
import { updateStudent } from '../students/students.service.js';
|
||||
import * as XLSX from 'xlsx';
|
||||
import { formatClassName, normalizeStudentNo, type TableImportKind } from '@classhelper/shared';
|
||||
import { activeStudentWhere, isActiveStudent } from '../../lib/students.js';
|
||||
@@ -11,8 +12,6 @@ import { ApiError } from '../../lib/http.js';
|
||||
import type { TokenPayload } from '../../lib/jwt.js';
|
||||
import { logger } from '../../lib/logger.js';
|
||||
import { hashPassword } from '../../lib/password.js';
|
||||
import { resolveUserClassRole } from '../../lib/access.js';
|
||||
import { canImport, canManageSubjectContent } from '@classhelper/shared';
|
||||
import type { TableCommitInput, TableFileInput } from './imports.schemas.js';
|
||||
|
||||
/**
|
||||
@@ -362,7 +361,6 @@ export async function commitGrades(user: TokenPayload, input: TableCommitInput):
|
||||
* 不属于那门课的记录(且覆盖语义的 append/overwrite 会把它写进同一张表)。
|
||||
* 判定沿用 shared 的 canManageSubjectContent,与 Web 端单条录入、作业发布**同一份规则**。
|
||||
*/
|
||||
const classRole = await resolveUserClassRole(user, classId);
|
||||
|
||||
const errors: ImportRowError[] = [];
|
||||
const warnings: string[] = [];
|
||||
@@ -425,21 +423,7 @@ export async function commitGrades(user: TokenPayload, input: TableCommitInput):
|
||||
warnings.push(`第 ${rowNumber} 行:科目「${courseName}」不存在,已按"不指定科目"导入`);
|
||||
|
||||
// 该科目的成绩能不能由**当前这个人**写(管理员全通;挂了科目就只能是该科任课老师)
|
||||
const canWriteCourse = canManageSubjectContent({
|
||||
classRole,
|
||||
hasSubject: Boolean(course),
|
||||
isSubjectTeacherOfCourse: course ? course.teacherId === user.sub : false,
|
||||
allowClassDevice: false,
|
||||
});
|
||||
if (!canWriteCourse) {
|
||||
errors.push({
|
||||
row: rowNumber,
|
||||
message: course
|
||||
? `「${course.name}」不是你任教的科目,不能导入该科目的成绩`
|
||||
: '不指定科目的成绩只有班主任或管理员可以导入,请填写科目列后再导入',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
await assertContentWrite(user, 'grade:import', classId, course?.id ?? null, '导入成绩');
|
||||
|
||||
// 表格里带等级列就按"自定义等级"落库(教师明确给了等级),否则按百分制换算
|
||||
const levelType = levelText ? 'custom' : 'percent';
|
||||
@@ -521,6 +505,15 @@ export async function commitStudents(user: TokenPayload, input: TableCommitInput
|
||||
else classByName.set(key, item);
|
||||
}
|
||||
|
||||
const operations: {
|
||||
classId: string;
|
||||
studentNo: string;
|
||||
name: string;
|
||||
gender: string;
|
||||
guardianPhone: string;
|
||||
existingId: string | null;
|
||||
sourceClassId: string | null;
|
||||
}[] = [];
|
||||
const errors: ImportRowError[] = [];
|
||||
const warnings: string[] = [];
|
||||
let inserted = 0;
|
||||
@@ -580,19 +573,68 @@ export async function commitStudents(user: TokenPayload, input: TableCommitInput
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
if (existing.classId !== classId)
|
||||
await addStudent(user, classId, { studentNo, name, gender, guardianPhone });
|
||||
await updateStudent(user, existing.id, {
|
||||
if (existing.classId !== classId) await assertCanManageRoster(user, existing.classId ?? undefined);
|
||||
operations.push({
|
||||
classId,
|
||||
studentNo,
|
||||
name,
|
||||
...(gender ? { gender } : {}),
|
||||
...(guardianPhone ? { guardianPhone } : {}),
|
||||
gender,
|
||||
guardianPhone,
|
||||
existingId: existing.id,
|
||||
sourceClassId: existing.classId,
|
||||
});
|
||||
updated += 1;
|
||||
} else {
|
||||
await addStudent(user, classId, { studentNo, name, gender, guardianPhone });
|
||||
operations.push({
|
||||
classId,
|
||||
studentNo,
|
||||
name,
|
||||
gender,
|
||||
guardianPhone,
|
||||
existingId: null,
|
||||
sourceClassId: null,
|
||||
});
|
||||
inserted += 1;
|
||||
}
|
||||
}
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
for (const op of operations) {
|
||||
if (!op.existingId || op.sourceClassId !== op.classId)
|
||||
await changeStudentLifecycle(
|
||||
user,
|
||||
{
|
||||
studentIds: op.existingId ? [op.existingId] : [],
|
||||
kind: 'class-change',
|
||||
toClassId: op.classId,
|
||||
note: '学生名单导入',
|
||||
...(!op.existingId
|
||||
? {
|
||||
newStudent: {
|
||||
studentNo: op.studentNo,
|
||||
name: op.name,
|
||||
gender: op.gender,
|
||||
guardianPhone: op.guardianPhone,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
tx,
|
||||
);
|
||||
if (op.existingId)
|
||||
await tx.student.update({
|
||||
where: { id: op.existingId },
|
||||
data: {
|
||||
name: op.name,
|
||||
...(op.gender ? { gender: op.gender } : {}),
|
||||
...(op.guardianPhone ? { guardianPhone: op.guardianPhone } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
notifyStudentClasses(operations.flatMap((op) => [op.sourceClassId, op.classId]));
|
||||
|
||||
const result: ImportResult = {
|
||||
kind: 'students',
|
||||
@@ -618,73 +660,94 @@ export async function commitStudents(user: TokenPayload, input: TableCommitInput
|
||||
*/
|
||||
export async function commitTeachers(user: TokenPayload, input: TableCommitInput): Promise<ImportResult> {
|
||||
await assertImportAllowed(user, undefined, 'teachers');
|
||||
const buffer = decodeBase64(input.contentBase64);
|
||||
const parsed = parseTable(buffer, input.fileName);
|
||||
assertAdmin(user);
|
||||
return prisma.$transaction(
|
||||
async (tx) => {
|
||||
const buffer = decodeBase64(input.contentBase64);
|
||||
const parsed = parseTable(buffer, input.fileName);
|
||||
|
||||
const errors: ImportRowError[] = [];
|
||||
const warnings: string[] = [];
|
||||
let inserted = 0;
|
||||
let updated = 0;
|
||||
let skipped = 0;
|
||||
const errors: ImportRowError[] = [];
|
||||
const warnings: string[] = [];
|
||||
let inserted = 0;
|
||||
let updated = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const [index, row] of parsed.rows.entries()) {
|
||||
const rowNumber = index + 2;
|
||||
const username = cellValue(row, parsed.columns, input.mapping, 'username');
|
||||
const name = cellValue(row, parsed.columns, input.mapping, 'name');
|
||||
const phone = cellValue(row, parsed.columns, input.mapping, 'phone');
|
||||
const password = cellValue(row, parsed.columns, input.mapping, 'password');
|
||||
const roleCell = cellValue(row, parsed.columns, input.mapping, 'role');
|
||||
for (const [index, row] of parsed.rows.entries()) {
|
||||
const rowNumber = index + 2;
|
||||
const username = cellValue(row, parsed.columns, input.mapping, 'username');
|
||||
const name = cellValue(row, parsed.columns, input.mapping, 'name');
|
||||
const phone = cellValue(row, parsed.columns, input.mapping, 'phone');
|
||||
const password = cellValue(row, parsed.columns, input.mapping, 'password');
|
||||
const roleCell = cellValue(row, parsed.columns, input.mapping, 'role');
|
||||
|
||||
if (!username || !name) {
|
||||
errors.push({ row: rowNumber, message: '工号与姓名都必须填写' });
|
||||
continue;
|
||||
}
|
||||
if (!/^[A-Za-z0-9_.-]{3,32}$/.test(username)) {
|
||||
errors.push({ row: rowNumber, message: `工号「${username}」格式不合法(3~32 位字母/数字/._-)` });
|
||||
continue;
|
||||
}
|
||||
if (!username || !name) {
|
||||
errors.push({ row: rowNumber, message: '工号与姓名都必须填写' });
|
||||
continue;
|
||||
}
|
||||
if (!/^[A-Za-z0-9_.-]{3,32}$/.test(username)) {
|
||||
errors.push({ row: rowNumber, message: `工号「${username}」格式不合法(3~32 位字母/数字/._-)` });
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = await prisma.user.findUnique({ where: { username }, select: { id: true, role: true } });
|
||||
if (existing) {
|
||||
if (input.mode === 'append') {
|
||||
skipped += 1;
|
||||
continue;
|
||||
const existing = await tx.user.findUnique({ where: { username }, select: { id: true, role: true } });
|
||||
if (existing?.role === 'ADMIN') {
|
||||
errors.push({ row: rowNumber, message: '管理员账号请通过教师账号管理修改' });
|
||||
continue;
|
||||
}
|
||||
if (existing) {
|
||||
if (input.mode === 'append') {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
const nextRole = parseTeacherRole(roleCell) ?? existing.role;
|
||||
await tx.user.update({
|
||||
where: { id: existing.id },
|
||||
data: { name, role: nextRole, ...(phone ? { phone } : {}) },
|
||||
});
|
||||
updated += 1;
|
||||
} else {
|
||||
const initialPassword = password || env.defaultTeacherPassword;
|
||||
await tx.user.create({
|
||||
data: {
|
||||
username,
|
||||
name,
|
||||
phone,
|
||||
role: parseTeacherRole(roleCell) ?? 'TEACHER',
|
||||
passwordHash: await hashPassword(initialPassword),
|
||||
},
|
||||
});
|
||||
inserted += 1;
|
||||
}
|
||||
}
|
||||
const nextRole = parseTeacherRole(roleCell) ?? existing.role;
|
||||
await prisma.user.update({
|
||||
where: { id: existing.id },
|
||||
data: { name, role: nextRole, ...(phone ? { phone } : {}) },
|
||||
});
|
||||
updated += 1;
|
||||
} else {
|
||||
const initialPassword = password || env.defaultTeacherPassword;
|
||||
await prisma.user.create({
|
||||
|
||||
const result: ImportResult = {
|
||||
kind: 'teachers',
|
||||
total: parsed.rows.length,
|
||||
inserted,
|
||||
updated,
|
||||
skipped,
|
||||
failed: errors.length,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
logger.info(
|
||||
`教师名单导入:模式=${input.mode} 新增=${inserted} 更新=${updated} 跳过=${skipped} 失败=${errors.length}`,
|
||||
);
|
||||
await tx.permissionAudit.create({
|
||||
data: {
|
||||
username,
|
||||
name,
|
||||
phone,
|
||||
role: parseTeacherRole(roleCell) ?? 'TEACHER',
|
||||
passwordHash: await hashPassword(initialPassword),
|
||||
actorId: user.sub,
|
||||
actorName: user.name,
|
||||
action: 'import-commitTeachers',
|
||||
targetType: 'import',
|
||||
targetId: '',
|
||||
beforeJson: 'null',
|
||||
afterJson: JSON.stringify(result),
|
||||
},
|
||||
});
|
||||
inserted += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const result: ImportResult = {
|
||||
kind: 'teachers',
|
||||
total: parsed.rows.length,
|
||||
inserted,
|
||||
updated,
|
||||
skipped,
|
||||
failed: errors.length,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
logger.info(
|
||||
`教师名单导入:模式=${input.mode} 新增=${inserted} 更新=${updated} 跳过=${skipped} 失败=${errors.length}`,
|
||||
return result;
|
||||
},
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ 班级任课老师 */
|
||||
@@ -705,160 +768,184 @@ export async function commitClassTeachers(
|
||||
input: TableCommitInput,
|
||||
): Promise<ImportResult> {
|
||||
await assertImportAllowed(user, undefined, 'classTeachers');
|
||||
const buffer = decodeBase64(input.contentBase64);
|
||||
const parsed = parseTable(buffer, input.fileName);
|
||||
assertAdmin(user);
|
||||
return prisma
|
||||
.$transaction(
|
||||
async (tx) => {
|
||||
const buffer = decodeBase64(input.contentBase64);
|
||||
const parsed = parseTable(buffer, input.fileName);
|
||||
|
||||
const useTeacherNo = input.useTeacherNo !== false; // 默认开:有工号就优先按工号匹配
|
||||
const useTeacherNo = input.useTeacherNo !== false; // 默认开:有工号就优先按工号匹配
|
||||
|
||||
const errors: ImportRowError[] = [];
|
||||
const warnings: string[] = [];
|
||||
let inserted = 0;
|
||||
let updated = 0;
|
||||
let skipped = 0;
|
||||
const errors: ImportRowError[] = [];
|
||||
const warnings: string[] = [];
|
||||
let inserted = 0;
|
||||
let updated = 0;
|
||||
let skipped = 0;
|
||||
|
||||
const classes = await prisma.class.findMany({
|
||||
where: { archivedYearId: null },
|
||||
select: { id: true, name: true, teacherId: true },
|
||||
});
|
||||
const classByName = new Map<string, { id: string; name: string; teacherId: string | null }>();
|
||||
for (const item of classes) {
|
||||
const key = item.name.toLowerCase();
|
||||
if (classByName.has(key)) classByName.delete(key);
|
||||
else classByName.set(key, item);
|
||||
}
|
||||
|
||||
// 本次导入新建的教师账号(同一份表格里再遇到同名/同工号时复用,避免重复建号)
|
||||
const createdTeachers = new Map<string, string>();
|
||||
|
||||
for (const [index, row] of parsed.rows.entries()) {
|
||||
const rowNumber = index + 2;
|
||||
const className = cellValue(row, parsed.columns, input.mapping, 'className');
|
||||
const subjectName = cellValue(row, parsed.columns, input.mapping, 'subjectName');
|
||||
const teacherNo = cellValue(row, parsed.columns, input.mapping, 'teacherNo');
|
||||
const teacherName = cellValue(row, parsed.columns, input.mapping, 'teacherName');
|
||||
const roleCell = cellValue(row, parsed.columns, input.mapping, 'role');
|
||||
|
||||
if (!className || !teacherNo) {
|
||||
errors.push({ row: rowNumber, message: '班级与教师工号都必须填写' });
|
||||
continue;
|
||||
}
|
||||
|
||||
const targetClass = classByName.get(className.toLowerCase());
|
||||
if (!targetClass) {
|
||||
errors.push({ row: rowNumber, message: `找不到班级「${className}」(可能已毕业归档或名称重复)` });
|
||||
continue;
|
||||
}
|
||||
|
||||
await assertImportAllowed(user, targetClass.id, 'classTeachers');
|
||||
const role = parseClassTeacherRole(roleCell);
|
||||
if (!role) {
|
||||
errors.push({ row: rowNumber, message: `角色「${roleCell}」无法识别,请填「班主任」或「科任」` });
|
||||
continue;
|
||||
}
|
||||
if (role === 'SUBJECT' && !subjectName) {
|
||||
errors.push({ row: rowNumber, message: '科任老师必须填写科目' });
|
||||
continue;
|
||||
}
|
||||
|
||||
// ---- 匹配或新建教师账号
|
||||
let teacherId = createdTeachers.get(teacherNo.toLowerCase());
|
||||
if (!teacherId) {
|
||||
if (useTeacherNo) {
|
||||
const byNo = await prisma.user.findUnique({
|
||||
where: { username: teacherNo },
|
||||
select: { id: true },
|
||||
const classes = await tx.class.findMany({
|
||||
where: { archivedYearId: null },
|
||||
select: { id: true, name: true, teacherId: true },
|
||||
});
|
||||
if (byNo) {
|
||||
teacherId = byNo.id;
|
||||
const classByName = new Map<string, { id: string; name: string; teacherId: string | null }>();
|
||||
for (const item of classes) {
|
||||
const key = item.name.toLowerCase();
|
||||
if (classByName.has(key)) classByName.delete(key);
|
||||
else classByName.set(key, item);
|
||||
}
|
||||
} else if (teacherName) {
|
||||
const byName = await prisma.user.findMany({
|
||||
where: { name: teacherName, role: { in: ['TEACHER', 'ADMIN'] } },
|
||||
select: { id: true },
|
||||
take: 2,
|
||||
});
|
||||
if (byName.length === 1) {
|
||||
teacherId = byName[0]!.id;
|
||||
} else if (byName.length > 1) {
|
||||
errors.push({
|
||||
row: rowNumber,
|
||||
message: `姓名「${teacherName}」对应多个教师账号,无法确定是哪一个;请打开「使用工号匹配」再导入`,
|
||||
});
|
||||
continue;
|
||||
|
||||
// 本次导入新建的教师账号(同一份表格里再遇到同名/同工号时复用,避免重复建号)
|
||||
const createdTeachers = new Map<string, string>();
|
||||
|
||||
for (const [index, row] of parsed.rows.entries()) {
|
||||
const rowNumber = index + 2;
|
||||
const className = cellValue(row, parsed.columns, input.mapping, 'className');
|
||||
const subjectName = cellValue(row, parsed.columns, input.mapping, 'subjectName');
|
||||
const teacherNo = cellValue(row, parsed.columns, input.mapping, 'teacherNo');
|
||||
const teacherName = cellValue(row, parsed.columns, input.mapping, 'teacherName');
|
||||
const roleCell = cellValue(row, parsed.columns, input.mapping, 'role');
|
||||
|
||||
if (!className || !teacherNo) {
|
||||
errors.push({ row: rowNumber, message: '班级与教师工号都必须填写' });
|
||||
continue;
|
||||
}
|
||||
|
||||
const targetClass = classByName.get(className.toLowerCase());
|
||||
if (!targetClass) {
|
||||
errors.push({
|
||||
row: rowNumber,
|
||||
message: `找不到班级「${className}」(可能已毕业归档或名称重复)`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const role = parseClassTeacherRole(roleCell);
|
||||
if (!role) {
|
||||
errors.push({ row: rowNumber, message: `角色「${roleCell}」无法识别,请填「班主任」或「科任」` });
|
||||
continue;
|
||||
}
|
||||
if (role === 'SUBJECT' && !subjectName) {
|
||||
errors.push({ row: rowNumber, message: '科任老师必须填写科目' });
|
||||
continue;
|
||||
}
|
||||
|
||||
// ---- 匹配或新建教师账号
|
||||
let teacherId = createdTeachers.get(teacherNo.toLowerCase());
|
||||
if (!teacherId) {
|
||||
if (useTeacherNo) {
|
||||
const byNo = await tx.user.findUnique({
|
||||
where: { username: teacherNo },
|
||||
select: { id: true },
|
||||
});
|
||||
if (byNo) {
|
||||
teacherId = byNo.id;
|
||||
}
|
||||
} else if (teacherName) {
|
||||
const byName = await tx.user.findMany({
|
||||
where: { name: teacherName, role: { in: ['TEACHER', 'ADMIN'] } },
|
||||
select: { id: true },
|
||||
take: 2,
|
||||
});
|
||||
if (byName.length === 1) {
|
||||
teacherId = byName[0]!.id;
|
||||
} else if (byName.length > 1) {
|
||||
errors.push({
|
||||
row: rowNumber,
|
||||
message: `姓名「${teacherName}」对应多个教师账号,无法确定是哪一个;请打开「使用工号匹配」再导入`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!teacherId) {
|
||||
if (!useTeacherNo) {
|
||||
errors.push({
|
||||
row: rowNumber,
|
||||
message: `按姓名找不到教师「${teacherName || teacherNo}」;请打开「使用工号匹配」以便按工号新建账号`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
await assertPermission(user, 'teacher:manage');
|
||||
await assertPermission(user, 'import:teachers');
|
||||
const created = await tx.user.create({
|
||||
data: {
|
||||
username: teacherNo,
|
||||
name: teacherName || teacherNo,
|
||||
role: 'TEACHER',
|
||||
passwordHash: await hashPassword(env.defaultTeacherPassword),
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
teacherId = created.id;
|
||||
inserted += 1;
|
||||
warnings.push(
|
||||
`第 ${rowNumber} 行:工号 ${teacherNo} 没有对应账号,已新建教师「${teacherName || teacherNo}」(初始密码见部署配置)`,
|
||||
);
|
||||
}
|
||||
createdTeachers.set(teacherNo.toLowerCase(), teacherId);
|
||||
|
||||
// ---- 写入角色
|
||||
if (role === 'HEAD') {
|
||||
if (targetClass.teacherId === teacherId) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
await tx.class.update({ where: { id: targetClass.id }, data: { teacherId } });
|
||||
targetClass.teacherId = teacherId;
|
||||
updated += 1;
|
||||
} else {
|
||||
const existing = await tx.course.findUnique({
|
||||
where: { classId_name: { classId: targetClass.id, name: subjectName } },
|
||||
select: { id: true, teacherId: true },
|
||||
});
|
||||
if (existing) {
|
||||
if (existing.teacherId === teacherId) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
await tx.course.update({ where: { id: existing.id }, data: { teacherId } });
|
||||
} else {
|
||||
await tx.course.create({
|
||||
data: { name: subjectName, classId: targetClass.id, teacherId },
|
||||
});
|
||||
}
|
||||
updated += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!teacherId) {
|
||||
if (!useTeacherNo) {
|
||||
errors.push({
|
||||
row: rowNumber,
|
||||
message: `按姓名找不到教师「${teacherName || teacherNo}」;请打开「使用工号匹配」以便按工号新建账号`,
|
||||
const result: ImportResult = {
|
||||
kind: 'classTeachers',
|
||||
total: parsed.rows.length,
|
||||
inserted,
|
||||
updated,
|
||||
skipped,
|
||||
failed: errors.length,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
logger.info(
|
||||
`班级任课老师导入:用工号匹配=${useTeacherNo} 新建教师=${inserted} 更新关系=${updated} 跳过=${skipped} 失败=${errors.length}`,
|
||||
);
|
||||
await tx.permissionAudit.create({
|
||||
data: {
|
||||
actorId: user.sub,
|
||||
actorName: user.name,
|
||||
action: 'import-commitClassTeachers',
|
||||
targetType: 'import',
|
||||
targetId: '',
|
||||
beforeJson: 'null',
|
||||
afterJson: JSON.stringify(result),
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
await assertPermission(user, 'teacher:manage');
|
||||
await assertPermission(user, 'import:teachers');
|
||||
const created = await prisma.user.create({
|
||||
data: {
|
||||
username: teacherNo,
|
||||
name: teacherName || teacherNo,
|
||||
role: 'TEACHER',
|
||||
passwordHash: await hashPassword(env.defaultTeacherPassword),
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
teacherId = created.id;
|
||||
inserted += 1;
|
||||
warnings.push(
|
||||
`第 ${rowNumber} 行:工号 ${teacherNo} 没有对应账号,已新建教师「${teacherName || teacherNo}」(初始密码见部署配置)`,
|
||||
);
|
||||
}
|
||||
createdTeachers.set(teacherNo.toLowerCase(), teacherId);
|
||||
|
||||
// ---- 写入角色
|
||||
if (role === 'HEAD') {
|
||||
if (targetClass.teacherId === teacherId) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
await prisma.class.update({ where: { id: targetClass.id }, data: { teacherId } });
|
||||
targetClass.teacherId = teacherId;
|
||||
updated += 1;
|
||||
} else {
|
||||
const existing = await prisma.course.findUnique({
|
||||
where: { classId_name: { classId: targetClass.id, name: subjectName } },
|
||||
select: { id: true, teacherId: true },
|
||||
});
|
||||
if (existing) {
|
||||
if (existing.teacherId === teacherId) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
await prisma.course.update({ where: { id: existing.id }, data: { teacherId } });
|
||||
} else {
|
||||
await prisma.course.create({
|
||||
data: { name: subjectName, classId: targetClass.id, teacherId },
|
||||
});
|
||||
}
|
||||
updated += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const result: ImportResult = {
|
||||
kind: 'classTeachers',
|
||||
total: parsed.rows.length,
|
||||
inserted,
|
||||
updated,
|
||||
skipped,
|
||||
failed: errors.length,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
logger.info(
|
||||
`班级任课老师导入:用工号匹配=${useTeacherNo} 新建教师=${inserted} 更新关系=${updated} 跳过=${skipped} 失败=${errors.length}`,
|
||||
);
|
||||
return result;
|
||||
return result;
|
||||
},
|
||||
{ timeout: 30000 },
|
||||
)
|
||||
.then((result) => {
|
||||
refreshClassSubscriptions();
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
export async function commitTable(user: TokenPayload, input: TableCommitInput): Promise<ImportResult> {
|
||||
@@ -890,19 +977,15 @@ async function assertImportAllowed(
|
||||
} as const;
|
||||
await assertPermission(user, key[kind]);
|
||||
if (classId) await assertClassAccess(user, classId, key[kind]);
|
||||
if (kind === 'students') return assertCanManageRoster(user, classId);
|
||||
if (kind === 'students') {
|
||||
if (classId) await assertManage(user, 'import:students', classId);
|
||||
return assertCanManageRoster(user, classId);
|
||||
}
|
||||
if (kind === 'teachers') return assertPermission(user, 'teacher:manage');
|
||||
if (kind === 'classTeachers') return assertCanAssignTeachers(user, classId);
|
||||
const classRole = classId ? await resolveUserClassRole(user, classId) : 'NONE';
|
||||
if (canImport(user.role, classRole, kind)) return;
|
||||
if (kind === 'grades') {
|
||||
throw ApiError.forbidden(
|
||||
classRole === 'SUBJECT' || classRole === 'CLASS_DEVICE'
|
||||
? '科任老师与 ClassHelper 班级端不能导入名单或成绩,请联系班主任或管理员'
|
||||
: '只能导入自己担任班主任的班级的数据',
|
||||
);
|
||||
}
|
||||
throw ApiError.forbidden('该导入仅限管理员使用');
|
||||
if (kind === 'grades' && classId) return;
|
||||
if (kind === 'scheduleTimes' && classId) return assertManage(user, 'import:schedule', classId);
|
||||
throw ApiError.forbidden('请指定目标班级');
|
||||
}
|
||||
|
||||
/** 只接受明确的角色值;空值或未识别值保留原角色,避免意外降级。 */
|
||||
|
||||
@@ -324,7 +324,7 @@ export async function importTimeLayout(
|
||||
user: TokenPayload,
|
||||
input: TimeLayoutImportInput,
|
||||
): Promise<{ layout: TimeLayoutDto; mode: string; warnings: string[]; replaced: number; merged: number }> {
|
||||
await assertCanImportSchedule(user, input.classId, 'timetable:manage');
|
||||
await assertCanImportSchedule(user, input.classId);
|
||||
|
||||
const parsed = parseClassIslandTimeLayout(input.payload);
|
||||
if (parsed.errors.length > 0) {
|
||||
@@ -380,7 +380,7 @@ export async function importTimeLayout(
|
||||
actionSet: '{}',
|
||||
};
|
||||
});
|
||||
const layout = await saveLayout({ classId: input.classId, name, points }, current?.id);
|
||||
const layout = await saveLayout(user, { classId: input.classId, name, points }, current?.id);
|
||||
logger.info(`课表时间配置导入:班级=${input.classId} 模式=${input.mode} 共 ${combined.length} 个时间点`);
|
||||
return {
|
||||
layout: toDto(layout),
|
||||
|
||||
@@ -4,7 +4,7 @@ import { idParamSchema } from '../../lib/schemas.js';
|
||||
import {
|
||||
authenticate,
|
||||
getAuthUser,
|
||||
requireStaffPermission,
|
||||
requirePermissionOrClassDevice,
|
||||
requirePermission,
|
||||
} from '../../middleware/auth.js';
|
||||
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
|
||||
@@ -28,7 +28,7 @@ router.use(authenticate());
|
||||
/** GET /api/notifications?classId=&priority=&unreadOnly=&date=&from=&to=&createdBy= - 通知列表 */
|
||||
router.get(
|
||||
'/',
|
||||
requireStaffPermission('notification:read'),
|
||||
requirePermissionOrClassDevice('notification:read'),
|
||||
validate({ query: listNotificationsQuerySchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -40,7 +40,7 @@ router.get(
|
||||
/** GET /api/notifications/days?classId=&from=&to=&createdBy= - 按天归档(日期 + 条数) */
|
||||
router.get(
|
||||
'/days',
|
||||
requireStaffPermission('notification:read'),
|
||||
requirePermissionOrClassDevice('notification:read'),
|
||||
validate({ query: notificationArchiveQuerySchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -52,7 +52,7 @@ router.get(
|
||||
/** GET /api/notifications/creators?classId=&from=&to=&date= - 按发布人归档 */
|
||||
router.get(
|
||||
'/creators',
|
||||
requireStaffPermission('notification:read'),
|
||||
requirePermissionOrClassDevice('notification:read'),
|
||||
validate({ query: notificationArchiveQuerySchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -64,7 +64,7 @@ router.get(
|
||||
/** GET /api/notifications/unread-count?classId= - 未读数(客户端红点) */
|
||||
router.get(
|
||||
'/unread-count',
|
||||
requireStaffPermission('notification:read'),
|
||||
requirePermissionOrClassDevice('notification:read'),
|
||||
validate({ query: notificationScopeQuerySchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -76,7 +76,7 @@ router.get(
|
||||
/** POST /api/notifications/read-all - 全部标为已读 */
|
||||
router.post(
|
||||
'/read-all',
|
||||
requireStaffPermission('notification:read'),
|
||||
requirePermissionOrClassDevice('notification:read'),
|
||||
validate({ body: notificationScopeQuerySchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -88,7 +88,7 @@ router.post(
|
||||
/** POST /api/notifications - 发布通知(发布后广播 notification:new) */
|
||||
router.post(
|
||||
'/',
|
||||
requireStaffPermission('notification:publish'),
|
||||
requirePermissionOrClassDevice('notification:publish'),
|
||||
validate({ body: createNotificationSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -100,7 +100,7 @@ router.post(
|
||||
/** POST /api/notifications/bulk - 一次给多个班级发同一条通知(逐班结果,不整批回滚) */
|
||||
router.post(
|
||||
'/bulk',
|
||||
requireStaffPermission('notification:publish'),
|
||||
requirePermissionOrClassDevice('notification:publish'),
|
||||
validate({ body: bulkCreateNotificationSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -115,7 +115,7 @@ router.post(
|
||||
/** POST /api/notifications/:id/read - 标记已读 */
|
||||
router.post(
|
||||
'/:id/read',
|
||||
requireStaffPermission('notification:read'),
|
||||
requirePermissionOrClassDevice('notification:read'),
|
||||
validate({ params: idParamSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { assertStaffPermission, assertPermission } from '../../lib/permissions.js';
|
||||
import { assertClassRead, isHead } from '../../lib/authz/index.js';
|
||||
import { assertPublish } from '../../lib/authz/index.js';
|
||||
import { assertPermissionOrClassDevice, assertPermission } from '../../lib/permissions.js';
|
||||
import { resolveContentCreator } from '../../lib/access.js';
|
||||
import {
|
||||
SOCKET_EVENTS,
|
||||
@@ -12,12 +14,7 @@ import {
|
||||
type NotificationDto,
|
||||
} from '@classhelper/shared';
|
||||
import { normalizeBulkClassIds, summarizeBulk, toBulkFailure } from '../../lib/bulk.js';
|
||||
import {
|
||||
assertClassAccess,
|
||||
assertCanPublishNotification,
|
||||
classScopeWhere,
|
||||
resolveClassScope,
|
||||
} from '../../lib/access.js';
|
||||
import { assertClassAccess, classScopeWhere, resolveClassScope } from '../../lib/access.js';
|
||||
import { activeStudentWhere } from '../../lib/students.js';
|
||||
import { prisma } from '../../lib/db.js';
|
||||
import { ApiError } from '../../lib/http.js';
|
||||
@@ -73,8 +70,8 @@ export async function listNotifications(
|
||||
user: TokenPayload,
|
||||
options: ListNotificationOptions,
|
||||
): Promise<NotificationDto[]> {
|
||||
await assertStaffPermission(user, 'notification:read');
|
||||
const scope = await resolveClassScope(user, options.classId);
|
||||
await assertPermissionOrClassDevice(user, 'notification:read');
|
||||
const scope = await resolveClassScope(user, options.classId, 'notification:read');
|
||||
const isStaff = user.role === 'ADMIN' || user.role === 'TEACHER';
|
||||
// 班级账号(班级设备)以全班学生为"个人范围",普通学生就是自己
|
||||
const personalIds = await resolvePersonalIds(user);
|
||||
@@ -112,8 +109,8 @@ export async function listNotificationDays(
|
||||
user: TokenPayload,
|
||||
options: { classId?: string; priority?: string; createdBy?: string; from?: string; to?: string },
|
||||
): Promise<NotificationDaysDto> {
|
||||
await assertStaffPermission(user, 'notification:read');
|
||||
const scope = await resolveClassScope(user, options.classId);
|
||||
await assertPermissionOrClassDevice(user, 'notification:read');
|
||||
const scope = await resolveClassScope(user, options.classId, 'notification:read');
|
||||
const grouped = await prisma.notification.groupBy({
|
||||
by: ['assignDate'],
|
||||
where: { ...classScopeWhere(scope), ...archiveWhere(options) },
|
||||
@@ -133,8 +130,8 @@ export async function listNotificationCreators(
|
||||
user: TokenPayload,
|
||||
options: { classId?: string; priority?: string; date?: string; from?: string; to?: string },
|
||||
): Promise<NotificationCreatorsDto> {
|
||||
await assertStaffPermission(user, 'notification:read');
|
||||
const scope = await resolveClassScope(user, options.classId);
|
||||
await assertPermissionOrClassDevice(user, 'notification:read');
|
||||
const scope = await resolveClassScope(user, options.classId, 'notification:read');
|
||||
const grouped = await prisma.notification.groupBy({
|
||||
by: ['createdBy'],
|
||||
where: { ...classScopeWhere(scope), ...archiveWhere(options) },
|
||||
@@ -171,7 +168,7 @@ export async function bulkCreateNotifications(
|
||||
user: TokenPayload,
|
||||
input: BulkCreateNotificationRequest,
|
||||
): Promise<BulkPublishResult> {
|
||||
await assertStaffPermission(user, 'notification:publish');
|
||||
await assertPermissionOrClassDevice(user, 'notification:publish');
|
||||
const classIds = normalizeBulkClassIds(input.classIds);
|
||||
const results: BulkPublishClassResult[] = [];
|
||||
|
||||
@@ -208,8 +205,8 @@ export async function createNotification(
|
||||
user: TokenPayload,
|
||||
input: CreateNotificationInput,
|
||||
): Promise<NotificationDto> {
|
||||
await assertStaffPermission(user, 'notification:publish');
|
||||
await assertCanPublishNotification(user, input.classId);
|
||||
await assertPermissionOrClassDevice(user, 'notification:publish');
|
||||
await assertPublish(user, 'notification:publish', input.classId, { allowClassDevice: true });
|
||||
|
||||
const priority = input.priority ?? 'NORMAL';
|
||||
if (priority === 'URGENT' && input.confirmDuringClass !== true) {
|
||||
@@ -251,7 +248,9 @@ export async function deleteNotification(user: TokenPayload, notificationId: str
|
||||
await assertPermission(user, 'notification:delete');
|
||||
const current = await prisma.notification.findUnique({ where: { id: notificationId } });
|
||||
if (!current) throw ApiError.notFound('通知不存在');
|
||||
await assertCanPublishNotification(user, current.classId);
|
||||
await assertClassRead(user, 'notification:delete', current.classId);
|
||||
if (user.role !== 'ADMIN' && current.createdBy !== user.sub && !(await isHead(user, current.classId)))
|
||||
throw ApiError.forbidden('只有发布者、班主任或管理员可删除通知');
|
||||
await prisma.notification.delete({ where: { id: notificationId } });
|
||||
emitToClass(current.classId, SOCKET_EVENTS.notificationDeleted, {
|
||||
id: notificationId,
|
||||
@@ -267,10 +266,10 @@ export async function markAsRead(
|
||||
user: TokenPayload,
|
||||
notificationId: string,
|
||||
): Promise<{ notificationId: string; readAt: string; marked: number }> {
|
||||
await assertStaffPermission(user, 'notification:read');
|
||||
await assertPermissionOrClassDevice(user, 'notification:read');
|
||||
const notification = await prisma.notification.findUnique({ where: { id: notificationId } });
|
||||
if (!notification) throw ApiError.notFound('通知不存在');
|
||||
await assertClassAccess(user, notification.classId);
|
||||
await assertClassAccess(user, notification.classId, 'notification:read');
|
||||
|
||||
const personalIds = await resolvePersonalIds(user);
|
||||
// 先过滤已读过的记录再创建:SQLite 下 createMany 不支持 skipDuplicates,
|
||||
@@ -293,8 +292,8 @@ export async function markAsRead(
|
||||
|
||||
/** 全部标为已读(可按班级限定) */
|
||||
export async function markAllAsRead(user: TokenPayload, classId?: string): Promise<{ marked: number }> {
|
||||
await assertStaffPermission(user, 'notification:read');
|
||||
const scope = await resolveClassScope(user, classId);
|
||||
await assertPermissionOrClassDevice(user, 'notification:read');
|
||||
const scope = await resolveClassScope(user, classId, 'notification:read');
|
||||
const personalIds = await resolvePersonalIds(user);
|
||||
const personalWhere = personalIdWhere(personalIds);
|
||||
|
||||
@@ -324,8 +323,8 @@ export async function markAllAsRead(user: TokenPayload, classId?: string): Promi
|
||||
|
||||
/** 未读数量(客户端红点) */
|
||||
export async function unreadCount(user: TokenPayload, classId?: string): Promise<number> {
|
||||
await assertStaffPermission(user, 'notification:read');
|
||||
const scope = await resolveClassScope(user, classId);
|
||||
await assertPermissionOrClassDevice(user, 'notification:read');
|
||||
const scope = await resolveClassScope(user, classId, 'notification:read');
|
||||
const personalIds = await resolvePersonalIds(user);
|
||||
return prisma.notification.count({
|
||||
where: { ...classScopeWhere(scope), reads: { none: personalIdWhere(personalIds) } },
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { z } from 'zod';
|
||||
import { Router } from 'express';
|
||||
import { sendCreated, sendOk } from '../../lib/http.js';
|
||||
import { ApiError, sendCreated, sendOk } from '../../lib/http.js';
|
||||
import { idParamSchema } from '../../lib/schemas.js';
|
||||
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
|
||||
import { validate, validatedBody, validatedParams, validatedQuery } from '../../middleware/validate.js';
|
||||
import { authenticate, getAuthUser, requirePermission, requireAdmin } from '../../middleware/auth.js';
|
||||
import {
|
||||
validateBadRequest as validate,
|
||||
validatedBody,
|
||||
validatedParams,
|
||||
validatedQuery,
|
||||
} from '../../middleware/validate.js';
|
||||
import { defineModule } from '../module.types.js';
|
||||
import {
|
||||
assignUserGroupsSchema,
|
||||
@@ -29,17 +34,19 @@ router.get('/me', async (req, res) => {
|
||||
sendOk(res, await permissionService.myPermissions(user), '获取当前权限成功');
|
||||
});
|
||||
|
||||
router.use(requireAdmin());
|
||||
|
||||
/** GET /api/permissions/catalog - 权限键目录(权限页整棵勾选树的唯一来源) */
|
||||
router.get('/catalog', requirePermission('permission:manage'), async (_req, res) => {
|
||||
sendOk(res, await permissionService.permissionCatalog(), '获取权限目录成功');
|
||||
router.get('/catalog', requirePermission('permission:manage'), async (req, res) => {
|
||||
sendOk(res, await permissionService.permissionCatalog(getAuthUser(req)), '获取权限目录成功');
|
||||
});
|
||||
|
||||
/** GET /api/permissions/groups - 权限组列表 */
|
||||
router.get('/groups', requirePermission('permission:manage'), async (_req, res) => {
|
||||
sendOk(res, await permissionService.listGroups(), '获取权限组成功');
|
||||
router.get('/groups', requirePermission('permission:manage'), async (req, res) => {
|
||||
sendOk(res, await permissionService.listGroups(getAuthUser(req)), '获取权限组成功');
|
||||
});
|
||||
|
||||
router.get('/scopes', requirePermission('permission:manage'), async (_req, res) =>
|
||||
router.get('/scopes', requirePermission('permission:manage'), async (req, res) =>
|
||||
sendOk(res, await permissionService.scopeOptions()),
|
||||
);
|
||||
router.get(
|
||||
@@ -53,14 +60,15 @@ router.get(
|
||||
}),
|
||||
async (req, res) => {
|
||||
const { page, pageSize } = validatedQuery<{ page: number; pageSize: number }>(req);
|
||||
sendOk(res, await permissionService.listAudits(page, pageSize));
|
||||
sendOk(res, await permissionService.listAudits(getAuthUser(req), page, pageSize));
|
||||
},
|
||||
);
|
||||
router.get(
|
||||
'/groups/:id/members',
|
||||
requirePermission('permission:manage'),
|
||||
validate({ params: idParamSchema }),
|
||||
async (req, res) => sendOk(res, await permissionService.groupMembers(String(req.params.id))),
|
||||
async (req, res) =>
|
||||
sendOk(res, await permissionService.groupMembers(getAuthUser(req), String(req.params.id))),
|
||||
);
|
||||
|
||||
/** POST /api/permissions/groups - 新建权限组 */
|
||||
@@ -99,10 +107,29 @@ router.delete(
|
||||
);
|
||||
|
||||
/** GET /api/permissions/users - 成员列表(含所属权限组与生效权限数量) */
|
||||
router.get('/users', requirePermission('permission:manage'), async (_req, res) => {
|
||||
sendOk(res, await permissionService.listMembers(), '获取成员成功');
|
||||
router.get('/users', requirePermission('permission:manage'), async (req, res) => {
|
||||
sendOk(res, await permissionService.listMembers(getAuthUser(req)), '获取成员成功');
|
||||
});
|
||||
|
||||
router.get(
|
||||
'/users/:id/effective',
|
||||
validate({ params: idParamSchema, query: z.object({ assignments: z.string().max(16000).optional() }) }),
|
||||
async (req, res) => {
|
||||
const query = validatedQuery<{ assignments?: string }>(req);
|
||||
let preview: AssignUserGroupsInput | undefined;
|
||||
if (query.assignments !== undefined) {
|
||||
try {
|
||||
preview = assignUserGroupsSchema.parse({ assignments: JSON.parse(query.assignments) });
|
||||
} catch {
|
||||
throw ApiError.badRequest('分配预览格式不正确');
|
||||
}
|
||||
}
|
||||
sendOk(
|
||||
res,
|
||||
await permissionService.effectivePermissions(getAuthUser(req), String(req.params.id), preview),
|
||||
);
|
||||
},
|
||||
);
|
||||
/** PUT /api/permissions/users/:id/groups - 整体替换某账号的权限组 */
|
||||
router.put(
|
||||
'/users/:id/groups',
|
||||
|
||||
@@ -22,7 +22,7 @@ const keysSchema = z.array(permissionKeySchema).max(200, '一次最多提交 200
|
||||
export const createPermissionGroupSchema = z.object({
|
||||
name: z.string().trim().min(1, '请输入权限组名称').max(40, '权限组名称最多 40 个字'),
|
||||
description: z.string().trim().max(200, '描述最多 200 个字').optional(),
|
||||
scope: z.enum(['SELF_CLASS', 'GRADE', 'DIVISION', 'ALL']).optional(),
|
||||
scope: z.enum(['TEACHING', 'HEAD', 'GRADE', 'DIVISION', 'ALL']).optional(),
|
||||
scopeValue: z.string().trim().max(200).optional(),
|
||||
keys: keysSchema.optional(),
|
||||
});
|
||||
@@ -36,15 +36,33 @@ export const createPermissionGroupSchema = z.object({
|
||||
export const updatePermissionGroupSchema = z.object({
|
||||
name: z.string().trim().min(1, '请输入权限组名称').max(40, '权限组名称最多 40 个字').optional(),
|
||||
description: z.string().trim().max(200, '描述最多 200 个字').optional(),
|
||||
scope: z.enum(['SELF_CLASS', 'GRADE', 'DIVISION', 'ALL']).optional(),
|
||||
scope: z.enum(['TEACHING', 'HEAD', 'GRADE', 'DIVISION', 'ALL']).optional(),
|
||||
scopeValue: z.string().trim().max(200).optional(),
|
||||
keys: keysSchema.optional(),
|
||||
});
|
||||
|
||||
/** 给账号分配权限组:整体替换;传空数组 = 回到角色默认模板 */
|
||||
export const assignUserGroupsSchema = z.object({
|
||||
groupIds: z.array(z.string().min(1)).max(50, '一次最多分配 50 个权限组'),
|
||||
});
|
||||
export const assignUserGroupsSchema = z
|
||||
.object({
|
||||
groupIds: z.array(z.string().min(1)).max(50).optional(),
|
||||
assignments: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
groupId: z.string().min(1),
|
||||
scope: z.enum(['TEACHING', 'HEAD', 'GRADE', 'DIVISION', 'ALL']),
|
||||
scopeValue: z.string().max(200).default(''),
|
||||
})
|
||||
.strict(),
|
||||
)
|
||||
.max(50)
|
||||
.optional(),
|
||||
})
|
||||
.strict()
|
||||
.refine(
|
||||
(v) => (v.assignments !== undefined) !== (v.groupIds !== undefined),
|
||||
'请提供 assignments 或 groupIds 之一',
|
||||
);
|
||||
|
||||
export type CreatePermissionGroupInput = z.infer<typeof createPermissionGroupSchema>;
|
||||
export type UpdatePermissionGroupInput = z.infer<typeof updatePermissionGroupSchema>;
|
||||
|
||||
@@ -1,165 +1,72 @@
|
||||
import { disconnectUserSessions } from '../../realtime/bus.js';
|
||||
import {
|
||||
PERMISSION_CATEGORIES,
|
||||
PERMISSION_KEYS,
|
||||
PERMISSION_META,
|
||||
PERMISSION_LABELS,
|
||||
PERMISSION_CATEGORIES,
|
||||
ROLE_DEFAULT_PERMISSIONS,
|
||||
parsePermissionScope,
|
||||
defaultPromotionPlan,
|
||||
type PermissionKey,
|
||||
type PermissionScope,
|
||||
type PermissionGroupDto,
|
||||
type PermissionMemberDto,
|
||||
type PermissionCatalogDto,
|
||||
type EffectivePermissionsDto,
|
||||
type AssignUserGroupsRequest,
|
||||
type CreatePermissionGroupRequest,
|
||||
type MyPermissionsDto,
|
||||
type PermissionCatalogDto,
|
||||
type PermissionGroupDto,
|
||||
type PermissionKey,
|
||||
type PermissionMemberDto,
|
||||
type UpdatePermissionGroupRequest,
|
||||
type UserRole,
|
||||
type PromotionPlan,
|
||||
} from '@classhelper/shared';
|
||||
import { prisma } from '../../lib/db.js';
|
||||
import { ApiError } from '../../lib/http.js';
|
||||
import type { TokenPayload } from '../../lib/jwt.js';
|
||||
import { ensureDefaultGroups } from '../../lib/permissions.js';
|
||||
import {
|
||||
parsePermissionScope,
|
||||
defaultPromotionPlan,
|
||||
type PermissionScope,
|
||||
type PromotionPlan,
|
||||
} from '@classhelper/shared';
|
||||
import { assertPermission } from '../../lib/permissions.js';
|
||||
assertAdmin,
|
||||
invalidateAuthPrincipals,
|
||||
loadAuthPrincipal,
|
||||
principalForAssignments,
|
||||
classIdsFor,
|
||||
manageClassIdsFor,
|
||||
actionScopesFor,
|
||||
type AssignmentRow,
|
||||
} from '../../lib/authz/index.js';
|
||||
import { getSetting } from '../../lib/settings.js';
|
||||
import {
|
||||
ADMIN_UNREMOVABLE_PERMISSIONS,
|
||||
ensureDefaultGroups,
|
||||
loadEffectivePermissions,
|
||||
} from '../../lib/permissions.js';
|
||||
|
||||
/**
|
||||
* 权限组服务:组 / 键 / 成员三件事的全部读写都收在这里。
|
||||
*
|
||||
* 生效权限的计算**不在本文件**,而在 `lib/permissions.ts` 的 `loadEffectivePermissions`
|
||||
* (鉴权中间件每请求调用一次);本文件只负责"编辑这些组",两者共用同一份 shared 常量。
|
||||
*/
|
||||
|
||||
const GROUP_SELECT = {
|
||||
id: true,
|
||||
name: true,
|
||||
description: true,
|
||||
builtin: true,
|
||||
defaultRole: true,
|
||||
scope: true,
|
||||
scopeValue: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
items: { select: { key: true } },
|
||||
_count: { select: { users: true } },
|
||||
} as const;
|
||||
|
||||
type GroupRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
builtin: boolean;
|
||||
defaultRole: string | null;
|
||||
scope: string;
|
||||
scopeValue: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
items: { key: string }[];
|
||||
_count: { users: number };
|
||||
};
|
||||
|
||||
const MEMBER_SELECT = {
|
||||
id: true,
|
||||
username: true,
|
||||
name: true,
|
||||
role: true,
|
||||
permissionGroups: { select: { groupId: true } },
|
||||
} as const;
|
||||
|
||||
/** 目录顺序(key → 序号):组的键与"我的权限"都按它排序,前端渲染才稳定 */
|
||||
const KEY_ORDER = new Map<string, number>(PERMISSION_KEYS.map((key, index) => [key, index]));
|
||||
|
||||
function knownKey(key: string): key is PermissionKey {
|
||||
return KEY_ORDER.has(key);
|
||||
}
|
||||
|
||||
function byCatalogOrder(a: string, b: string): number {
|
||||
return (KEY_ORDER.get(a) ?? Number.MAX_SAFE_INTEGER) - (KEY_ORDER.get(b) ?? Number.MAX_SAFE_INTEGER);
|
||||
}
|
||||
|
||||
/** 去重 + 丢掉已下线的键 + 按目录顺序排序 */
|
||||
function normalizeKeys(keys: readonly string[] | undefined): PermissionKey[] {
|
||||
const unique = new Set<PermissionKey>();
|
||||
for (const key of keys ?? []) if (knownKey(key)) unique.add(key);
|
||||
return [...unique].sort(byCatalogOrder);
|
||||
}
|
||||
|
||||
function toGroupDto(row: GroupRow): PermissionGroupDto {
|
||||
return {
|
||||
id: row.id,
|
||||
scope: row.scope as PermissionGroupDto['scope'],
|
||||
scopeValue: row.scopeValue,
|
||||
scopeLabel: scopeLabel(row),
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
builtin: row.builtin,
|
||||
defaultRole: row.defaultRole as UserRole | null,
|
||||
keys: row.items
|
||||
.map((item) => item.key)
|
||||
.filter(knownKey)
|
||||
.sort(byCatalogOrder),
|
||||
memberCount: row._count.users,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function scopeLabel(group: { scope: string; scopeValue: string }) {
|
||||
const include = { items: true, _count: { select: { users: true } } } as const;
|
||||
type Group = Awaited<ReturnType<typeof prisma.permissionGroup.findMany<{ include: typeof include }>>>[number];
|
||||
const known = (key: string): key is PermissionKey => (PERMISSION_KEYS as readonly string[]).includes(key);
|
||||
const keysFor = (group: { items: { key: string }[] }) => group.items.map((i) => i.key).filter(known);
|
||||
const activeKeys = (group: { items: { key: string }[] }) =>
|
||||
keysFor(group).filter((key) => PERMISSION_META[key].kind !== 'admin');
|
||||
function label(scope: string, scopeValue: string) {
|
||||
try {
|
||||
return parsePermissionScope(group.scope as PermissionScope, group.scopeValue).scopeLabel;
|
||||
return parsePermissionScope(scope as PermissionScope, scopeValue).scopeLabel;
|
||||
} catch {
|
||||
return '未配置范围(无班级访问权)';
|
||||
}
|
||||
}
|
||||
|
||||
export async function scopeOptions() {
|
||||
const classes = await prisma.class.findMany({ distinct: ['grade'], select: { grade: true } });
|
||||
const raw = await getSetting('term.promotionPlan');
|
||||
const plan: PromotionPlan = raw ? JSON.parse(raw) : defaultPromotionPlan();
|
||||
const planGrades = plan.tracks.flatMap((track) => track.stages.map((stage) => stage.grade));
|
||||
function dto(group: Group): PermissionGroupDto {
|
||||
const inactiveKeys = keysFor(group).filter((k) => PERMISSION_META[k].kind === 'admin');
|
||||
return {
|
||||
grades: [...new Set([...classes.map((cls) => cls.grade), ...planGrades])].sort(),
|
||||
planGrades,
|
||||
divisions: plan.tracks.map((track) => ({
|
||||
name: track.name,
|
||||
grades: track.stages.map((stage) => stage.grade),
|
||||
})),
|
||||
...group,
|
||||
keys: activeKeys(group),
|
||||
scope: group.scope as PermissionScope,
|
||||
scopeLabel: label(group.scope, group.scopeValue),
|
||||
defaultScope: group.scope as PermissionScope,
|
||||
defaultScopeValue: group.scopeValue,
|
||||
defaultScopeLabel: label(group.scope, group.scopeValue),
|
||||
defaultRole: group.defaultRole === 'TEACHER' ? 'TEACHER' : null,
|
||||
kind: group.defaultRole ? 'default' : group.builtin ? 'builtin' : 'custom',
|
||||
inactiveKeys,
|
||||
warnings: inactiveKeys.length ? ['旧管理员专用键已停用,仅管理员固定拥有'] : [],
|
||||
memberCount: group._count.users,
|
||||
createdAt: group.createdAt.toISOString(),
|
||||
updatedAt: group.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async function validateScope(input: { scope?: PermissionScope; scopeValue?: string }) {
|
||||
try {
|
||||
return parsePermissionScope(
|
||||
input.scope ?? 'SELF_CLASS',
|
||||
input.scopeValue ?? '',
|
||||
(await scopeOptions()).grades,
|
||||
);
|
||||
} catch (error) {
|
||||
throw ApiError.badRequest(String(error instanceof Error ? error.message : error));
|
||||
}
|
||||
}
|
||||
|
||||
async function assertGrant(
|
||||
actor: TokenPayload,
|
||||
group: { scope: string; defaultRole?: string | null; keys: readonly string[] },
|
||||
) {
|
||||
await assertPermission(actor, 'permission:manage');
|
||||
if (actor.role === 'ADMIN') return;
|
||||
if (group.defaultRole) throw ApiError.forbidden('只有管理员可以管理角色默认组');
|
||||
if (group.scope !== 'SELF_CLASS') throw ApiError.forbidden('只有管理员可以管理年级、部或全校范围组');
|
||||
const own = await loadEffectivePermissions(actor);
|
||||
if (group.keys.some((key) => !own.has(key as PermissionKey)))
|
||||
throw ApiError.forbidden('不能授予或管理超出自己生效权限的组');
|
||||
}
|
||||
|
||||
function auditData(
|
||||
export function auditData(
|
||||
actor: TokenPayload,
|
||||
action: string,
|
||||
targetType: string,
|
||||
@@ -177,17 +84,34 @@ function auditData(
|
||||
afterJson: JSON.stringify(after),
|
||||
};
|
||||
}
|
||||
|
||||
export async function createGroup(
|
||||
actor: TokenPayload,
|
||||
input: CreatePermissionGroupRequest,
|
||||
): Promise<PermissionGroupDto> {
|
||||
const scope = await validateScope(input);
|
||||
const keys = normalizeKeys(input.keys);
|
||||
await assertGrant(actor, { ...scope, keys });
|
||||
if (await prisma.permissionGroup.findUnique({ where: { name: input.name.trim() } }))
|
||||
throw ApiError.conflict('权限组名称已存在');
|
||||
const created = await prisma.$transaction(async (tx) => {
|
||||
export async function scopeOptions() {
|
||||
const classes = await prisma.class.findMany({ distinct: ['grade'], select: { grade: true } });
|
||||
const raw = await getSetting('term.promotionPlan');
|
||||
const plan: PromotionPlan = raw ? JSON.parse(raw) : defaultPromotionPlan();
|
||||
const planGrades = plan.tracks.flatMap((t) => t.stages.map((s) => s.grade));
|
||||
return {
|
||||
grades: [...new Set([...classes.map((c) => c.grade), ...planGrades])].sort(),
|
||||
planGrades,
|
||||
divisions: plan.tracks.map((t) => ({ name: t.name, grades: t.stages.map((s) => s.grade) })),
|
||||
};
|
||||
}
|
||||
async function validateScope(scope: PermissionScope = 'TEACHING', scopeValue = '') {
|
||||
try {
|
||||
return parsePermissionScope(scope, scopeValue, (await scopeOptions()).grades);
|
||||
} catch (error) {
|
||||
throw ApiError.badRequest(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
function validateKeys(keys: readonly string[]) {
|
||||
if (keys.some((k) => !known(k) || PERMISSION_META[k].kind === 'admin'))
|
||||
throw ApiError.badRequest('教师模板不能包含管理员专用键或未知键');
|
||||
return [...new Set(keys)] as PermissionKey[];
|
||||
}
|
||||
export async function createGroup(actor: TokenPayload, input: CreatePermissionGroupRequest) {
|
||||
assertAdmin(actor);
|
||||
const scope = await validateScope(input.scope, input.scopeValue);
|
||||
const keys = validateKeys(input.keys ?? []);
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const row = await tx.permissionGroup.create({
|
||||
data: {
|
||||
name: input.name.trim(),
|
||||
@@ -196,35 +120,34 @@ export async function createGroup(
|
||||
scopeValue: scope.scopeValue,
|
||||
items: { create: keys.map((key) => ({ key })) },
|
||||
},
|
||||
select: GROUP_SELECT,
|
||||
include,
|
||||
});
|
||||
await tx.permissionAudit.create({
|
||||
data: auditData(actor, 'create', 'group', row.id, null, toGroupDto(row)),
|
||||
});
|
||||
return row;
|
||||
await tx.permissionAudit.create({ data: auditData(actor, 'create', 'group', row.id, null, dto(row)) });
|
||||
invalidateAuthPrincipals();
|
||||
return dto(row);
|
||||
});
|
||||
return toGroupDto(created);
|
||||
}
|
||||
|
||||
export async function updateGroup(
|
||||
actor: TokenPayload,
|
||||
groupId: string,
|
||||
input: UpdatePermissionGroupRequest,
|
||||
): Promise<PermissionGroupDto> {
|
||||
const current = await prisma.permissionGroup.findUnique({ where: { id: groupId }, select: GROUP_SELECT });
|
||||
if (!current) throw ApiError.notFound('权限组不存在');
|
||||
const before = toGroupDto(current);
|
||||
await assertGrant(actor, { ...current, keys: before.keys });
|
||||
const scope = await validateScope({
|
||||
scope: input.scope ?? before.scope,
|
||||
scopeValue: input.scopeValue ?? before.scopeValue,
|
||||
});
|
||||
const keys = normalizeKeys(input.keys ?? before.keys);
|
||||
await assertGrant(actor, { ...scope, defaultRole: current.defaultRole, keys });
|
||||
const updated = await prisma.$transaction(async (tx) => {
|
||||
await tx.permissionGroupItem.deleteMany({ where: { groupId } });
|
||||
export async function updateGroup(actor: TokenPayload, id: string, input: UpdatePermissionGroupRequest) {
|
||||
assertAdmin(actor);
|
||||
const current = await prisma.permissionGroup.findUnique({ where: { id }, include });
|
||||
if (!current || current.defaultRole === 'ADMIN') throw ApiError.notFound('模板不存在');
|
||||
const scope = await validateScope(
|
||||
input.scope ?? (current.scope as PermissionScope),
|
||||
input.scopeValue ?? current.scopeValue,
|
||||
);
|
||||
if (current.defaultRole === 'TEACHER' && scope.scope !== 'TEACHING')
|
||||
throw ApiError.badRequest('教师默认模板固定使用任教班级范围');
|
||||
// 旧管理员键仅停用;编辑可分配能力时保留它们,不能通过写入口重新授予。
|
||||
const inactiveKeys = keysFor(current).filter((key) => PERMISSION_META[key].kind === 'admin');
|
||||
const keys =
|
||||
input.keys === undefined
|
||||
? current.items.map((i) => i.key)
|
||||
: [...new Set([...validateKeys(input.keys), ...inactiveKeys])];
|
||||
let affected: string[] = [];
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
await tx.permissionGroupItem.deleteMany({ where: { groupId: id } });
|
||||
const row = await tx.permissionGroup.update({
|
||||
where: { id: groupId },
|
||||
where: { id },
|
||||
data: {
|
||||
name: input.name?.trim(),
|
||||
description: input.description?.trim(),
|
||||
@@ -232,120 +155,204 @@ export async function updateGroup(
|
||||
scopeValue: scope.scopeValue,
|
||||
items: { create: keys.map((key) => ({ key })) },
|
||||
},
|
||||
select: GROUP_SELECT,
|
||||
include,
|
||||
});
|
||||
await tx.permissionAudit.create({
|
||||
data: auditData(actor, 'update', 'group', id, dto(current), dto(row)),
|
||||
});
|
||||
const members =
|
||||
current.defaultRole === 'TEACHER'
|
||||
? await tx.user.findMany({ where: { role: 'TEACHER' }, select: { id: true } })
|
||||
: await tx.userPermissionGroup
|
||||
.findMany({ where: { groupId: id }, select: { userId: true } })
|
||||
.then((rows) => rows.map((r) => ({ id: r.userId })));
|
||||
affected = members.map((u) => u.id);
|
||||
return dto(row);
|
||||
});
|
||||
invalidateAuthPrincipals();
|
||||
disconnectUserSessions(affected);
|
||||
return result;
|
||||
}
|
||||
export async function deleteGroup(actor: TokenPayload, id: string) {
|
||||
assertAdmin(actor);
|
||||
const group = await prisma.permissionGroup.findUnique({ where: { id }, include });
|
||||
if (!group) throw ApiError.notFound('模板不存在');
|
||||
if (group.builtin || group.defaultRole) throw ApiError.badRequest('内置或默认模板不可删除');
|
||||
const members = await prisma.userPermissionGroup.findMany({
|
||||
where: { groupId: id },
|
||||
select: { userId: true },
|
||||
});
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.permissionGroup.delete({ where: { id } });
|
||||
await tx.permissionAudit.create({ data: auditData(actor, 'delete', 'group', id, dto(group), null) });
|
||||
});
|
||||
invalidateAuthPrincipals();
|
||||
disconnectUserSessions(members.map((m) => m.userId));
|
||||
return { id };
|
||||
}
|
||||
export async function listMembers(actor: TokenPayload): Promise<PermissionMemberDto[]> {
|
||||
assertAdmin(actor);
|
||||
await ensureDefaultGroups();
|
||||
const defaultGroup = await prisma.permissionGroup.findUniqueOrThrow({
|
||||
where: { defaultRole: 'TEACHER' },
|
||||
include: { items: true },
|
||||
});
|
||||
const users = await prisma.user.findMany({
|
||||
where: { role: { in: ['ADMIN', 'TEACHER'] } },
|
||||
include: { permissionGroups: { include: { group: { include: { items: true } } } } },
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
return Promise.all(
|
||||
users.map(async (u) => {
|
||||
const user: TokenPayload = {
|
||||
sub: u.id,
|
||||
role: u.role as TokenPayload['role'],
|
||||
username: u.username,
|
||||
name: u.name,
|
||||
classId: null,
|
||||
};
|
||||
const auth = await principalForAssignments(user, u.permissionGroups, defaultGroup);
|
||||
const assignments =
|
||||
u.role === 'ADMIN'
|
||||
? []
|
||||
: u.permissionGroups.map((a) => ({
|
||||
groupId: a.groupId,
|
||||
groupName: a.group.name,
|
||||
scope: a.scope as PermissionScope,
|
||||
scopeValue: a.scopeValue,
|
||||
scopeLabel: label(a.scope, a.scopeValue),
|
||||
}));
|
||||
return {
|
||||
id: u.id,
|
||||
username: u.username,
|
||||
name: u.name,
|
||||
role: user.role,
|
||||
fixed: u.role === 'ADMIN',
|
||||
assignments,
|
||||
groupIds: [...new Set(assignments.map((a) => a.groupId))],
|
||||
permissions: [...auth.permissions],
|
||||
permissionCount: auth.permissions.size,
|
||||
scopeLabels: assignments.length
|
||||
? [...new Set(assignments.map((a) => a.scopeLabel))]
|
||||
: [u.role === 'ADMIN' ? '固定全部权限' : '默认模板 · 任教班级'],
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
export async function groupMembers(actor: TokenPayload, id: string) {
|
||||
assertAdmin(actor);
|
||||
const group = await prisma.permissionGroup.findUnique({ where: { id } });
|
||||
if (!group || group.defaultRole === 'ADMIN') throw ApiError.notFound('模板不存在');
|
||||
return (await listMembers(actor)).filter(
|
||||
(u) =>
|
||||
!u.fixed && (u.groupIds.includes(id) || (group.defaultRole === 'TEACHER' && !u.assignments.length)),
|
||||
);
|
||||
}
|
||||
async function assignmentRows(input: AssignUserGroupsRequest): Promise<AssignmentRow[]> {
|
||||
const ids = input.assignments?.map((a) => a.groupId) ?? input.groupIds ?? [];
|
||||
const groups = await prisma.permissionGroup.findMany({
|
||||
where: { id: { in: ids } },
|
||||
include: { items: true },
|
||||
});
|
||||
if (new Set(ids).size !== groups.length || groups.some((g) => g.defaultRole === 'ADMIN'))
|
||||
throw ApiError.badRequest('模板不存在或已停用');
|
||||
const assignments =
|
||||
input.assignments ??
|
||||
ids.map((groupId) => {
|
||||
const g = groups.find((g) => g.id === groupId)!;
|
||||
return { groupId, scope: g.scope as PermissionScope, scopeValue: g.scopeValue };
|
||||
});
|
||||
const rows: AssignmentRow[] = [];
|
||||
for (const assignment of assignments) {
|
||||
const group = groups.find((g) => g.id === assignment.groupId)!;
|
||||
const scope = await validateScope(assignment.scope, assignment.scopeValue);
|
||||
if (group.defaultRole === 'TEACHER' && scope.scope !== 'TEACHING')
|
||||
throw ApiError.badRequest('教师默认模板只允许任教班级范围');
|
||||
if (
|
||||
activeKeys(group).some(
|
||||
(k) => PERMISSION_META[k].kind === 'class' && !PERMISSION_META[k].allowedScopes.includes(scope.scope),
|
||||
)
|
||||
)
|
||||
throw ApiError.badRequest('分配范围不适用于模板能力');
|
||||
rows.push({ groupId: group.id, scope: scope.scope, scopeValue: scope.scopeValue, group });
|
||||
}
|
||||
return [...new Map(rows.map((a) => [`${a.groupId}:${a.scope}:${a.scopeValue}`, a])).values()];
|
||||
}
|
||||
export async function effectivePermissions(
|
||||
actor: TokenPayload,
|
||||
id: string,
|
||||
preview?: AssignUserGroupsRequest,
|
||||
): Promise<EffectivePermissionsDto> {
|
||||
assertAdmin(actor);
|
||||
await ensureDefaultGroups();
|
||||
const u = await prisma.user.findUnique({
|
||||
where: { id },
|
||||
include: { permissionGroups: { include: { group: { include: { items: true } } } } },
|
||||
});
|
||||
if (!u) throw ApiError.notFound('账号不存在');
|
||||
const user: TokenPayload = {
|
||||
sub: id,
|
||||
name: u.name,
|
||||
username: u.username,
|
||||
role: u.role as TokenPayload['role'],
|
||||
classId: null,
|
||||
};
|
||||
if (preview && u.role === 'ADMIN') throw ApiError.badRequest('管理员固定全部权限,不接受分配');
|
||||
const auth = await principalForAssignments(
|
||||
user,
|
||||
preview ? await assignmentRows(preview) : u.permissionGroups,
|
||||
);
|
||||
const classes = await prisma.class.findMany({ select: { id: true, name: true } });
|
||||
const entries = await Promise.all(
|
||||
[...auth.permissions].map(async (key) => {
|
||||
const ids = await classIdsFor(user, key, auth);
|
||||
return {
|
||||
key,
|
||||
scopes: [...new Set((auth.grants.get(key) ?? []).map((g) => label(g.scope, g.scopeValue)))],
|
||||
classes: classes.filter((c) => ids.includes(c.id)),
|
||||
classCount: ids.length,
|
||||
};
|
||||
}),
|
||||
);
|
||||
return {
|
||||
permissions: [...auth.permissions],
|
||||
entries,
|
||||
conflicts: entries
|
||||
.filter((e) => e.scopes.length > 1)
|
||||
.map((e) => `${PERMISSION_LABELS[e.key]}:实际合并范围 ${e.scopes.join(' ∪ ')};较窄分配可能被覆盖`),
|
||||
headOnlyKeys: [...auth.permissions].filter((k) => PERMISSION_META[k].relation === 'head'),
|
||||
};
|
||||
}
|
||||
export async function assignUserGroups(actor: TokenPayload, userId: string, input: AssignUserGroupsRequest) {
|
||||
assertAdmin(actor);
|
||||
const u = await prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!u) throw ApiError.notFound('账号不存在');
|
||||
if (u.role !== 'TEACHER') throw ApiError.badRequest('管理员固定全部权限,不能分配模板');
|
||||
const rows = await assignmentRows(input);
|
||||
const before = (await listMembers(actor)).find((m) => m.id === userId)!;
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.userPermissionGroup.deleteMany({ where: { userId } });
|
||||
if (rows.length)
|
||||
await tx.userPermissionGroup.createMany({
|
||||
data: rows.map(({ groupId, scope, scopeValue }) => ({ userId, groupId, scope, scopeValue })),
|
||||
});
|
||||
await tx.permissionAudit.create({
|
||||
data: auditData(
|
||||
actor,
|
||||
current.defaultRole ? 'update-default' : 'update',
|
||||
'group',
|
||||
groupId,
|
||||
before,
|
||||
toGroupDto(row),
|
||||
'assign',
|
||||
'user',
|
||||
userId,
|
||||
before.assignments,
|
||||
rows.map(({ groupId, scope, scopeValue }) => ({ groupId, scope, scopeValue })),
|
||||
),
|
||||
});
|
||||
return row;
|
||||
});
|
||||
return toGroupDto(updated);
|
||||
invalidateAuthPrincipals();
|
||||
disconnectUserSessions([userId]);
|
||||
return (await listMembers(actor)).find((m) => m.id === userId)!;
|
||||
}
|
||||
|
||||
export async function deleteGroup(actor: TokenPayload, groupId: string) {
|
||||
const current = await prisma.permissionGroup.findUnique({ where: { id: groupId }, select: GROUP_SELECT });
|
||||
if (!current) throw ApiError.notFound('权限组不存在');
|
||||
await assertGrant(actor, { ...current, keys: current.items.map((item) => item.key) });
|
||||
if (current.builtin) throw ApiError.badRequest('内置权限组不可删除');
|
||||
// 删除最后一组会回落默认组;非管理员不能借删除制造额外授权。
|
||||
if (actor.role !== 'ADMIN') throw ApiError.forbidden('删除组会改变成员默认权限,仅管理员可执行');
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.permissionGroup.delete({ where: { id: groupId } });
|
||||
await tx.permissionAudit.create({
|
||||
data: auditData(actor, 'delete', 'group', groupId, toGroupDto(current), null),
|
||||
});
|
||||
});
|
||||
return { id: groupId };
|
||||
}
|
||||
|
||||
/** 组和成员各预取一次,账号数增长不会增加权限查询次数。 */
|
||||
export async function listMembers(): Promise<PermissionMemberDto[]> {
|
||||
await ensureDefaultGroups();
|
||||
const [rows, groups] = await Promise.all([
|
||||
prisma.user.findMany({
|
||||
where: { role: { in: ['ADMIN', 'TEACHER'] } },
|
||||
select: MEMBER_SELECT,
|
||||
orderBy: [{ role: 'asc' }, { name: 'asc' }],
|
||||
}),
|
||||
prisma.permissionGroup.findMany({ include: { items: true } }),
|
||||
]);
|
||||
return rows.map((row) => {
|
||||
const groupIds = row.permissionGroups.map((link) => link.groupId);
|
||||
const effective = groups.filter((group) =>
|
||||
groupIds.length ? groupIds.includes(group.id) : group.defaultRole === row.role,
|
||||
);
|
||||
const permissions = normalizeKeys([
|
||||
...effective.flatMap((group) => group.items.map((item) => item.key)),
|
||||
...(row.role === 'ADMIN' ? ADMIN_UNREMOVABLE_PERMISSIONS : []),
|
||||
]);
|
||||
return {
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
name: row.name,
|
||||
role: row.role as UserRole,
|
||||
groupIds,
|
||||
permissions,
|
||||
permissionCount: permissions.length,
|
||||
scopeLabels: [...new Set(effective.map(scopeLabel))],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function groupMembers(groupId: string) {
|
||||
const group = await prisma.permissionGroup.findUnique({ where: { id: groupId } });
|
||||
if (!group) throw ApiError.notFound('权限组不存在');
|
||||
return (await listMembers()).filter((member) =>
|
||||
member.groupIds.length ? member.groupIds.includes(groupId) : member.role === group.defaultRole,
|
||||
);
|
||||
}
|
||||
|
||||
export async function assignUserGroups(
|
||||
actor: TokenPayload,
|
||||
userId: string,
|
||||
input: AssignUserGroupsRequest,
|
||||
): Promise<PermissionMemberDto> {
|
||||
await assertPermission(actor, 'permission:manage');
|
||||
const user = await prisma.user.findUnique({ where: { id: userId }, select: MEMBER_SELECT });
|
||||
if (!user || !['ADMIN', 'TEACHER'].includes(user.role)) throw ApiError.notFound('员工账号不存在');
|
||||
const ids = [...new Set(input.groupIds)];
|
||||
const groups = await prisma.permissionGroup.findMany({
|
||||
where: ids.length ? { id: { in: ids } } : { defaultRole: user.role },
|
||||
include: { items: true },
|
||||
});
|
||||
if (ids.length && groups.length !== ids.length) throw ApiError.badRequest('部分权限组不存在');
|
||||
for (const group of groups) {
|
||||
if (group.defaultRole && group.defaultRole !== user.role)
|
||||
throw ApiError.forbidden('默认组只能分配给同角色账号');
|
||||
// 同角色默认组可显式选中,但仍检查范围与键的授权能力。
|
||||
await assertGrant(actor, { ...group, defaultRole: null, keys: group.items.map((item) => item.key) });
|
||||
}
|
||||
if (actor.role !== 'ADMIN' && user.role === 'ADMIN') throw ApiError.forbidden('非管理员不能更改管理员分组');
|
||||
const before = (await listMembers()).find((member) => member.id === userId)!;
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.userPermissionGroup.deleteMany({ where: { userId } });
|
||||
if (ids.length)
|
||||
await tx.userPermissionGroup.createMany({ data: ids.map((groupId) => ({ userId, groupId })) });
|
||||
await tx.permissionAudit.create({
|
||||
data: auditData(actor, 'assign', 'user', userId, before, {
|
||||
groupIds: ids,
|
||||
permissions: normalizeKeys([
|
||||
...groups.flatMap((group) => group.items.map((item) => item.key)),
|
||||
...(user.role === 'ADMIN' ? ADMIN_UNREMOVABLE_PERMISSIONS : []),
|
||||
]),
|
||||
}),
|
||||
});
|
||||
});
|
||||
return (await listMembers()).find((member) => member.id === userId)!;
|
||||
}
|
||||
|
||||
export async function listAudits(page = 1, pageSize = 30) {
|
||||
export async function listAudits(actor: TokenPayload, page = 1, pageSize = 30) {
|
||||
assertAdmin(actor);
|
||||
const [items, total] = await Promise.all([
|
||||
prisma.permissionAudit.findMany({
|
||||
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
||||
@@ -356,71 +363,68 @@ export async function listAudits(page = 1, pageSize = 30) {
|
||||
]);
|
||||
return { items, total, page, pageSize };
|
||||
}
|
||||
|
||||
/** GET /api/permissions/catalog - 权限键目录(权限页整棵勾选树都按它渲染) */
|
||||
export async function permissionCatalog(): Promise<PermissionCatalogDto> {
|
||||
export async function permissionCatalog(actor: TokenPayload): Promise<PermissionCatalogDto> {
|
||||
assertAdmin(actor);
|
||||
await ensureDefaultGroups();
|
||||
const defaults = await prisma.permissionGroup.findMany({
|
||||
where: { defaultRole: { not: null } },
|
||||
const group = await prisma.permissionGroup.findUniqueOrThrow({
|
||||
where: { defaultRole: 'TEACHER' },
|
||||
include: { items: true },
|
||||
});
|
||||
const roles = Object.keys(ROLE_DEFAULT_PERMISSIONS) as UserRole[];
|
||||
const keysForRole = (role: UserRole): PermissionKey[] => {
|
||||
const keys =
|
||||
defaults.find((group) => group.defaultRole === role)?.items.map((item) => item.key) ??
|
||||
ROLE_DEFAULT_PERMISSIONS[role];
|
||||
return normalizeKeys(role === 'ADMIN' ? [...keys, ...ADMIN_UNREMOVABLE_PERMISSIONS] : keys);
|
||||
};
|
||||
return {
|
||||
keys: [...PERMISSION_KEYS],
|
||||
labels: { ...PERMISSION_LABELS },
|
||||
categories: PERMISSION_CATEGORIES.map((category) => ({
|
||||
title: category.title,
|
||||
keys: [...category.keys],
|
||||
})),
|
||||
meta: PERMISSION_META,
|
||||
labels: PERMISSION_LABELS,
|
||||
categories: PERMISSION_CATEGORIES.map((c) => ({ title: c.title, keys: [...c.keys] })),
|
||||
defaultRoles: Object.fromEntries(
|
||||
PERMISSION_KEYS.map((key) => [key, roles.filter((role) => keysForRole(role).includes(key))]),
|
||||
) as Record<PermissionKey, UserRole[]>,
|
||||
roleDefaults: Object.fromEntries(roles.map((role) => [role, keysForRole(role)])) as Record<
|
||||
UserRole,
|
||||
PermissionKey[]
|
||||
>,
|
||||
unremovable: [...ADMIN_UNREMOVABLE_PERMISSIONS],
|
||||
PERMISSION_KEYS.map((k) => [k, [...PERMISSION_META[k].defaultRoles]]),
|
||||
) as PermissionCatalogDto['defaultRoles'],
|
||||
roleDefaults: {
|
||||
...ROLE_DEFAULT_PERMISSIONS,
|
||||
ADMIN: [...PERMISSION_KEYS],
|
||||
TEACHER: activeKeys(group),
|
||||
CLASS_DEVICE: [],
|
||||
},
|
||||
unremovable: [...PERMISSION_KEYS],
|
||||
};
|
||||
}
|
||||
|
||||
/** GET /api/permissions/groups - 权限组列表(内置组排在最前,其余按创建时间) */
|
||||
export async function listGroups(): Promise<PermissionGroupDto[]> {
|
||||
export async function listGroups(actor: TokenPayload): Promise<PermissionGroupDto[]> {
|
||||
assertAdmin(actor);
|
||||
await ensureDefaultGroups();
|
||||
const rows = await prisma.permissionGroup.findMany({
|
||||
select: GROUP_SELECT,
|
||||
orderBy: [{ builtin: 'desc' }, { createdAt: 'asc' }],
|
||||
});
|
||||
const members = await listMembers();
|
||||
return (rows as GroupRow[]).map((row) => ({
|
||||
...toGroupDto(row),
|
||||
memberCount: members.filter((m) =>
|
||||
m.groupIds.length ? m.groupIds.includes(row.id) : row.defaultRole === m.role,
|
||||
const members = await listMembers(actor);
|
||||
return (
|
||||
await prisma.permissionGroup.findMany({
|
||||
where: { OR: [{ defaultRole: null }, { defaultRole: 'TEACHER' }] },
|
||||
include,
|
||||
orderBy: [{ builtin: 'desc' }, { createdAt: 'asc' }],
|
||||
})
|
||||
).map((group) => ({
|
||||
...dto(group),
|
||||
memberCount: members.filter(
|
||||
(m) =>
|
||||
!m.fixed &&
|
||||
(m.groupIds.includes(group.id) || (group.defaultRole === 'TEACHER' && !m.assignments.length)),
|
||||
).length,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/permissions/me - 当前账号的生效权限。
|
||||
*
|
||||
* 两端按它决定"入口显不显示"(真正的拦截始终在服务端)。
|
||||
* 班级端会话恒为空集:它的能力由班级维度断言决定。
|
||||
*/
|
||||
export async function myPermissions(user: TokenPayload): Promise<MyPermissionsDto> {
|
||||
const permissions = await loadEffectivePermissions(user);
|
||||
const fromGroups =
|
||||
user.classSession || user.role === 'CLASS_DEVICE'
|
||||
? false
|
||||
: (await prisma.userPermissionGroup.count({ where: { userId: user.sub } })) > 0;
|
||||
|
||||
export async function myPermissions(user: TokenPayload) {
|
||||
const auth = await loadAuthPrincipal(user);
|
||||
const classKeys = [...auth.permissions].filter((key) => PERMISSION_META[key].kind === 'class');
|
||||
const managementKeys = classKeys.filter((key) =>
|
||||
['roster:manage', 'schedule:write', 'import:students', 'import:schedule', 'grade:write'].includes(key),
|
||||
);
|
||||
const [scopes, management] = await Promise.all([
|
||||
Promise.all(classKeys.map(async (key) => [key, await classIdsFor(user, key, auth)] as const)),
|
||||
Promise.all(managementKeys.map(async (key) => [key, await manageClassIdsFor(user, key, auth)] as const)),
|
||||
]);
|
||||
return {
|
||||
role: user.role,
|
||||
permissions: [...permissions].sort(byCatalogOrder),
|
||||
fromGroups,
|
||||
permissions: [...auth.permissions],
|
||||
fromGroups: auth.fromGroups,
|
||||
classScopes: Object.fromEntries(scopes),
|
||||
manageScopes: Object.fromEntries(management),
|
||||
actionScopes: await actionScopesFor(user, scopes, management),
|
||||
allScopeKeys: [...auth.grants]
|
||||
.filter(([, grants]) => grants.some((g) => g.scope === 'ALL'))
|
||||
.map(([key]) => key),
|
||||
};
|
||||
}
|
||||
@@ -40,8 +40,8 @@ export async function listSchedules(
|
||||
user: TokenPayload,
|
||||
options: ListScheduleOptions,
|
||||
): Promise<ScheduleDto[]> {
|
||||
if (options.classId) await assertClassAccess(user, options.classId);
|
||||
const classIds = options.classId ? [options.classId] : await getAccessibleClassIds(user);
|
||||
if (options.classId) await assertClassAccess(user, options.classId, 'schedule:read');
|
||||
const classIds = options.classId ? [options.classId] : await getAccessibleClassIds(user, 'schedule:read');
|
||||
const result: ScheduleDto[] = [];
|
||||
for (const classId of classIds) {
|
||||
if (options.week) {
|
||||
@@ -98,7 +98,7 @@ export async function getActiveScheduleWeek(
|
||||
) {
|
||||
const classId = isClassDevice(user) ? requireClassDeviceClassId(user) : options.classId;
|
||||
if (!classId) throw ApiError.badRequest('请指定班级');
|
||||
await assertClassAccess(user, classId);
|
||||
await assertClassAccess(user, classId, 'schedule:read');
|
||||
const context = await loadTermContext(classId),
|
||||
week = options.week ?? resolveCurrentWeek(new Date(), context);
|
||||
if (week > context.maxWeek) throw ApiError.badRequest('周次超过本班教学周数');
|
||||
@@ -124,7 +124,7 @@ export async function computeClassStatus(classId: string, at = new Date()): Prom
|
||||
export async function getClassStatus(user: TokenPayload, options: { classId?: string; at?: Date } = {}) {
|
||||
const classId = isClassDevice(user) ? requireClassDeviceClassId(user) : options.classId;
|
||||
if (!classId) throw ApiError.badRequest('请指定班级');
|
||||
await assertClassAccess(user, classId);
|
||||
await assertClassAccess(user, classId, 'schedule:read');
|
||||
return computeClassStatus(classId, options.at);
|
||||
}
|
||||
|
||||
@@ -151,8 +151,8 @@ function matches(p: PlanRow, day: number, week: number) {
|
||||
(p.weekCountDiv === 0 || ((week - 1) % p.weekCountDivTotal) + 1 === p.weekCountDiv)
|
||||
);
|
||||
}
|
||||
async function assertCourse(courseId: string, classId: string) {
|
||||
if (!(await prisma.course.findFirst({ where: { id: courseId, classId } })))
|
||||
async function assertCourse(courseId: string, classId: string, db: Tx = prisma) {
|
||||
if (!(await db.course.findFirst({ where: { id: courseId, classId } })))
|
||||
throw ApiError.badRequest('课程不属于本班');
|
||||
}
|
||||
export async function createSchedule(
|
||||
@@ -161,10 +161,8 @@ export async function createSchedule(
|
||||
transaction?: Tx,
|
||||
identity?: string,
|
||||
): Promise<ScheduleDto> {
|
||||
if (!transaction) {
|
||||
await assertCanManageSchedule(user, input.classId);
|
||||
await assertCourse(input.courseId, input.classId);
|
||||
}
|
||||
await assertCanManageSchedule(user, input.classId, transaction);
|
||||
await assertCourse(input.courseId, input.classId, transaction);
|
||||
const cls = await (transaction ?? prisma).class.findUniqueOrThrow({ where: { id: input.classId } }),
|
||||
max = transaction
|
||||
? normalizeMaxWeeks(
|
||||
@@ -282,6 +280,8 @@ export async function updateSchedule(
|
||||
const rule: Partial<{ weekStart: number; weekEnd: number; weekParity: 'ALL' | 'ODD' | 'EVEN' }> = stored
|
||||
? JSON.parse(stored.value)
|
||||
: {};
|
||||
for (const targetClassId of new Set(entries.map((e) => e.plan.classId)))
|
||||
await assertCanManageSchedule(user, targetClassId);
|
||||
const classId = entries[0]!.plan.classId;
|
||||
await assertCanManageSchedule(user, classId);
|
||||
if (input.courseId) await assertCourse(input.courseId, classId);
|
||||
|
||||
@@ -10,7 +10,7 @@ import { idParamSchema } from '../../lib/schemas.js';
|
||||
import {
|
||||
authenticate,
|
||||
getAuthUser,
|
||||
requireStaffPermission,
|
||||
requirePermissionOrClassDevice,
|
||||
requirePermission,
|
||||
} from '../../middleware/auth.js';
|
||||
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
|
||||
@@ -30,18 +30,18 @@ const router = Router();
|
||||
router.use(authenticate());
|
||||
router.get(
|
||||
'/active-week',
|
||||
requireStaffPermission('schedule:read'),
|
||||
requirePermissionOrClassDevice('schedule:read'),
|
||||
validate({ query: gridQuerySchema }),
|
||||
async (req, res) =>
|
||||
sendOk(res, await scheduleService.getActiveScheduleWeek(getAuthUser(req), validatedQuery(req))),
|
||||
);
|
||||
router.get(
|
||||
'/scheduled',
|
||||
requireStaffPermission('schedule:read'),
|
||||
requirePermissionOrClassDevice('schedule:read'),
|
||||
validate({ query: z.object({ classId: z.string().min(1) }) }),
|
||||
async (req, res) => {
|
||||
const { classId } = validatedQuery<{ classId: string }>(req);
|
||||
await assertClassAccess(getAuthUser(req), classId);
|
||||
await assertClassAccess(getAuthUser(req), classId, 'schedule:read');
|
||||
sendOk(
|
||||
res,
|
||||
await prisma.scheduledClassPlan.findMany({
|
||||
@@ -109,7 +109,7 @@ router.post(
|
||||
/** GET /api/schedules?classId=&week=&dayOfWeek= - 课表列表 */
|
||||
router.get(
|
||||
'/',
|
||||
requireStaffPermission('schedule:read'),
|
||||
requirePermissionOrClassDevice('schedule:read'),
|
||||
validate({ query: listSchedulesQuerySchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -121,7 +121,7 @@ router.get(
|
||||
/** GET /api/schedules/grid?classId=&week= - 周视图(7 列结构) */
|
||||
router.get(
|
||||
'/grid',
|
||||
requireStaffPermission('schedule:read'),
|
||||
requirePermissionOrClassDevice('schedule:read'),
|
||||
validate({ query: gridQuerySchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -145,7 +145,7 @@ router.get(
|
||||
*/
|
||||
router.get(
|
||||
'/current',
|
||||
requireStaffPermission('schedule:read'),
|
||||
requirePermissionOrClassDevice('schedule:read'),
|
||||
validate({ query: classStatusQuerySchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
|
||||
@@ -8,11 +8,11 @@ import {
|
||||
getAccessibleClassIds,
|
||||
resolveClassScope,
|
||||
} from '../../lib/access.js';
|
||||
import { assertStaffPermission, assertPermission } from '../../lib/permissions.js';
|
||||
import { assertPermissionOrClassDevice, assertPermission } from '../../lib/permissions.js';
|
||||
import { changeStudentLifecycle, notifyStudentClasses } from '../../lib/student-lifecycle.js';
|
||||
import { activeStudentWhere } from '../../lib/students.js';
|
||||
import { prisma } from '../../lib/db.js';
|
||||
import { hasAllScope } from '../../lib/permission-scope.js';
|
||||
import { hasOrganizationalGrant } from '../../lib/authz/index.js';
|
||||
import { ApiError } from '../../lib/http.js';
|
||||
import type { TokenPayload } from '../../lib/jwt.js';
|
||||
import { toStudentDto, toStudentTransferDto } from '../../lib/mappers.js';
|
||||
@@ -38,7 +38,7 @@ const withClass = { class: { select: { name: true, grade: true } } } as const;
|
||||
|
||||
/** 学生名单(按班级权限过滤) */
|
||||
export async function listStudents(user: TokenPayload, options: ListStudentsQuery): Promise<StudentDto[]> {
|
||||
await assertStaffPermission(user, 'roster:read');
|
||||
await assertPermissionOrClassDevice(user, 'roster:read');
|
||||
const scope = await resolveClassScope(user, options.classId, 'roster:read');
|
||||
|
||||
const students = await prisma.student.findMany({
|
||||
@@ -219,16 +219,16 @@ export async function listTransfers(
|
||||
options: ListTransfersQuery,
|
||||
): Promise<PaginatedResult<StudentTransferDto>> {
|
||||
await assertPermission(user, 'roster:read');
|
||||
const hasGlobalRosterScope = isAdmin(user) || (await hasAllScope(user, 'roster:read'));
|
||||
const hasGlobalRosterScope = isAdmin(user) || (await hasOrganizationalGrant(user, 'roster:read'));
|
||||
|
||||
if (options.classId) await assertClassAccess(user, options.classId);
|
||||
if (options.classId) await assertClassAccess(user, options.classId, 'roster:read');
|
||||
if (options.studentId) {
|
||||
const student = await prisma.student.findUnique({
|
||||
where: { id: options.studentId },
|
||||
select: { classId: true },
|
||||
});
|
||||
if (!student) throw ApiError.notFound('学生不存在');
|
||||
if (student.classId) await assertClassAccess(user, student.classId);
|
||||
if (student.classId) await assertClassAccess(user, student.classId, 'roster:read');
|
||||
else if (!hasGlobalRosterScope) throw ApiError.forbidden('无权查看未分班学生的调班历史');
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { requireAdmin } from '../../middleware/auth.js';
|
||||
import { Router } from 'express';
|
||||
import { API_PATHS } from '@classhelper/shared';
|
||||
import { sendOk } from '../../lib/http.js';
|
||||
@@ -13,7 +14,7 @@ import {
|
||||
import * as systemService from './system.service.js';
|
||||
|
||||
const router = Router();
|
||||
router.use(authenticate());
|
||||
router.use(authenticate(), requireAdmin());
|
||||
|
||||
/**
|
||||
* 系统设置与运维接口(**全部仅 ADMIN**,权限键 `system:manage`)。
|
||||
@@ -46,7 +47,7 @@ router.put(
|
||||
validate({ body: updateAutostartSchema }),
|
||||
async (req, res) => {
|
||||
const input = validatedBody<UpdateAutostartInput>(req);
|
||||
sendOk(res, await systemService.updateAutostart(input), '开机自启设置已生效');
|
||||
sendOk(res, await systemService.updateAutostart(getAuthUser(req), input), '开机自启设置已生效');
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { assertAdmin } from '../../lib/authz/index.js';
|
||||
import type { TokenPayload } from '../../lib/jwt.js';
|
||||
import { createAutostartController } from './autostart.js';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
@@ -10,7 +12,6 @@ import {
|
||||
} from '@classhelper/shared';
|
||||
import { env } from '../../config/env.js';
|
||||
import { prisma } from '../../lib/db.js';
|
||||
import type { TokenPayload } from '../../lib/jwt.js';
|
||||
import { logger } from '../../lib/logger.js';
|
||||
import { SETTING_KEYS, getBooleanSetting, setBooleanSetting } from '../../lib/settings.js';
|
||||
import { APP_VERSION } from '../../lib/version.js';
|
||||
@@ -39,6 +40,7 @@ export async function updateSystemSettings(
|
||||
user: TokenPayload,
|
||||
input: UpdateSystemSettingsInput,
|
||||
): Promise<SystemSettingsDto> {
|
||||
assertAdmin(user);
|
||||
if (input.developerMode !== undefined) {
|
||||
await setBooleanSetting(SETTING_KEYS.developerMode, input.developerMode);
|
||||
logger.info(`开发者模式已${input.developerMode ? '开启' : '关闭'}(操作人=${user.username})`);
|
||||
@@ -55,7 +57,11 @@ const autostart = createAutostartController({
|
||||
export async function getAutostartStatus(): Promise<AutostartStatusDto> {
|
||||
return autostart.read();
|
||||
}
|
||||
export async function updateAutostart(input: UpdateAutostartInput): Promise<AutostartStatusDto> {
|
||||
export async function updateAutostart(
|
||||
actor: TokenPayload,
|
||||
input: UpdateAutostartInput,
|
||||
): Promise<AutostartStatusDto> {
|
||||
assertAdmin(actor);
|
||||
return autostart.write(input.enabled);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { Router } from 'express';
|
||||
import { sendCreated, sendOk } from '../../lib/http.js';
|
||||
import { idParamSchema } from '../../lib/schemas.js';
|
||||
import { authenticate, requirePermission, requireAnyPermission } from '../../middleware/auth.js';
|
||||
import {
|
||||
authenticate,
|
||||
getAuthUser,
|
||||
requireAdmin,
|
||||
requirePermission,
|
||||
requireAnyPermission,
|
||||
} from '../../middleware/auth.js';
|
||||
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
|
||||
import { defineModule } from '../module.types.js';
|
||||
import {
|
||||
@@ -27,39 +33,50 @@ router.get(
|
||||
validate({ query: listTeachersQuerySchema }),
|
||||
async (req, res) => {
|
||||
const { keyword } = validatedQuery<{ keyword?: string }>(req);
|
||||
sendOk(res, await teacherService.listTeachers(keyword), '获取教师列表成功');
|
||||
sendOk(res, await teacherService.listTeachers(getAuthUser(req), keyword), '获取教师列表成功');
|
||||
},
|
||||
);
|
||||
|
||||
/** POST /api/teachers - 录入教师账号(需 teacher:manage) */
|
||||
router.post(
|
||||
'/',
|
||||
requireAdmin(),
|
||||
requirePermission('teacher:manage'),
|
||||
validate({ body: createTeacherSchema }),
|
||||
async (req, res) => {
|
||||
sendCreated(res, await teacherService.createTeacher(req.body as CreateTeacherInput), '教师账号创建成功');
|
||||
sendCreated(
|
||||
res,
|
||||
await teacherService.createTeacher(getAuthUser(req), req.body as CreateTeacherInput),
|
||||
'教师账号创建成功',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
/** PATCH /api/teachers/:id - 编辑教师(姓名 / 用户名 / 角色) */
|
||||
router.patch(
|
||||
'/:id',
|
||||
requireAdmin(),
|
||||
requirePermission('teacher:manage'),
|
||||
validate({ params: idParamSchema, body: updateTeacherSchema }),
|
||||
async (req, res) => {
|
||||
const { id } = validatedParams<{ id: string }>(req);
|
||||
sendOk(res, await teacherService.updateTeacher(id, req.body as UpdateTeacherInput), '教师信息更新成功');
|
||||
sendOk(
|
||||
res,
|
||||
await teacherService.updateTeacher(getAuthUser(req), id, req.body as UpdateTeacherInput),
|
||||
'教师信息更新成功',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
/** POST /api/teachers/:id/reset-password - 重置教师密码 */
|
||||
router.post(
|
||||
'/:id/reset-password',
|
||||
requireAdmin(),
|
||||
requirePermission('teacher:manage'),
|
||||
validate({ params: idParamSchema, body: resetTeacherPasswordSchema }),
|
||||
async (req, res) => {
|
||||
const { id } = validatedParams<{ id: string }>(req);
|
||||
await teacherService.resetTeacherPassword(id, req.body as ResetTeacherPasswordInput);
|
||||
await teacherService.resetTeacherPassword(getAuthUser(req), id, req.body as ResetTeacherPasswordInput);
|
||||
sendOk(res, { id }, '密码已重置');
|
||||
},
|
||||
);
|
||||
@@ -67,11 +84,12 @@ router.post(
|
||||
/** DELETE /api/teachers/:id - 删除教师账号(有班级职责/课程时拒绝,见 service) */
|
||||
router.delete(
|
||||
'/:id',
|
||||
requireAdmin(),
|
||||
requirePermission('teacher:manage'),
|
||||
validate({ params: idParamSchema }),
|
||||
async (req, res) => {
|
||||
const { id } = validatedParams<{ id: string }>(req);
|
||||
await teacherService.deleteTeacher(id);
|
||||
await teacherService.deleteTeacher(getAuthUser(req), id);
|
||||
sendOk(res, { id }, '教师账号已删除');
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import type { Prisma } from '../../generated/prisma/client.js';
|
||||
import { disconnectUserSessions } from '../../realtime/bus.js';
|
||||
import { assertAdmin, assertPermission } from '../../lib/authz/index.js';
|
||||
import type { TokenPayload } from '../../lib/jwt.js';
|
||||
import { auditData } from '../permissions/permissions.service.js';
|
||||
import type { UserDto } from '@classhelper/shared';
|
||||
import { env } from '../../config/env.js';
|
||||
import { prisma } from '../../lib/db.js';
|
||||
@@ -17,7 +22,8 @@ import type {
|
||||
*/
|
||||
|
||||
/** 教师列表(教师 + 管理员账号) */
|
||||
export async function listTeachers(keyword?: string): Promise<UserDto[]> {
|
||||
export async function listTeachers(actor: TokenPayload, keyword?: string): Promise<UserDto[]> {
|
||||
await assertPermission(actor, 'teacher:read');
|
||||
const teachers = await prisma.user.findMany({
|
||||
where: {
|
||||
role: { in: ['TEACHER', 'ADMIN'] },
|
||||
@@ -29,7 +35,8 @@ export async function listTeachers(keyword?: string): Promise<UserDto[]> {
|
||||
return teachers.map(toUserDto);
|
||||
}
|
||||
|
||||
export async function createTeacher(input: CreateTeacherInput): Promise<UserDto> {
|
||||
export async function createTeacher(actor: TokenPayload, input: CreateTeacherInput): Promise<UserDto> {
|
||||
assertAdmin(actor);
|
||||
const created = await prisma.user.create({
|
||||
data: {
|
||||
username: input.username,
|
||||
@@ -38,6 +45,12 @@ export async function createTeacher(input: CreateTeacherInput): Promise<UserDto>
|
||||
passwordHash: await hashPassword(input.password ?? env.defaultTeacherPassword),
|
||||
},
|
||||
});
|
||||
await prisma.permissionAudit.create({
|
||||
data: auditData(actor, 'teacher-create', 'user', created.id, null, {
|
||||
role: created.role,
|
||||
name: created.name,
|
||||
}),
|
||||
});
|
||||
return toUserDto(created);
|
||||
}
|
||||
|
||||
@@ -53,29 +66,50 @@ async function findTeacherOrThrow(teacherId: string): Promise<{ id: string; role
|
||||
return teacher;
|
||||
}
|
||||
|
||||
export async function updateTeacher(teacherId: string, input: UpdateTeacherInput): Promise<UserDto> {
|
||||
export async function updateTeacher(
|
||||
actor: TokenPayload,
|
||||
teacherId: string,
|
||||
input: UpdateTeacherInput,
|
||||
): Promise<UserDto> {
|
||||
assertAdmin(actor);
|
||||
const teacher = await findTeacherOrThrow(teacherId);
|
||||
const updated = await prisma.user.update({
|
||||
where: { id: teacher.id },
|
||||
data: {
|
||||
...(input.name ? { name: input.name } : {}),
|
||||
...(input.username ? { username: input.username } : {}),
|
||||
...(input.role ? { role: input.role } : {}),
|
||||
},
|
||||
const updated = await prisma.$transaction(async (tx) => {
|
||||
if (teacher.role === 'ADMIN' && input.role && input.role !== 'ADMIN') await protectLastAdmin(tx);
|
||||
const result = await tx.user.update({
|
||||
where: { id: teacher.id },
|
||||
data: {
|
||||
...(input.name ? { name: input.name } : {}),
|
||||
...(input.username ? { username: input.username } : {}),
|
||||
...(input.role ? { role: input.role } : {}),
|
||||
},
|
||||
});
|
||||
await tx.permissionAudit.create({
|
||||
data: auditData(actor, 'teacher-update', 'user', teacher.id, teacher, {
|
||||
role: result.role,
|
||||
name: result.name,
|
||||
}),
|
||||
});
|
||||
return result;
|
||||
});
|
||||
disconnectUserSessions([teacher.id]);
|
||||
return toUserDto(updated);
|
||||
}
|
||||
|
||||
/** 重置教师密码(未指定时用默认初始密码) */
|
||||
export async function resetTeacherPassword(
|
||||
actor: TokenPayload,
|
||||
teacherId: string,
|
||||
input: ResetTeacherPasswordInput,
|
||||
): Promise<void> {
|
||||
assertAdmin(actor);
|
||||
const teacher = await findTeacherOrThrow(teacherId);
|
||||
await prisma.user.update({
|
||||
where: { id: teacher.id },
|
||||
data: { passwordHash: await hashPassword(input.newPassword ?? env.defaultTeacherPassword) },
|
||||
});
|
||||
await prisma.permissionAudit.create({
|
||||
data: auditData(actor, 'teacher-password', 'user', teacher.id, null, { passwordChanged: true }),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -101,8 +135,11 @@ export async function resetTeacherPassword(
|
||||
* 因此只有"没有留下任何班级共享内容"的账号才允许删除(实践中就是刚录入错的那条),
|
||||
* 否则返回 409 让管理员先转移/清理。
|
||||
*/
|
||||
export async function deleteTeacher(userId: string): Promise<void> {
|
||||
export async function deleteTeacher(actor: TokenPayload, userId: string): Promise<void> {
|
||||
assertAdmin(actor);
|
||||
const teacher = await findTeacherOrThrow(userId);
|
||||
if (teacher.role === 'ADMIN' && (await prisma.user.count({ where: { role: 'ADMIN' } })) <= 1)
|
||||
throw ApiError.conflict('不能删除最后一个管理员');
|
||||
|
||||
const [ownedClasses, courses, homeworks, notifications, coursewares] = await Promise.all([
|
||||
prisma.class.count({ where: { teacherId: teacher.id } }),
|
||||
@@ -128,7 +165,21 @@ export async function deleteTeacher(userId: string): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
if (coursewares > 0) throw ApiError.conflict(`「${teacher.name}」还有 ${coursewares} 个课件,请先清理课件再删除账号`);
|
||||
if (coursewares > 0)
|
||||
throw ApiError.conflict(`「${teacher.name}」还有 ${coursewares} 个课件,请先清理课件再删除账号`);
|
||||
|
||||
await prisma.user.delete({ where: { id: teacher.id } });
|
||||
await prisma.$transaction(async (tx) => {
|
||||
if (teacher.role === 'ADMIN') await protectLastAdmin(tx);
|
||||
await tx.user.delete({ where: { id: teacher.id } });
|
||||
await tx.permissionAudit.create({
|
||||
data: auditData(actor, 'teacher-delete', 'user', teacher.id, teacher, null),
|
||||
});
|
||||
});
|
||||
disconnectUserSessions([teacher.id]);
|
||||
}
|
||||
|
||||
async function protectLastAdmin(tx: Prisma.TransactionClient) {
|
||||
await tx.user.updateMany({ where: { role: 'ADMIN' }, data: { role: 'ADMIN' } });
|
||||
if ((await tx.user.count({ where: { role: 'ADMIN' } })) <= 1)
|
||||
throw ApiError.conflict('不能删除或降级最后一个管理员');
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { refreshClassSubscriptions } from '../../realtime/bus.js';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
@@ -441,6 +442,7 @@ async function executePromotion(year: number, options: RunOptions) {
|
||||
for (const track of preview.tracks)
|
||||
for (const cls of track.classes)
|
||||
emitToClass(cls.id, SOCKET_EVENTS.classUpdated, { classId: cls.id, action: 'updated' });
|
||||
refreshClassSubscriptions();
|
||||
await notifyScheduleChanged();
|
||||
return { ...(await prisma.promotionRun.findUniqueOrThrow({ where: { year } })), alreadyDone: false };
|
||||
}
|
||||
|
||||
@@ -9,7 +9,13 @@ import {
|
||||
} from './promotion.service.js';
|
||||
import { Router } from 'express';
|
||||
import { ApiError, sendOk } from '../../lib/http.js';
|
||||
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
|
||||
import {
|
||||
authenticate,
|
||||
getAuthUser,
|
||||
requireAnyPermission,
|
||||
requirePermissionOrClassDevice,
|
||||
requirePermission,
|
||||
} from '../../middleware/auth.js';
|
||||
import { validate, validatedBody, validatedQuery } from '../../middleware/validate.js';
|
||||
import { defineModule } from '../module.types.js';
|
||||
import {
|
||||
@@ -74,16 +80,21 @@ router.post(
|
||||
* - **写**:仅管理员 —— 周次是全校口径,改错会让所有班的课表整体错位;
|
||||
* - `classId` 留空 = 全校默认;某个班有自己的配置时优先用它。
|
||||
*/
|
||||
router.get('/', requirePermission('term:read'), validate({ query: termQuerySchema }), async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const { classId } = validatedQuery<{ classId?: string }>(req);
|
||||
sendOk(res, await termService.getTermWeeks(user, classId), '获取学期周次成功');
|
||||
});
|
||||
router.get(
|
||||
'/',
|
||||
requirePermissionOrClassDevice('term:read'),
|
||||
validate({ query: termQuerySchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const { classId } = validatedQuery<{ classId?: string }>(req);
|
||||
sendOk(res, await termService.getTermWeeks(user, classId), '获取学期周次成功');
|
||||
},
|
||||
);
|
||||
|
||||
/** PUT /api/term - 保存逐周区间(整表覆盖,仅管理员) */
|
||||
router.put(
|
||||
'/',
|
||||
requirePermission('term:write'),
|
||||
requireAnyPermission('term:write', 'class:settings'),
|
||||
validate({ body: updateTermWeeksSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -102,7 +113,7 @@ router.put(
|
||||
*/
|
||||
router.post(
|
||||
'/auto',
|
||||
requirePermission('term:write'),
|
||||
requireAnyPermission('term:write', 'class:settings'),
|
||||
validate({ body: autoTermWeeksSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -121,7 +132,7 @@ router.post(
|
||||
*/
|
||||
router.get(
|
||||
'/holidays',
|
||||
requirePermission('term:write'),
|
||||
requireAnyPermission('term:write', 'class:settings'),
|
||||
validate({ query: holidayQuerySchema }),
|
||||
async (req, res) => {
|
||||
const { year } = validatedQuery<{ year?: number }>(req);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { assertAdmin, assertHeadOnly } from '../../lib/authz/index.js';
|
||||
import {
|
||||
buildAutoTermWeeks,
|
||||
validateTermWeeks,
|
||||
@@ -38,9 +39,15 @@ function scopeOf(classId?: string | null): string {
|
||||
|
||||
/** 读取某个范围的周次配置 */
|
||||
export async function getTermWeeks(user: TokenPayload, classId?: string | null): Promise<TermWeeksDto> {
|
||||
const scope = scopeOf(classId);
|
||||
if (scope) await assertClassAccess(user, scope);
|
||||
const scope = user.role === 'CLASS_DEVICE' ? (user.classId ?? user.sub) : scopeOf(classId);
|
||||
if (user.role === 'CLASS_DEVICE' && classId && classId !== scope)
|
||||
throw ApiError.forbidden('不能读取其他班级周次');
|
||||
if (!scope) await assertPermission(user, 'term:read');
|
||||
if (scope) await assertClassAccess(user, scope, 'term:read');
|
||||
return readTermWeeks(scope);
|
||||
}
|
||||
|
||||
async function readTermWeeks(scope: string): Promise<TermWeeksDto> {
|
||||
const [weeks] = await Promise.all([
|
||||
prisma.termWeek.findMany({ where: { classId: scope }, orderBy: { weekNumber: 'asc' } }),
|
||||
scope
|
||||
@@ -75,8 +82,9 @@ export async function updateTermWeeks(
|
||||
user: TokenPayload,
|
||||
input: UpdateTermWeeksInput,
|
||||
): Promise<TermWeeksDto> {
|
||||
await assertPermission(user, 'term:write');
|
||||
const scope = scopeOf(input.classId);
|
||||
if (scope) await assertHeadOnly(user, 'class:settings', scope);
|
||||
else assertAdmin(user);
|
||||
|
||||
const maximum = input.maxTermWeeks ?? (await getMaxTermWeeks());
|
||||
if (input.maxTermWeeks !== undefined && scope) throw ApiError.badRequest('总周数只能在全校默认范围修改');
|
||||
@@ -132,7 +140,7 @@ export async function updateTermWeeks(
|
||||
logger.info(`学期周次已更新:范围=${scope || '全校默认'} 周数=${input.weeks.length} 由 ${user.name} 执行`);
|
||||
if (input.maxTermWeeks !== undefined) await setSetting(SETTING_KEYS.termMaxWeeks, String(maximum));
|
||||
await publishTermChange(scope);
|
||||
return getTermWeeks(user, scope || null);
|
||||
return readTermWeeks(scope);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -142,8 +150,9 @@ export async function updateTermWeeks(
|
||||
* 比从空白开始一周周填要快得多。
|
||||
*/
|
||||
export async function autoTermWeeks(user: TokenPayload, input: AutoTermWeeksInput): Promise<TermWeeksDto> {
|
||||
await assertPermission(user, 'term:write');
|
||||
const scope = scopeOf(input.classId);
|
||||
if (scope) await assertHeadOnly(user, 'class:settings', scope);
|
||||
else assertAdmin(user);
|
||||
const context = await loadTermContext(scope || null);
|
||||
const termStartDate = input.termStartDate || context.termStartDate || env.termStartDate;
|
||||
const maxWeek = input.maxWeek ?? context.maxWeek;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { TokenPayload } from '../../lib/jwt.js';
|
||||
import { assertAdmin } from '../../lib/authz/index.js';
|
||||
import { getReleaseProgress, setReleaseProgress } from './release-progress.js';
|
||||
import { Transform } from 'node:stream';
|
||||
import crypto from 'node:crypto';
|
||||
@@ -54,10 +56,14 @@ function releaseRoot(): string {
|
||||
}
|
||||
|
||||
function releaseDir(version: string): string {
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(version) || version.includes('..'))
|
||||
throw ApiError.badRequest('版本号不是安全路径段');
|
||||
return path.join(releaseRoot(), version);
|
||||
}
|
||||
|
||||
function releaseFilePath(version: string, fileName: string): string {
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(fileName) || fileName.includes('..'))
|
||||
throw ApiError.badRequest('文件名不是安全路径段');
|
||||
return path.join(releaseDir(version), fileName);
|
||||
}
|
||||
|
||||
@@ -128,9 +134,11 @@ export async function resolveReleaseFile(
|
||||
|
||||
/** 登记一份已经在磁盘上的安装包:算 sha256 / 大小后落库(可重复登记,会覆盖) */
|
||||
export async function registerRelease(
|
||||
userId: string,
|
||||
actor: TokenPayload,
|
||||
input: RegisterClientReleaseInput,
|
||||
): Promise<ClientReleaseDto> {
|
||||
assertAdmin(actor);
|
||||
const userId = actor.sub;
|
||||
const version = input.version;
|
||||
// 文件名默认走发布命名约定(与 GitHub Release、官网兜底直链一致)
|
||||
const known = (await prisma.clientRelease.findUnique({ where: { version } })) as ReleaseRow | null;
|
||||
@@ -171,11 +179,12 @@ export async function registerRelease(
|
||||
|
||||
/** 让服务端从 GitHub 拉一份安装包并登记(教室无外网时由服务端代下) */
|
||||
export async function fetchReleaseFromGithub(
|
||||
userId: string,
|
||||
actor: TokenPayload,
|
||||
input: FetchClientReleaseInput,
|
||||
): Promise<ClientReleaseDto> {
|
||||
if (['downloading', 'verifying'].includes(getReleaseProgress().stage))
|
||||
throw ApiError.conflict('已有安装包正在下载');
|
||||
assertAdmin(actor);
|
||||
const version = input.version;
|
||||
setReleaseProgress({ version, stage: 'downloading', downloadedBytes: 0, totalBytes: 0, error: '' });
|
||||
const fileName = clientReleaseFileName(version);
|
||||
@@ -221,13 +230,14 @@ export async function fetchReleaseFromGithub(
|
||||
|
||||
// 落盘后统一走登记:sha256 / 大小 / 落库都在同一处算
|
||||
setReleaseProgress({ stage: 'verifying' });
|
||||
const release = await registerRelease(userId, { version, fileName });
|
||||
const release = await registerRelease(actor, { version, fileName });
|
||||
setReleaseProgress({ stage: 'ready', downloadedBytes: release.size, totalBytes: release.size });
|
||||
return release;
|
||||
}
|
||||
|
||||
/** 删除一份缓存的安装包(元数据 + 磁盘文件) */
|
||||
export async function deleteRelease(version: string): Promise<{ version: string }> {
|
||||
export async function deleteRelease(actor: TokenPayload, version: string): Promise<{ version: string }> {
|
||||
assertAdmin(actor);
|
||||
const row = (await prisma.clientRelease.findUnique({ where: { version } })) as ReleaseRow | null;
|
||||
if (!row) throw ApiError.notFound('服务端没有这个版本的安装包');
|
||||
await prisma.clientRelease.delete({ where: { version } });
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { assertClassDevice, assertPermission, classIdsFor } from '../../lib/authz/index.js';
|
||||
import { getReleaseProgress, setReleaseProgress } from './release-progress.js';
|
||||
import { Router } from 'express';
|
||||
import { sendCreated, sendOk, ApiError } from '../../lib/http.js';
|
||||
@@ -7,9 +8,10 @@ import {
|
||||
hasAuthPermission,
|
||||
requireAnyPermission,
|
||||
requirePermission,
|
||||
requireStaffPermission,
|
||||
requireAdmin,
|
||||
requirePermissionOrClassDevice,
|
||||
} from '../../middleware/auth.js';
|
||||
import { validate, validatedBody, validatedParams } from '../../middleware/validate.js';
|
||||
import { validateBadRequest as validate, validatedBody, validatedParams } from '../../middleware/validate.js';
|
||||
import { defineModule } from '../module.types.js';
|
||||
import * as clientReleases from './client-releases.service.js';
|
||||
import * as updateService from './update.service.js';
|
||||
@@ -39,7 +41,7 @@ router.get('/client/progress', requirePermission('client:update'), async (_req,
|
||||
* 权限:任何已登录用户都能查(信息本身来自公开仓库)。`force=1` 绕过服务端缓存,
|
||||
* 但**只有管理员生效** —— 否则任意登录用户都能靠刷这个参数把匿名限流打满。
|
||||
*/
|
||||
router.get('/check', requireStaffPermission('update:check'), async (req, res) => {
|
||||
router.get('/check', requirePermissionOrClassDevice('update:check'), async (req, res) => {
|
||||
const force = req.query.force === '1' && hasAuthPermission(req, 'update:force');
|
||||
sendOk(res, await updateService.checkForUpdates({ force }), '更新检查完成');
|
||||
});
|
||||
@@ -68,13 +70,14 @@ router.get(
|
||||
*/
|
||||
router.post(
|
||||
'/client/fetch',
|
||||
requireAdmin(),
|
||||
requirePermission('client:update'),
|
||||
validate({ body: fetchClientReleaseSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const input = validatedBody<FetchClientReleaseInput>(req);
|
||||
try {
|
||||
sendCreated(res, await clientReleases.fetchReleaseFromGithub(user.sub, input), '安装包已下载并登记');
|
||||
sendCreated(res, await clientReleases.fetchReleaseFromGithub(user, input), '安装包已下载并登记');
|
||||
} catch (error) {
|
||||
if (
|
||||
!(error instanceof ApiError && error.status === 409) &&
|
||||
@@ -97,23 +100,25 @@ router.post(
|
||||
*/
|
||||
router.post(
|
||||
'/client/releases',
|
||||
requireAdmin(),
|
||||
requirePermission('client:update'),
|
||||
validate({ body: registerClientReleaseSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const input = validatedBody<RegisterClientReleaseInput>(req);
|
||||
sendCreated(res, await clientReleases.registerRelease(user.sub, input), '安装包已登记');
|
||||
sendCreated(res, await clientReleases.registerRelease(user, input), '安装包已登记');
|
||||
},
|
||||
);
|
||||
|
||||
/** DELETE /api/update/client/releases/:version - 删除缓存(元数据 + 磁盘文件) */
|
||||
router.delete(
|
||||
'/client/releases/:version',
|
||||
requireAdmin(),
|
||||
requirePermission('client:update'),
|
||||
validate({ params: clientReleaseVersionParamSchema }),
|
||||
async (req, res) => {
|
||||
const { version } = validatedParams<{ version: string }>(req);
|
||||
sendOk(res, await clientReleases.deleteRelease(version), '安装包已删除');
|
||||
sendOk(res, await clientReleases.deleteRelease(getAuthUser(req), version), '安装包已删除');
|
||||
},
|
||||
);
|
||||
|
||||
@@ -128,6 +133,13 @@ router.get(
|
||||
'/client/download/:version',
|
||||
validate({ params: clientReleaseVersionParamSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
if (user.role === 'CLASS_DEVICE') assertClassDevice(user, user.classId ?? user.sub);
|
||||
else {
|
||||
await assertPermission(user, 'client:read');
|
||||
if (user.role !== 'ADMIN' && !(await classIdsFor(user, 'client:read')).length)
|
||||
throw ApiError.forbidden('无可访问班级');
|
||||
}
|
||||
const { version } = validatedParams<{ version: string }>(req);
|
||||
const found = await clientReleases.resolveReleaseFile(version);
|
||||
if (!found) throw ApiError.notFound('服务端没有缓存这个版本的安装包');
|
||||
|
||||
@@ -13,7 +13,7 @@ export const clientReleaseVersionSchema = z
|
||||
.trim()
|
||||
.min(1, '请输入版本号')
|
||||
.max(40, '版本号最多 40 个字符')
|
||||
.refine((value) => SAFE_SEGMENT.test(value), {
|
||||
.refine((value) => SAFE_SEGMENT.test(value) && value !== '.' && value !== '..' && !value.includes('..'), {
|
||||
message: '版本号只能包含字母、数字、点、下划线与短横线',
|
||||
});
|
||||
|
||||
@@ -30,7 +30,9 @@ export const registerClientReleaseSchema = z.object({
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(120)
|
||||
.refine((value) => SAFE_SEGMENT.test(value), { message: '文件名只能包含字母、数字、点、下划线与短横线' })
|
||||
.refine((value) => SAFE_SEGMENT.test(value) && value !== '.' && value !== '..' && !value.includes('..'), {
|
||||
message: '文件名只能包含字母、数字、点、下划线与短横线',
|
||||
})
|
||||
.optional(),
|
||||
notes: z.string().trim().max(500).optional(),
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SOCKET_ROOMS } from '@classhelper/shared';
|
||||
import { SOCKET_ROOMS, SOCKET_EVENT_CHANNELS } from '@classhelper/shared';
|
||||
|
||||
/**
|
||||
* 实时事件总线。
|
||||
@@ -21,7 +21,19 @@ export function emitToRooms(rooms: string[], event: string, payload: unknown): v
|
||||
|
||||
/** 按班级房间广播(class:{classId}) */
|
||||
export function emitToClass(classId: string, event: string, payload: unknown): void {
|
||||
emitToRooms([SOCKET_ROOMS.class(classId)], event, payload);
|
||||
emitToRooms([SOCKET_ROOMS.classChannel(classId, SOCKET_EVENT_CHANNELS[event] ?? 'core')], event, payload);
|
||||
}
|
||||
|
||||
let disconnectUsers: (ids: string[] | null) => void = () => {};
|
||||
export function registerSessionInvalidator(invalidate: (ids: string[] | null) => void) {
|
||||
disconnectUsers = invalidate;
|
||||
}
|
||||
export function disconnectUserSessions(ids: string[]) {
|
||||
disconnectUsers(ids);
|
||||
}
|
||||
/** 班级集合或任课关系变化后,重连重新解析按键范围。 */
|
||||
export function refreshClassSubscriptions() {
|
||||
disconnectUsers(null);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,36 +3,42 @@ import { Server } from 'socket.io';
|
||||
import {
|
||||
SOCKET_EVENTS,
|
||||
SOCKET_ROOMS,
|
||||
SOCKET_CHANNELS,
|
||||
type SocketChannel,
|
||||
type PermissionKey,
|
||||
type ClientToServerEvents,
|
||||
type ServerToClientEvents,
|
||||
type SocketAuthPayload,
|
||||
type UserRole,
|
||||
} from '@classhelper/shared';
|
||||
import { env } from '../config/env.js';
|
||||
import { getAccessibleClassIds } from '../lib/access.js';
|
||||
import { verifyToken } from '../lib/jwt.js';
|
||||
import { verifyToken, type TokenPayload } from '../lib/jwt.js';
|
||||
import { loadSessionUser } from '../lib/authz/session.js';
|
||||
import { classIdsFor, loadAuthPrincipal, classDeviceClassId } from '../lib/authz/index.js';
|
||||
import { logger } from '../lib/logger.js';
|
||||
import { registerEmitter, type RealtimeEmitter } from './bus.js';
|
||||
import { registerEmitter, registerSessionInvalidator } from './bus.js';
|
||||
|
||||
interface SocketData {
|
||||
user: {
|
||||
id: string;
|
||||
username: string;
|
||||
name: string;
|
||||
role: UserRole;
|
||||
classId: string | null;
|
||||
classSession: boolean;
|
||||
};
|
||||
user: TokenPayload;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export type RealtimeServer = Server<
|
||||
ClientToServerEvents,
|
||||
ServerToClientEvents,
|
||||
Record<string, never>,
|
||||
SocketData
|
||||
>;
|
||||
|
||||
/** 绑定 Socket.IO 到 HTTP 服务,并注册到事件总线 */
|
||||
const channelKeys: Record<SocketChannel, PermissionKey[]> = {
|
||||
core: ['class:read', 'schedule:read'],
|
||||
homework: ['homework:read'],
|
||||
notification: ['notification:read'],
|
||||
grade: ['grade:read'],
|
||||
courseware: ['courseware:read'],
|
||||
};
|
||||
async function channelClasses(user: TokenPayload, channel: SocketChannel): Promise<Set<string>> {
|
||||
const id = classDeviceClassId(user);
|
||||
if (id) return new Set([id]);
|
||||
return new Set((await Promise.all(channelKeys[channel].map((key) => classIdsFor(user, key)))).flat());
|
||||
}
|
||||
export function initRealtime(httpServer: HttpServer): RealtimeServer {
|
||||
const io: RealtimeServer = new Server(httpServer, {
|
||||
cors: { origin: env.corsOrigins, credentials: true },
|
||||
@@ -40,110 +46,92 @@ export function initRealtime(httpServer: HttpServer): RealtimeServer {
|
||||
pingInterval: 25000,
|
||||
pingTimeout: 20000,
|
||||
});
|
||||
|
||||
// 握手鉴权:客户端通过 handshake.auth.token 传入 JWT
|
||||
io.use((socket, next) => {
|
||||
try {
|
||||
const auth = (socket.handshake.auth ?? {}) as Partial<SocketAuthPayload>;
|
||||
const queryToken =
|
||||
typeof socket.handshake.query.token === 'string' ? socket.handshake.query.token : null;
|
||||
const token = auth.token ?? queryToken;
|
||||
if (!token) {
|
||||
next(new Error('缺少认证 token'));
|
||||
return;
|
||||
void (async () => {
|
||||
try {
|
||||
const auth = socket.handshake.auth as Partial<SocketAuthPayload>;
|
||||
const token =
|
||||
auth.token ??
|
||||
(typeof socket.handshake.query.token === 'string' ? socket.handshake.query.token : '');
|
||||
const user = await loadSessionUser(verifyToken(token));
|
||||
await loadAuthPrincipal(user);
|
||||
socket.data = { user, token };
|
||||
next();
|
||||
} catch {
|
||||
next(new Error('认证失败,请重新登录'));
|
||||
}
|
||||
const payload = verifyToken(token);
|
||||
// ClassHelper 班级端:socket.data.user.id 用**班级 id**,据此加入 session:{classId} 房间,
|
||||
// 「叫人」等定向消息因此能直达教室机器(见 calls.service)。
|
||||
socket.data.user = {
|
||||
id: payload.classSession ? (payload.classId ?? payload.sub) : payload.sub,
|
||||
username: payload.classSession ? (payload.classCode ?? payload.username) : payload.username,
|
||||
name: payload.name,
|
||||
role: payload.role,
|
||||
classId: payload.classId,
|
||||
classSession: payload.classSession === true,
|
||||
};
|
||||
next();
|
||||
} catch {
|
||||
next(new Error('认证失败,请重新登录'));
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
const user = socket.data.user;
|
||||
void (async () => {
|
||||
const rooms = [SOCKET_ROOMS.session(user.id), SOCKET_ROOMS.role(user.role)];
|
||||
|
||||
try {
|
||||
const classIds = await getAccessibleClassIds({
|
||||
sub: user.id,
|
||||
username: user.username,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
classId: user.classId,
|
||||
classSession: user.classSession,
|
||||
});
|
||||
for (const classId of classIds) {
|
||||
await socket.join(SOCKET_ROOMS.class(classId));
|
||||
rooms.push(SOCKET_ROOMS.class(classId));
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`加入班级房间失败(user=${user.id})`, error);
|
||||
}
|
||||
|
||||
// ClassHelper 班级端归入 students 房间(它代表全班),教师/管理员归入 teachers
|
||||
const audienceRoom = user.role === 'CLASS_DEVICE' ? SOCKET_ROOMS.students : SOCKET_ROOMS.teachers;
|
||||
await socket.join(audienceRoom);
|
||||
rooms.push(audienceRoom);
|
||||
|
||||
logger.info(`实时连接建立:${user.name}(${user.role}) sid=${socket.id}`);
|
||||
socket.emit(SOCKET_EVENTS.connected, { sessionId: user.id, role: user.role, rooms });
|
||||
})();
|
||||
|
||||
// 客户端可显式订阅某个班级(服务端二次校验权限)
|
||||
socket.on('class:join', (classId, ack) => {
|
||||
// 手动订阅每次回查账号与权限;不沿用握手时的权限快照。
|
||||
const ready = (async () => {
|
||||
const base = [
|
||||
SOCKET_ROOMS.session(user.sub),
|
||||
SOCKET_ROOMS.role(user.role),
|
||||
user.role === 'CLASS_DEVICE' ? SOCKET_ROOMS.students : SOCKET_ROOMS.teachers,
|
||||
];
|
||||
await socket.join(base);
|
||||
for (const channel of SOCKET_CHANNELS)
|
||||
for (const id of await channelClasses(user, channel))
|
||||
await socket.join(SOCKET_ROOMS.classChannel(id, channel));
|
||||
socket.emit(SOCKET_EVENTS.connected, {
|
||||
sessionId: user.sub,
|
||||
role: user.role,
|
||||
rooms: [...socket.rooms].filter((r) => r !== socket.id),
|
||||
});
|
||||
})().catch((error) => {
|
||||
logger.warn('实时订阅失败', error);
|
||||
socket.disconnect(true);
|
||||
});
|
||||
socket.on('class:join', (request, ack) => {
|
||||
void (async () => {
|
||||
await ready;
|
||||
try {
|
||||
const allowed = await getAccessibleClassIds({
|
||||
sub: user.id,
|
||||
username: user.username,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
classId: user.classId,
|
||||
classSession: user.classSession,
|
||||
});
|
||||
if (!allowed.includes(classId)) {
|
||||
ack?.({ ok: false, message: '无权订阅该班级' });
|
||||
const current = await loadSessionUser(verifyToken(socket.data.token));
|
||||
const classId = typeof request === 'string' ? request : request.classId;
|
||||
const requested =
|
||||
typeof request === 'string' ? SOCKET_CHANNELS : (request.channels ?? SOCKET_CHANNELS);
|
||||
if (!requested.length || requested.some((c) => !SOCKET_CHANNELS.includes(c))) {
|
||||
ack?.({ ok: false, message: '未知事件通道' });
|
||||
return;
|
||||
}
|
||||
await socket.join(SOCKET_ROOMS.class(classId));
|
||||
const allowed: SocketChannel[] = [];
|
||||
for (const channel of requested)
|
||||
if ((await channelClasses(current, channel)).has(classId)) allowed.push(channel);
|
||||
// 旧字符串协议只加入有权通道;新协议显式通道有任一无权则全部拒绝。
|
||||
if (!allowed.length || (typeof request !== 'string' && allowed.length !== requested.length)) {
|
||||
ack?.({ ok: false, message: '无权订阅该班级或通道' });
|
||||
return;
|
||||
}
|
||||
for (const channel of allowed) await socket.join(SOCKET_ROOMS.classChannel(classId, channel));
|
||||
ack?.({ ok: true });
|
||||
} catch {
|
||||
ack?.({ ok: false, message: '订阅失败' });
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
socket.on('class:leave', (classId) => {
|
||||
void socket.leave(SOCKET_ROOMS.class(classId));
|
||||
});
|
||||
|
||||
socket.on('ping', (ack) => {
|
||||
ack?.();
|
||||
});
|
||||
|
||||
socket.on('disconnect', (reason) => {
|
||||
logger.debug(`实时连接断开:${user.name} (${reason})`);
|
||||
socket.on('class:leave', (request) => {
|
||||
void (async () => {
|
||||
await ready;
|
||||
const classId = typeof request === 'string' ? request : request.classId;
|
||||
for (const channel of typeof request === 'string'
|
||||
? SOCKET_CHANNELS
|
||||
: (request.channels ?? SOCKET_CHANNELS))
|
||||
await socket.leave(SOCKET_ROOMS.classChannel(classId, channel));
|
||||
})();
|
||||
});
|
||||
socket.on('ping', (ack) => ack?.());
|
||||
});
|
||||
registerEmitter((rooms, event, payload) => {
|
||||
(io.to(rooms) as unknown as { emit(event: string, payload: unknown): void }).emit(event, payload);
|
||||
});
|
||||
registerSessionInvalidator((ids) => {
|
||||
if (ids === null) {
|
||||
io.in(SOCKET_ROOMS.teachers).disconnectSockets(true);
|
||||
return;
|
||||
}
|
||||
for (const id of ids) io.in(SOCKET_ROOMS.session(id)).disconnectSockets(true);
|
||||
});
|
||||
|
||||
const emit: RealtimeEmitter = (rooms, event, payload) => {
|
||||
const target = io.to(rooms) as unknown as { emit: (event: string, payload: unknown) => void };
|
||||
target.emit(event, payload);
|
||||
logger.debug(`实时推送 ${event} -> ${rooms.join(',')}`);
|
||||
};
|
||||
registerEmitter(emit);
|
||||
|
||||
logger.info('Socket.IO 实时通道已就绪(房间规则:class:{classId} / session:{id})');
|
||||
return io;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'] },
|
||||
{
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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[];
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
一键推送更新
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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[]>([]);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user