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