feat(courseware,docs): 完善课件传输与岛编辑并统一全仓文档
- 增加课件上传、班级可见范围、设备补收与传输进度 - 整合自定义灵动岛编辑器和相关客户端交互 - 同步三端契约、数据库迁移、部署配置与隔离回归 - 按源码统一文档与官网说明,移除过时任务书和审查记录 - 保持产品版本为 1.4.0
This commit is contained in:
1 parent
536379036a
commit
c7f215a09c
127 files changed
+4334
-8482
No files matched your search
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@classhelper/server",
|
||||
"version": "1.3.3",
|
||||
"version": "1.4.0",
|
||||
"private": true,
|
||||
"description": "ClassHelper后端服务:REST API + Socket.IO 实时推送 + Prisma 数据访问",
|
||||
"type": "module",
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
CREATE TABLE "Courseware" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY, "ownerId" TEXT NOT NULL, "name" TEXT NOT NULL,
|
||||
"size" INTEGER NOT NULL, "category" TEXT NOT NULL, "extension" TEXT NOT NULL,
|
||||
"archiveDate" TEXT NOT NULL, "storagePath" TEXT NOT NULL, "sha256" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "Courseware_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
CREATE UNIQUE INDEX "Courseware_storagePath_key" ON "Courseware"("storagePath");
|
||||
CREATE INDEX "Courseware_ownerId_createdAt_idx" ON "Courseware"("ownerId", "createdAt");
|
||||
CREATE TABLE "CoursewareVisibility" (
|
||||
"coursewareId" TEXT NOT NULL, "classId" TEXT NOT NULL, PRIMARY KEY ("coursewareId", "classId"),
|
||||
FOREIGN KEY ("coursewareId") REFERENCES "Courseware"("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
FOREIGN KEY ("classId") REFERENCES "Class"("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
CREATE INDEX "CoursewareVisibility_classId_idx" ON "CoursewareVisibility"("classId");
|
||||
CREATE TABLE "CoursewarePush" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY, "coursewareId" TEXT NOT NULL, "classId" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY ("coursewareId") REFERENCES "Courseware"("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
FOREIGN KEY ("classId") REFERENCES "Class"("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
CREATE INDEX "CoursewarePush_classId_createdAt_idx" ON "CoursewarePush"("classId", "createdAt");
|
||||
CREATE TABLE "CoursewareReceipt" (
|
||||
"pushId" TEXT NOT NULL, "deviceId" TEXT NOT NULL, "completedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY ("pushId", "deviceId"),
|
||||
FOREIGN KEY ("pushId") REFERENCES "CoursewarePush"("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
FOREIGN KEY ("deviceId") REFERENCES "ClientDevice"("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
-- 仅为旧默认组和内置教学岗位补充新能力;自定义组仍需管理员按需授权。
|
||||
INSERT INTO "PermissionGroupItem" ("id", "groupId", "key")
|
||||
SELECT 'cw-read-' || "id", "id", 'courseware:read' FROM "PermissionGroup"
|
||||
WHERE "defaultRole" IN ('ADMIN', 'TEACHER') OR "id" IN ('builtin-head', 'builtin-teacher');
|
||||
INSERT INTO "PermissionGroupItem" ("id", "groupId", "key")
|
||||
SELECT 'cw-write-' || "id", "id", 'courseware:write' FROM "PermissionGroup"
|
||||
WHERE "defaultRole" IN ('ADMIN', 'TEACHER') OR "id" IN ('builtin-head', 'builtin-teacher');
|
||||
@@ -34,6 +34,7 @@ datasource db {
|
||||
|
||||
/// 账号:管理员 / 教师 / ClassHelper 班级端。**学生不在这里**(见 Student)。
|
||||
model User {
|
||||
coursewares Courseware[]
|
||||
id String @id @default(cuid())
|
||||
/// 登录名:教师即**工号**,班级端即班级码
|
||||
username String @unique
|
||||
@@ -201,6 +202,8 @@ model TermWeek {
|
||||
/// `grade`(高一/高二/高三)表示**当前年级**,学年升级时批量改它即可 ——
|
||||
/// 升级只动这一个字段,班级记录、学生名单、作业与成绩全部沿用,**不归档**。
|
||||
model Class {
|
||||
coursewares CoursewareVisibility[]
|
||||
coursewarePushes CoursewarePush[]
|
||||
id String @id @default(cuid())
|
||||
/// 班级称呼:由 enrollmentYear + classIndex 生成,如「2026级1班」
|
||||
name String
|
||||
@@ -501,6 +504,7 @@ model AppSetting {
|
||||
/// 服务端由此知道**每台机器在哪个班、版本多少、是否在线**,并把更新与远控
|
||||
/// 指令按「查库 + 心跳拉取」的范式下发,不新增长连接握手。
|
||||
model ClientDevice {
|
||||
coursewareReceipts CoursewareReceipt[]
|
||||
id String @id @default(cuid())
|
||||
/// 该设备当前登录的班级(未登录时为空)
|
||||
classId String?
|
||||
@@ -717,3 +721,50 @@ model PromotionRun {
|
||||
createdAt DateTime @default(now())
|
||||
finishedAt DateTime?
|
||||
}
|
||||
/// 课件只在完整接收并计算摘要后入库;存储路径不对客户端暴露。
|
||||
model Courseware {
|
||||
id String @id @default(cuid())
|
||||
ownerId String
|
||||
name String
|
||||
size Int
|
||||
category String
|
||||
extension String
|
||||
archiveDate String
|
||||
storagePath String @unique
|
||||
sha256 String
|
||||
createdAt DateTime @default(now())
|
||||
owner User @relation(fields: [ownerId], references: [id], onDelete: Restrict)
|
||||
visibility CoursewareVisibility[]
|
||||
pushes CoursewarePush[]
|
||||
@@index([ownerId, createdAt])
|
||||
}
|
||||
|
||||
model CoursewareVisibility {
|
||||
coursewareId String
|
||||
classId String
|
||||
courseware Courseware @relation(fields: [coursewareId], references: [id], onDelete: Cascade)
|
||||
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
|
||||
@@id([coursewareId, classId])
|
||||
@@index([classId])
|
||||
}
|
||||
|
||||
/// 下发持久化,班级离线时不丢失;每台设备完成下载后单独回执。
|
||||
model CoursewarePush {
|
||||
id String @id @default(cuid())
|
||||
coursewareId String
|
||||
classId String
|
||||
createdAt DateTime @default(now())
|
||||
courseware Courseware @relation(fields: [coursewareId], references: [id], onDelete: Cascade)
|
||||
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
|
||||
receipts CoursewareReceipt[]
|
||||
@@index([classId, createdAt])
|
||||
}
|
||||
|
||||
model CoursewareReceipt {
|
||||
pushId String
|
||||
deviceId String
|
||||
completedAt DateTime @default(now())
|
||||
push CoursewarePush @relation(fields: [pushId], references: [id], onDelete: Cascade)
|
||||
device ClientDevice @relation(fields: [deviceId], references: [id], onDelete: Cascade)
|
||||
@@id([pushId, deviceId])
|
||||
}
|
||||
@@ -78,6 +78,7 @@ async function resetDatabase(): Promise<void> {
|
||||
await prisma.clientCommand.deleteMany();
|
||||
await prisma.clientDevice.deleteMany();
|
||||
await prisma.clientRelease.deleteMany();
|
||||
await prisma.courseware.deleteMany();
|
||||
await prisma.aiAudit.deleteMany();
|
||||
await prisma.userPermissionGroup.deleteMany();
|
||||
await prisma.permissionGroupItem.deleteMany();
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import http from 'node:http';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { once } from 'node:events';
|
||||
import { createApp } from '../src/app.js';
|
||||
import { prisma } from '../src/lib/db.js';
|
||||
import { signToken } from '../src/lib/jwt.js';
|
||||
import { exportSnapshot, assertValidSnapshot, restoreSnapshot } from '../src/lib/snapshot.js';
|
||||
import { ensureDefaultGroups } from '../src/lib/permissions.js';
|
||||
import { registerEmitter } from '../src/realtime/bus.js';
|
||||
import {
|
||||
COURSEWARE_MAX_BYTES,
|
||||
SOCKET_EVENTS,
|
||||
coursewareCategory,
|
||||
dayKeyLocal,
|
||||
type CoursewareDto,
|
||||
} from '@classhelper/shared';
|
||||
if (
|
||||
!process.env.DATABASE_URL?.includes('v14-verification') ||
|
||||
!process.env.COURSEWARE_DIR?.includes('v14-verification')
|
||||
)
|
||||
throw new Error('课件验收只能使用临时库和文件目录');
|
||||
let count = 0;
|
||||
function check(name: string, value: unknown) {
|
||||
assert.ok(value, name);
|
||||
console.log(`通过 ${++count}:${name}`);
|
||||
}
|
||||
const emitted: { rooms: string[]; event: string; payload: unknown }[] = [];
|
||||
registerEmitter((rooms, event, payload) => emitted.push({ rooms, event, payload }));
|
||||
const staff = await Promise.all(
|
||||
['课件教师甲', '课件教师乙', '仅班主任', '临时管理员'].map((name, i) =>
|
||||
prisma.user.create({
|
||||
data: {
|
||||
name,
|
||||
username: `cw-${Date.now()}-${i}`,
|
||||
passwordHash: '仅签发临时令牌',
|
||||
role: i === 3 ? 'ADMIN' : 'TEACHER',
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
const tokens = staff.map((user) =>
|
||||
signToken({
|
||||
sub: user.id,
|
||||
username: user.username,
|
||||
name: user.name,
|
||||
role: user.role as 'ADMIN' | 'TEACHER',
|
||||
classId: null,
|
||||
}),
|
||||
);
|
||||
const classes = await Promise.all(
|
||||
[0, 1, 2].map((i) =>
|
||||
prisma.class.create({
|
||||
data: {
|
||||
name: `课件验证班${i + 1}`,
|
||||
grade: '验证',
|
||||
code: `CW${Date.now()}${i}`,
|
||||
teacherId: staff[2]!.id,
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
await prisma.course.createMany({
|
||||
data: classes.map((c, i) => ({ name: '语文', classId: c.id, teacherId: staff[i === 2 ? 1 : 0]!.id })),
|
||||
});
|
||||
const classTokens = classes.map((c) =>
|
||||
signToken({
|
||||
sub: c.id,
|
||||
classId: c.id,
|
||||
username: c.code,
|
||||
name: c.name,
|
||||
role: 'CLASS_DEVICE',
|
||||
classSession: true,
|
||||
}),
|
||||
);
|
||||
await ensureDefaultGroups();
|
||||
let server = http.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 AddressInfo).port}/api`;
|
||||
async function request(route: string, token = tokens[0], body?: unknown, method = body ? 'POST' : 'GET') {
|
||||
const res = await fetch(`${base()}${route}`, {
|
||||
method,
|
||||
headers: { ...(token ? { authorization: `Bearer ${token}` } : {}), 'content-type': 'application/json' },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
return { status: res.status, ...(await res.json()) };
|
||||
}
|
||||
const bytes = Buffer.from('ClassHelper 课件流式上传验收\n'.repeat(3000));
|
||||
async function upload(
|
||||
options: Partial<{ name: string; size: number; classIds: string[]; mode: string }> = {},
|
||||
token = tokens[0],
|
||||
data = bytes,
|
||||
) {
|
||||
const meta = {
|
||||
name: '第1课 演示.pptx',
|
||||
size: data.length,
|
||||
classIds: [classes[0]!.id],
|
||||
mode: 'cloud',
|
||||
...options,
|
||||
};
|
||||
const res = await fetch(
|
||||
`${base()}/courseware/upload?metadata=${encodeURIComponent(JSON.stringify(meta))}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/octet-stream' },
|
||||
body: data,
|
||||
signal: AbortSignal.timeout(10000),
|
||||
},
|
||||
);
|
||||
return { status: res.status, ...(await res.json()) };
|
||||
}
|
||||
const list = async (token: string) => (await request('/courseware', token)).data as CoursewareDto[];
|
||||
const heartbeat = async (i: number, key: string) =>
|
||||
request('/clients/heartbeat', classTokens[i], {
|
||||
deviceKey: key,
|
||||
clientVersion: '1.4.0',
|
||||
platform: 'verification',
|
||||
});
|
||||
const pending = async (i: number, key: string) =>
|
||||
request(`/courseware/transfers?deviceKey=${key}`, classTokens[i]);
|
||||
async function files(dir = process.env.COURSEWARE_DIR!): Promise<string[]> {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
|
||||
return (
|
||||
await Promise.all(
|
||||
entries.map((entry) =>
|
||||
entry.isDirectory() ? files(path.join(dir, entry.name)) : [path.join(dir, entry.name)],
|
||||
),
|
||||
)
|
||||
).flat();
|
||||
}
|
||||
try {
|
||||
check('未登录不能列出课件', (await request('/courseware', '')).status === 401);
|
||||
const ownClasses = (await request('/courseware/classes')).data;
|
||||
check(
|
||||
'只能选择自己的授课班级',
|
||||
ownClasses.length === 2 && !ownClasses.some((c: { id: string }) => c.id === classes[2]!.id),
|
||||
);
|
||||
check(
|
||||
'仅班主任而无任课关系不能选择班级',
|
||||
(await request('/courseware/classes', tokens[2])).data.length === 0,
|
||||
);
|
||||
check('管理员上传同样只能选择本人任课班级', (await upload({}, tokens[3])).status === 403);
|
||||
check('班级端不能上传', (await upload({}, classTokens[0])).status === 403);
|
||||
check('拒绝非任课班级上传', (await upload({ classIds: [classes[2]!.id] })).status === 403);
|
||||
check('拒绝目录穿越文件名', (await upload({ name: '../secret.pptx' })).status === 422);
|
||||
check('拒绝空文件', (await upload({ size: 0 }, tokens[0], Buffer.alloc(0))).status === 422);
|
||||
check('拒绝超过 512 MB 的课件', (await upload({ size: COURSEWARE_MAX_BYTES + 1 })).status === 422);
|
||||
check('拒绝错误文件长度', (await upload({ size: 1 })).status === 400);
|
||||
check('推送不能不选班级', (await upload({ mode: 'push', classIds: [] })).status === 422);
|
||||
const created = await upload();
|
||||
const item = created.data as CoursewareDto;
|
||||
check(
|
||||
'完整上传后创建课件元数据',
|
||||
created.status === 201 && item.size === bytes.length && item.name === '第1课 演示.pptx',
|
||||
);
|
||||
check(
|
||||
'摘要、日期与类型正确',
|
||||
item.sha256 === createHash('sha256').update(bytes).digest('hex') &&
|
||||
item.archiveDate === dayKeyLocal(new Date()) &&
|
||||
item.category === 'presentation',
|
||||
);
|
||||
check('API 不暴露服务端路径', !('storagePath' in item));
|
||||
const stored = await prisma.courseware.findUniqueOrThrow({ where: { id: item.id } });
|
||||
check(
|
||||
'文件按日期和类型保存',
|
||||
stored.storagePath.startsWith(path.join(item.archiveDate, 'presentation')) &&
|
||||
(await fs.readFile(path.join(process.env.COURSEWARE_DIR!, stored.storagePath))).equals(bytes),
|
||||
);
|
||||
check(
|
||||
'班级仅看到自己可见的课件',
|
||||
(await list(classTokens[0]!)).length === 1 && (await list(classTokens[1]!)).length === 0,
|
||||
);
|
||||
check('其他教师不能访问本人文件', (await request(`/courseware/${item.id}`, tokens[1])).status === 403);
|
||||
check(
|
||||
'其他班级不能绕过列表下载',
|
||||
(await request(`/courseware/${item.id}/download`, classTokens[1])).status === 403,
|
||||
);
|
||||
const downloaded = await fetch(`${base()}/courseware/${item.id}/download`, {
|
||||
headers: { authorization: `Bearer ${classTokens[0]}` },
|
||||
});
|
||||
check(
|
||||
'中文文件按附件下载且内容一致',
|
||||
downloaded.headers.get('content-disposition')?.includes('attachment') &&
|
||||
Buffer.from(await downloaded.arrayBuffer()).equals(bytes),
|
||||
);
|
||||
check('下载响应禁止共享缓存', downloaded.headers.get('cache-control')?.includes('no-store'));
|
||||
await heartbeat(0, 'courseware-device-1');
|
||||
await heartbeat(0, 'courseware-device-2');
|
||||
await heartbeat(1, 'courseware-device-3');
|
||||
check('仅存云端不会自动发送任务', (await pending(0, 'courseware-device-1')).data.length === 0);
|
||||
check('未登记设备不能拉取传输', (await pending(0, 'unknown-device')).status === 403);
|
||||
check('不能使用其他班级设备标识', (await pending(1, 'courseware-device-1')).status === 403);
|
||||
const privateItem = (await upload({ classIds: [], name: '私有讲义.pdf' })).data as CoursewareDto;
|
||||
check('仅自己可见文件不向班级披露', !(await list(classTokens[0]!)).some((c) => c.id === privateItem.id));
|
||||
check(
|
||||
'无法推送到课件可见范围以外',
|
||||
(await request(`/courseware/${item.id}/push`, tokens[0], { classIds: [classes[1]!.id] })).status === 400,
|
||||
);
|
||||
check(
|
||||
'他人不能改可见范围',
|
||||
(await request(`/courseware/${item.id}/visibility`, tokens[1], { classIds: [] }, 'PATCH')).status === 403,
|
||||
);
|
||||
const pushed = (
|
||||
await upload({ mode: 'push', name: '课堂练习.docx', classIds: classes.slice(0, 2).map((c) => c.id) })
|
||||
).data as CoursewareDto;
|
||||
check(
|
||||
'推送模式先存云端实体',
|
||||
!!(await prisma.courseware.findUnique({ where: { id: pushed.id } })) &&
|
||||
(await list(tokens[0]!)).some((c) => c.id === pushed.id),
|
||||
);
|
||||
check(
|
||||
'班级端不泄露其他可见班级',
|
||||
(await list(classTokens[0]!)).every((c) => c.classes.every((v) => v.id === classes[0]!.id)),
|
||||
);
|
||||
const task = (await pending(0, 'courseware-device-1')).data[0];
|
||||
check('推送任务可由离线班级下次上线拉取', task.courseware.id === pushed.id);
|
||||
check(
|
||||
'实时提示只发送到所选班级房间',
|
||||
emitted
|
||||
.filter((e) => e.event === SOCKET_EVENTS.coursewareChanged)
|
||||
.every((e) =>
|
||||
e.rooms.every((room) => [classes[0]!.id, classes[1]!.id].some((id) => room === `class:${id}`)),
|
||||
),
|
||||
);
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
server = http.createServer(createApp());
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
check('重启服务端后仍保留未完成传输', (await pending(0, 'courseware-device-1')).data[0].id === task.id);
|
||||
const ackRoute = `/courseware/transfers/${task.id}/ack`;
|
||||
check(
|
||||
'其他班级不能回执任务',
|
||||
(await request(ackRoute, classTokens[1], { deviceKey: 'courseware-device-3' })).status === 404,
|
||||
);
|
||||
check(
|
||||
'接收完成后可回执',
|
||||
(await request(ackRoute, classTokens[0], { deviceKey: 'courseware-device-1' })).status === 200,
|
||||
);
|
||||
check(
|
||||
'重复回执幂等',
|
||||
(await request(ackRoute, classTokens[0], { deviceKey: 'courseware-device-1' })).status === 200,
|
||||
);
|
||||
check(
|
||||
'回执仅清除本设备任务',
|
||||
(await pending(0, 'courseware-device-1')).data.length === 0 &&
|
||||
(await pending(0, 'courseware-device-2')).data.length === 1,
|
||||
);
|
||||
await request(`/courseware/${pushed.id}/visibility`, tokens[0], { classIds: [classes[0]!.id] }, 'PATCH');
|
||||
check(
|
||||
'撤回班级可见范围同时取消其待传任务',
|
||||
(await pending(1, 'courseware-device-3')).data.length === 0 &&
|
||||
!(await list(classTokens[1]!)).some((c) => c.id === pushed.id),
|
||||
);
|
||||
check(
|
||||
'撤回后不能通过直链下载',
|
||||
(await request(`/courseware/${pushed.id}/download`, classTokens[1])).status === 403,
|
||||
);
|
||||
await request(`/courseware/${pushed.id}/push`, tokens[0], { classIds: [classes[0]!.id] });
|
||||
check('已归档课件可再次推送', (await pending(0, 'courseware-device-1')).data.length === 1);
|
||||
const defaultGroup = await prisma.permissionGroup.findUniqueOrThrow({ where: { defaultRole: 'TEACHER' } });
|
||||
await prisma.permissionGroupItem.deleteMany({
|
||||
where: { groupId: defaultGroup.id, key: 'courseware:write' },
|
||||
});
|
||||
check('收回写权限立即禁止上传', (await upload()).status === 403);
|
||||
await prisma.permissionGroupItem.create({ data: { groupId: defaultGroup.id, key: 'courseware:write' } });
|
||||
const snapshot = await exportSnapshot(prisma, 'sqlite');
|
||||
check(
|
||||
'备份包含课件、可见范围、推送和回执',
|
||||
snapshot.data.Courseware.length === 3 &&
|
||||
snapshot.data.CoursewareReceipt.length === 1 &&
|
||||
snapshot.data.CoursewarePush.length > 0 &&
|
||||
snapshot.data.CoursewareVisibility.length > 0,
|
||||
);
|
||||
const oldSnapshot = structuredClone(snapshot);
|
||||
for (const key of ['Courseware', 'CoursewarePush', 'CoursewareReceipt', 'CoursewareVisibility'])
|
||||
delete (oldSnapshot.data as Record<string, unknown>)[key];
|
||||
assertValidSnapshot(oldSnapshot);
|
||||
check('允许读取升级前的旧版快照', true);
|
||||
await request(`/courseware/${pushed.id}`, tokens[0], undefined, 'DELETE');
|
||||
check(
|
||||
'删除课件级联清理推送任务',
|
||||
(await pending(0, 'courseware-device-1')).data.length === 0 &&
|
||||
(await pending(0, 'courseware-device-2')).data.length === 0,
|
||||
);
|
||||
check('删除实体后无残余文件', (await files()).length === 2);
|
||||
const before = (await files()).length;
|
||||
await new Promise<void>((resolve) => {
|
||||
const metadata = { name: '中断上传.zip', size: 100000, classIds: [], mode: 'cloud' };
|
||||
const req = http.request(
|
||||
`${base()}/courseware/upload?metadata=${encodeURIComponent(JSON.stringify(metadata))}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
authorization: `Bearer ${tokens[0]}`,
|
||||
'content-type': 'application/octet-stream',
|
||||
'content-length': '100000',
|
||||
},
|
||||
},
|
||||
);
|
||||
req.on('error', () => resolve());
|
||||
req.write(Buffer.alloc(1000));
|
||||
setTimeout(() => req.destroy(), 250);
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
check(
|
||||
'上传中断自动清理临时文件',
|
||||
(await files()).length === before && !(await files()).some((file) => file.endsWith('.part')),
|
||||
);
|
||||
check(
|
||||
'文件分类覆盖演示、文档、媒体与压缩包',
|
||||
coursewareCategory('A.PPTX') === 'presentation' &&
|
||||
coursewareCategory('a.pdf') === 'document' &&
|
||||
coursewareCategory('v.mp4') === 'video' &&
|
||||
coursewareCategory('a.7z') === 'archive',
|
||||
);
|
||||
await restoreSnapshot(prisma, 'sqlite', snapshot);
|
||||
check(
|
||||
'恢复数据库后保留课件共享和回执数据',
|
||||
(await prisma.courseware.count()) === 3 && (await prisma.coursewareReceipt.count()) === 1,
|
||||
);
|
||||
console.log(`=== 课件隔离验收:${count}/${count} 项通过 ===`);
|
||||
} finally {
|
||||
server.closeAllConnections();
|
||||
server.close();
|
||||
await once(server, 'close').catch(() => undefined);
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
@@ -3222,6 +3222,10 @@ async function main() {
|
||||
'UserPermissionGroup',
|
||||
'AppSetting',
|
||||
'ClientDevice',
|
||||
'Courseware',
|
||||
'CoursewareVisibility',
|
||||
'CoursewarePush',
|
||||
'CoursewareReceipt',
|
||||
'ClientCommand',
|
||||
'ClientRelease',
|
||||
'AiAudit',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { COURSEWARE_TRANSFER_TIMEOUT_MS } from '@classhelper/shared';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { createServer } from 'node:http';
|
||||
@@ -61,6 +62,8 @@ await refreshDeveloperMode();
|
||||
|
||||
const app = createApp();
|
||||
const httpServer = createServer(app);
|
||||
// 大课件允许较慢的内网上传;请求头仍沿用 Node 的独立超时限制。
|
||||
httpServer.requestTimeout = COURSEWARE_TRANSFER_TIMEOUT_MS;
|
||||
const io = initRealtime(httpServer);
|
||||
|
||||
/** 写入 PID 文件(安装包的 start.cmd / stop.cmd 依赖它精确控制进程) */
|
||||
|
||||
@@ -59,6 +59,10 @@ export const SNAPSHOT_TABLES = [
|
||||
'NotificationRead',
|
||||
'Grade',
|
||||
'ClientDevice',
|
||||
'Courseware',
|
||||
'CoursewareVisibility',
|
||||
'CoursewarePush',
|
||||
'CoursewareReceipt',
|
||||
'ClientCommand',
|
||||
'ClientRelease',
|
||||
'AiAudit',
|
||||
@@ -132,6 +136,8 @@ 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 (!Array.isArray(rows)) {
|
||||
throw new Error(`快照缺少表 ${table} 的数据`);
|
||||
}
|
||||
@@ -174,7 +180,7 @@ export async function restoreSnapshot(
|
||||
}
|
||||
}
|
||||
for (const table of SNAPSHOT_TABLES) {
|
||||
const rows = snapshot.data[table].map((row) => {
|
||||
const rows = (snapshot.data[table] ?? []).map((row) => {
|
||||
if (table !== 'Class' || typeof row !== 'object' || row === null) return row;
|
||||
const copy = { ...(row as Record<string, unknown>) };
|
||||
delete copy.notificationChannel;
|
||||
|
||||
@@ -22,7 +22,7 @@ router.use(authenticate());
|
||||
*
|
||||
* 权限分成两把:`ai:manage`(设置,默认只有管理员)与 `ai:use`(用一句话办事,默认教师也有)。
|
||||
* **`/interpret` 只解析、绝不落库**:真正发布由前端确认后调既有端点,权限与上课时段保护
|
||||
* 都在那条路径上(AGENTS.md §5 第 5/14 条)。
|
||||
* 都在那条路径上(AGENTS.md 的现行工程约定)。
|
||||
*/
|
||||
router.get('/settings', requirePermission('ai:manage'), async (_req, res) => {
|
||||
sendOk(res, await aiService.getAiSettings(), '获取大模型设置成功');
|
||||
|
||||
@@ -24,7 +24,7 @@ import type { AiInterpretInput, UpdateAiSettingsInput } from './ai.schemas.js';
|
||||
* 设计边界(照 approved 计划):
|
||||
* 1. 模型只输出结构化意图(`{action, params, confidence}`),服务端用**与手动入口同一份 zod**
|
||||
* 二次校验后返回预览;真正落库由前端确认后调既有端点(`POST /homeworks` 等),
|
||||
* 于是权限断言、上课时段紧急通知保护全部自动复用(AGENTS.md §5 第 5/14 条);
|
||||
* 于是权限断言、上课时段紧急通知保护全部自动复用(AGENTS.md 的现行工程约定);
|
||||
* 2. **密钥只进不出**:`AppSetting` 里存的是 `encryptSecret()` 的密文,任何 GET 都不回传;
|
||||
* 3. `provider: 'mock'` 用确定性规则离线解析 —— 自动化验收**绝不访问外网**(§5 第 52 条),
|
||||
* 真实模型走 `provider: 'openai'` 时的 `{baseUrl}/chat/completions`。
|
||||
|
||||
@@ -30,7 +30,7 @@ router.post(
|
||||
*
|
||||
* 归档是**打标记 + 只读**:毕业班级的作业与通知原样留在库里,
|
||||
* 通过 `GET /:id/classes/:classId/content` 只读查看。
|
||||
* 「未毕业而升级的班级不归档」—— 升级走 `POST /api/classes/promote`,只改年级。
|
||||
* 年度升级走 `POST /api/term/promotion/run`,毕业班归档,其余升一级;本模块读取归档结果。
|
||||
*/
|
||||
|
||||
/** GET /api/archives - 届别列表 */
|
||||
|
||||
@@ -27,7 +27,7 @@ import type {
|
||||
/**
|
||||
* ClassHelper 桌面客户端设备的心跳 / 远控命令 / 更新下发。
|
||||
*
|
||||
* 范式(AGENTS.md §5 第 67 条):**DB 记状态 + 客户端定时拉取** ——
|
||||
* 范式(AGENTS.md 的现行工程约定):**DB 记状态 + 客户端定时拉取** ——
|
||||
* 因此本文件里没有任何"推送"动作:所有下发都只是改 `ClientDevice` / `ClientCommand` 的字段,
|
||||
* 由客户端下一次心跳(30 秒内)带走。
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { Router } from 'express';
|
||||
import { sendOk, sendCreated } from '../../lib/http.js';
|
||||
import { idParamSchema } from '../../lib/schemas.js';
|
||||
import {
|
||||
authenticate,
|
||||
getAuthUser,
|
||||
requireRole,
|
||||
requirePermission,
|
||||
requireStaffPermission,
|
||||
} from '../../middleware/auth.js';
|
||||
import { validate, validatedBody, validatedQuery, validatedParams } from '../../middleware/validate.js';
|
||||
import { defineModule } from '../module.types.js';
|
||||
import { coursewareDeviceQuery, coursewareScopeSchema, coursewareUploadQuery } from './courseware.schemas.js';
|
||||
import type { CoursewareUploadRequest } from '@classhelper/shared';
|
||||
import * as service from './courseware.service.js';
|
||||
const router = Router();
|
||||
router.use(authenticate());
|
||||
const staff = [requireRole('ADMIN', 'TEACHER'), requirePermission('courseware:write')];
|
||||
router.get('/', requireStaffPermission('courseware:read'), async (req, res) =>
|
||||
sendOk(res, await service.listCourseware(getAuthUser(req))),
|
||||
);
|
||||
router.get('/classes', ...staff, async (req, res) =>
|
||||
sendOk(res, await service.teachingClasses(getAuthUser(req))),
|
||||
);
|
||||
router.get(
|
||||
'/transfers',
|
||||
requireRole('CLASS_DEVICE'),
|
||||
validate({ query: coursewareDeviceQuery }),
|
||||
async (req, res) =>
|
||||
sendOk(
|
||||
res,
|
||||
await service.pendingTransfers(getAuthUser(req), validatedQuery<{ deviceKey: string }>(req).deviceKey),
|
||||
),
|
||||
);
|
||||
router.post(
|
||||
'/transfers/:id/ack',
|
||||
requireRole('CLASS_DEVICE'),
|
||||
validate({ params: idParamSchema, body: coursewareDeviceQuery }),
|
||||
async (req, res) => {
|
||||
await service.acknowledgeTransfer(
|
||||
getAuthUser(req),
|
||||
validatedParams<{ id: string }>(req).id,
|
||||
validatedBody<{ deviceKey: string }>(req).deviceKey,
|
||||
);
|
||||
sendOk(res, null);
|
||||
},
|
||||
);
|
||||
router.post('/upload', ...staff, validate({ query: coursewareUploadQuery }), async (req, res) =>
|
||||
sendCreated(
|
||||
res,
|
||||
await service.uploadCourseware(
|
||||
getAuthUser(req),
|
||||
validatedQuery<{ metadata: CoursewareUploadRequest }>(req).metadata,
|
||||
req,
|
||||
),
|
||||
),
|
||||
);
|
||||
router.get(
|
||||
'/:id/download',
|
||||
requireStaffPermission('courseware:read'),
|
||||
validate({ params: idParamSchema }),
|
||||
async (req, res, next) => {
|
||||
const row = await service.readable(getAuthUser(req), validatedParams<{ id: string }>(req).id);
|
||||
res.setHeader('Cache-Control', 'private, no-store');
|
||||
res.setHeader('Content-Type', 'application/octet-stream');
|
||||
res.download(row.storagePath, row.name, { root: service.coursewareRoot, dotfiles: 'allow' }, (error) => {
|
||||
if (error && !res.headersSent) next(error);
|
||||
});
|
||||
},
|
||||
);
|
||||
router.patch(
|
||||
'/:id/visibility',
|
||||
...staff,
|
||||
validate({ params: idParamSchema, body: coursewareScopeSchema }),
|
||||
async (req, res) =>
|
||||
sendOk(
|
||||
res,
|
||||
await service.setVisibility(
|
||||
getAuthUser(req),
|
||||
validatedParams<{ id: string }>(req).id,
|
||||
validatedBody<{ classIds: string[] }>(req).classIds,
|
||||
),
|
||||
),
|
||||
);
|
||||
router.post(
|
||||
'/:id/push',
|
||||
...staff,
|
||||
validate({ params: idParamSchema, body: coursewareScopeSchema }),
|
||||
async (req, res) => {
|
||||
await service.pushCourseware(
|
||||
getAuthUser(req),
|
||||
validatedParams<{ id: string }>(req).id,
|
||||
validatedBody<{ classIds: string[] }>(req).classIds,
|
||||
);
|
||||
sendOk(res, null);
|
||||
},
|
||||
);
|
||||
router.delete('/:id', ...staff, validate({ params: idParamSchema }), async (req, res) => {
|
||||
await service.deleteCourseware(getAuthUser(req), validatedParams<{ id: string }>(req).id);
|
||||
sendOk(res, null);
|
||||
});
|
||||
router.get(
|
||||
'/:id',
|
||||
requireStaffPermission('courseware:read'),
|
||||
validate({ params: idParamSchema }),
|
||||
async (req, res) =>
|
||||
sendOk(res, await service.getCourseware(getAuthUser(req), validatedParams<{ id: string }>(req).id)),
|
||||
);
|
||||
export const coursewareModule = defineModule({ name: 'courseware', basePath: '/courseware', router });
|
||||
@@ -0,0 +1,33 @@
|
||||
import { z } from 'zod';
|
||||
import { COURSEWARE_MAX_BYTES } from '@classhelper/shared';
|
||||
|
||||
export const coursewareScopeSchema = z.object({ classIds: z.array(z.string().min(1).max(100)).max(100) });
|
||||
export const coursewareUploadSchema = coursewareScopeSchema
|
||||
.extend({
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(180)
|
||||
.refine(
|
||||
(name) => !/[<>:"/\\|?*\u0000-\u001f]/.test(name) && !name.endsWith('.') && !name.endsWith(' '),
|
||||
'文件名含无效字符',
|
||||
),
|
||||
size: z.number().int().min(1).max(COURSEWARE_MAX_BYTES),
|
||||
mode: z.enum(['cloud', 'push']),
|
||||
})
|
||||
.refine((input) => input.mode !== 'push' || input.classIds.length > 0, '推送时请选择授课班级');
|
||||
export const coursewareUploadQuery = z.object({
|
||||
metadata: z
|
||||
.string()
|
||||
.transform((value, ctx): unknown => {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
ctx.addIssue({ code: 'custom', message: '上传信息格式错误' });
|
||||
return z.NEVER;
|
||||
}
|
||||
})
|
||||
.pipe(coursewareUploadSchema),
|
||||
});
|
||||
export const coursewareDeviceQuery = z.object({ deviceKey: z.string().min(1).max(100) });
|
||||
@@ -0,0 +1,228 @@
|
||||
import fs from 'node:fs';
|
||||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { randomUUID, createHash } from 'node:crypto';
|
||||
import { Transform } from 'node:stream';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import type { Request } from 'express';
|
||||
import {
|
||||
COURSEWARE_MAX_BYTES,
|
||||
coursewareCategory,
|
||||
dayKeyLocal,
|
||||
SOCKET_EVENTS,
|
||||
type CoursewareDto,
|
||||
type CoursewareUploadRequest,
|
||||
} from '@classhelper/shared';
|
||||
import { env } from '../../config/env.js';
|
||||
import { prisma } from '../../lib/db.js';
|
||||
import { ApiError } from '../../lib/http.js';
|
||||
import { assertCanManageSubjectContent, requireClassDeviceClassId } from '../../lib/access.js';
|
||||
import type { TokenPayload } from '../../lib/jwt.js';
|
||||
import type { Prisma } from '../../generated/prisma/client.js';
|
||||
import { emitToClass } from '../../realtime/bus.js';
|
||||
|
||||
export const coursewareRoot = path.resolve(
|
||||
process.env.COURSEWARE_DIR || path.join(env.serverRoot, '..', 'data', 'courseware'),
|
||||
);
|
||||
const include = {
|
||||
owner: { select: { id: true, name: true } },
|
||||
visibility: { include: { class: { select: { id: true, name: true } } } },
|
||||
} as const;
|
||||
type RecordWithRelations = Prisma.CoursewareGetPayload<{ include: typeof include }>;
|
||||
function dto(row: RecordWithRelations): CoursewareDto {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
size: row.size,
|
||||
category: row.category as CoursewareDto['category'],
|
||||
extension: row.extension,
|
||||
archiveDate: row.archiveDate,
|
||||
sha256: row.sha256,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
teacher: row.owner,
|
||||
classes: row.visibility.map((item) => item.class),
|
||||
};
|
||||
}
|
||||
export async function teachingClasses(user: TokenPayload) {
|
||||
return prisma.class.findMany({
|
||||
where: { archivedYearId: null, courses: { some: { teacherId: user.sub } } },
|
||||
select: { id: true, name: true },
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
}
|
||||
async function validateClasses(user: TokenPayload, ids: string[]) {
|
||||
const classIds = [...new Set(ids)];
|
||||
const allowed = new Set((await teachingClasses(user)).map((item) => item.id));
|
||||
if (classIds.some((id) => !allowed.has(id))) throw ApiError.forbidden('只能选择自己任教的在读班级');
|
||||
for (const classId of classIds) {
|
||||
const course = await prisma.course.findFirst({
|
||||
where: { classId, teacherId: user.sub },
|
||||
select: { id: true },
|
||||
});
|
||||
await assertCanManageSubjectContent(user, classId, course?.id, '发布课件');
|
||||
}
|
||||
return classIds;
|
||||
}
|
||||
export async function readable(user: TokenPayload, id: string) {
|
||||
const row = await prisma.courseware.findUnique({ where: { id }, include });
|
||||
if (!row) throw ApiError.notFound('课件不存在');
|
||||
if (user.role === 'CLASS_DEVICE') {
|
||||
if (!row.visibility.some((item) => item.classId === requireClassDeviceClassId(user)))
|
||||
throw ApiError.forbidden('课件不在本班可见范围内');
|
||||
} else if (row.ownerId !== user.sub && user.role !== 'ADMIN')
|
||||
throw ApiError.forbidden('只能访问自己的课件');
|
||||
return row;
|
||||
}
|
||||
async function owned(user: TokenPayload, id: string) {
|
||||
const row = await readable(user, id);
|
||||
if (row.ownerId !== user.sub) throw ApiError.forbidden('只能修改自己的课件');
|
||||
return row;
|
||||
}
|
||||
export async function getCourseware(user: TokenPayload, id: string) {
|
||||
const item = dto(await readable(user, id));
|
||||
if (user.role === 'CLASS_DEVICE')
|
||||
item.classes = item.classes.filter((c) => c.id === requireClassDeviceClassId(user));
|
||||
return item;
|
||||
}
|
||||
export async function listCourseware(user: TokenPayload) {
|
||||
const classId = user.role === 'CLASS_DEVICE' ? requireClassDeviceClassId(user) : null;
|
||||
const rows = await prisma.courseware.findMany({
|
||||
where: classId
|
||||
? { visibility: { some: { classId } } }
|
||||
: user.role === 'ADMIN'
|
||||
? {}
|
||||
: { ownerId: user.sub },
|
||||
include,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
// 班级端只暴露本班的可见关系。
|
||||
return rows.map((row) => {
|
||||
const item = dto(row);
|
||||
if (classId) item.classes = item.classes.filter((c) => c.id === classId);
|
||||
return item;
|
||||
});
|
||||
}
|
||||
export async function uploadCourseware(user: TokenPayload, input: CoursewareUploadRequest, req: Request) {
|
||||
const classIds = await validateClasses(user, input.classIds);
|
||||
if (!req.is('application/octet-stream')) throw ApiError.badRequest('请使用二进制文件上传');
|
||||
if (req.headers['content-length'] && Number(req.headers['content-length']) !== input.size)
|
||||
throw ApiError.badRequest('文件长度与上传信息不符');
|
||||
const category = coursewareCategory(input.name),
|
||||
archiveDate = dayKeyLocal(new Date());
|
||||
const extension = path.extname(input.name).slice(1).toLowerCase();
|
||||
const relative = path.join(archiveDate, category, randomUUID());
|
||||
const file = path.join(coursewareRoot, relative),
|
||||
partial = `${file}.part`;
|
||||
await fsp.mkdir(path.dirname(file), { recursive: true });
|
||||
let bytes = 0;
|
||||
const hash = createHash('sha256');
|
||||
try {
|
||||
await pipeline(
|
||||
req,
|
||||
new Transform({
|
||||
transform(chunk: Buffer, _encoding, callback) {
|
||||
bytes += chunk.length;
|
||||
if (bytes > COURSEWARE_MAX_BYTES || bytes > input.size)
|
||||
return callback(ApiError.badRequest('课件超过上传大小限制'));
|
||||
hash.update(chunk);
|
||||
callback(null, chunk);
|
||||
},
|
||||
}),
|
||||
fs.createWriteStream(partial, { flags: 'wx' }),
|
||||
);
|
||||
if (bytes !== input.size) throw ApiError.badRequest('文件上传不完整,请重试');
|
||||
await fsp.rename(partial, file);
|
||||
// 上传耗时较长,完成后重新验证任课关系。
|
||||
await validateClasses(user, classIds);
|
||||
const row = await prisma.courseware.create({
|
||||
data: {
|
||||
ownerId: user.sub,
|
||||
name: input.name,
|
||||
size: bytes,
|
||||
category,
|
||||
extension,
|
||||
archiveDate,
|
||||
storagePath: relative,
|
||||
sha256: hash.digest('hex'),
|
||||
visibility: { create: classIds.map((classId) => ({ classId })) },
|
||||
pushes: input.mode === 'push' ? { create: classIds.map((classId) => ({ classId })) } : undefined,
|
||||
},
|
||||
include,
|
||||
});
|
||||
for (const classId of classIds) emitToClass(classId, SOCKET_EVENTS.coursewareChanged, { id: row.id });
|
||||
return dto(row);
|
||||
} catch (error) {
|
||||
await Promise.all([fsp.rm(partial, { force: true }), fsp.rm(file, { force: true })]);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
export async function setVisibility(user: TokenPayload, id: string, ids: string[]) {
|
||||
await owned(user, id);
|
||||
const classIds = await validateClasses(user, ids);
|
||||
const old = await prisma.coursewareVisibility.findMany({ where: { coursewareId: id } });
|
||||
const row = await prisma.$transaction(async (tx) => {
|
||||
await tx.coursewarePush.deleteMany({ where: { coursewareId: id, classId: { notIn: classIds } } });
|
||||
await tx.coursewareVisibility.deleteMany({ where: { coursewareId: id } });
|
||||
return tx.courseware.update({
|
||||
where: { id },
|
||||
data: { visibility: { create: classIds.map((classId) => ({ classId })) } },
|
||||
include,
|
||||
});
|
||||
});
|
||||
for (const classId of new Set([...classIds, ...old.map((v) => v.classId)]))
|
||||
emitToClass(classId, SOCKET_EVENTS.coursewareChanged, { id });
|
||||
return dto(row);
|
||||
}
|
||||
export async function pushCourseware(user: TokenPayload, id: string, ids: string[]) {
|
||||
const row = await owned(user, id);
|
||||
const classIds = await validateClasses(user, ids);
|
||||
if (!classIds.length || classIds.some((classId) => !row.visibility.some((v) => v.classId === classId)))
|
||||
throw ApiError.badRequest('推送班级必须在课件可见范围内');
|
||||
await prisma.$transaction(
|
||||
classIds.map((classId) => prisma.coursewarePush.create({ data: { coursewareId: id, classId } })),
|
||||
);
|
||||
for (const classId of classIds) emitToClass(classId, SOCKET_EVENTS.coursewareChanged, { id });
|
||||
}
|
||||
export async function deleteCourseware(user: TokenPayload, id: string) {
|
||||
const row = await owned(user, id);
|
||||
await prisma.courseware.delete({ where: { id } });
|
||||
await fsp.rm(path.join(coursewareRoot, row.storagePath), { force: true });
|
||||
for (const v of row.visibility) emitToClass(v.classId, SOCKET_EVENTS.coursewareChanged, { id });
|
||||
}
|
||||
export async function deviceFor(user: TokenPayload, deviceKey: string) {
|
||||
const classId = requireClassDeviceClassId(user);
|
||||
const device = await prisma.clientDevice.findUnique({ where: { deviceKey } });
|
||||
if (!device || device.classId !== classId) throw ApiError.forbidden('设备未登记或不属于当前班级');
|
||||
return { ...device, classId };
|
||||
}
|
||||
export async function pendingTransfers(user: TokenPayload, deviceKey: string) {
|
||||
const device = await deviceFor(user, deviceKey);
|
||||
const rows = await prisma.coursewarePush.findMany({
|
||||
where: {
|
||||
classId: device.classId,
|
||||
receipts: { none: { deviceId: device.id } },
|
||||
courseware: { visibility: { some: { classId: device.classId } } },
|
||||
},
|
||||
include: { courseware: { include } },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
take: 50,
|
||||
});
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
courseware: {
|
||||
...dto(row.courseware),
|
||||
classes: dto(row.courseware).classes.filter((c) => c.id === device.classId),
|
||||
},
|
||||
}));
|
||||
}
|
||||
export async function acknowledgeTransfer(user: TokenPayload, pushId: string, deviceKey: string) {
|
||||
const device = await deviceFor(user, deviceKey);
|
||||
const push = await prisma.coursewarePush.findFirst({ where: { id: pushId, classId: device.classId } });
|
||||
if (!push) throw ApiError.notFound('传输任务不存在');
|
||||
await readable(user, push.coursewareId);
|
||||
await prisma.coursewareReceipt.upsert({
|
||||
where: { pushId_deviceId: { pushId, deviceId: device.id } },
|
||||
create: { pushId, deviceId: device.id },
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
@@ -77,7 +77,7 @@ router.post(
|
||||
* POST /api/homeworks/bulk - 一次给多个班级布置同一份作业(逐班结果)
|
||||
*
|
||||
* 与 POST / 的区别:批量发布是 Web 管理端的"多班同发"工作流,因此这里挂 `homework:write`;
|
||||
* 教室机器的班级账号仍走单班录入(POST /,权限在服务层判,见 AGENTS.md §5 第 20 条)。
|
||||
* 教室机器的班级账号仍走单班录入(POST /,权限在服务层判,见 AGENTS.md 的现行工程约定)。
|
||||
* 每个班的科目归属依然由 createHomework 里的 assertCanManageSubjectContent 逐个判定 ——
|
||||
* 批量不是绕过科目门的后门(§5 第 5 条)。
|
||||
*/
|
||||
|
||||
@@ -149,7 +149,7 @@ export async function getHomework(user: TokenPayload, homeworkId: string): Promi
|
||||
* - **选了科目** → 必须是该科的任课老师(班主任不教这一科也发不了);
|
||||
* - **没选科目** → 本班班主任或管理员;
|
||||
* - **ClassHelper 班级端** → 可以录(老师在教室电脑上顺手记一条是常见需求,
|
||||
* 见 AGENTS.md §5 第 20 条),班级端录入的作业归属该班班主任。
|
||||
* 见 AGENTS.md 的现行工程约定),班级端录入的作业归属该班班主任。
|
||||
*/
|
||||
export async function createHomework(user: TokenPayload, input: CreateHomeworkInput): Promise<HomeworkDto> {
|
||||
await assertCanManageSubjectContent(user, input.classId, input.courseId ?? null, '布置作业', {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { coursewareModule } from './courseware/courseware.module.js';
|
||||
import {
|
||||
classPlansModule,
|
||||
timeLayoutsModule,
|
||||
@@ -33,6 +34,7 @@ import type { ApiModule } from './module.types.js';
|
||||
* 临时下线:把 enabled 置为 false。
|
||||
*/
|
||||
export const apiModules: ApiModule[] = [
|
||||
coursewareModule,
|
||||
authModule,
|
||||
aiModule,
|
||||
classesModule,
|
||||
|
||||
@@ -18,11 +18,7 @@ import * as teacherService from './teachers.service.js';
|
||||
const router = Router();
|
||||
router.use(authenticate());
|
||||
|
||||
/**
|
||||
* 教师账号管理:**全部接口仅管理员**。
|
||||
* ClassHelper 班级端主体是班级,但教师账号决定谁是哪班班主任、谁能录课表/成绩,属于系统级配置,
|
||||
* 因此录入/修改/重置密码/删除都只开放给 ADMIN(路由层 + 前端菜单双重收敛)。
|
||||
*/
|
||||
/** 教师列表使用 teacher:read/teacher:manage,写入统一要求 teacher:manage;额外业务约束见 service。 */
|
||||
|
||||
/** GET /api/teachers?keyword= - 教师列表(教师管理页 + 分配协作教师时使用) */
|
||||
router.get(
|
||||
@@ -35,7 +31,7 @@ router.get(
|
||||
},
|
||||
);
|
||||
|
||||
/** POST /api/teachers - 录入教师账号(仅管理员) */
|
||||
/** POST /api/teachers - 录入教师账号(需 teacher:manage) */
|
||||
router.post(
|
||||
'/',
|
||||
requirePermission('teacher:manage'),
|
||||
|
||||
@@ -104,11 +104,12 @@ export async function resetTeacherPassword(
|
||||
export async function deleteTeacher(userId: string): Promise<void> {
|
||||
const teacher = await findTeacherOrThrow(userId);
|
||||
|
||||
const [ownedClasses, courses, homeworks, notifications] = await Promise.all([
|
||||
const [ownedClasses, courses, homeworks, notifications, coursewares] = await Promise.all([
|
||||
prisma.class.count({ where: { teacherId: teacher.id } }),
|
||||
prisma.course.count({ where: { teacherId: teacher.id } }),
|
||||
prisma.homework.count({ where: { createdBy: teacher.id } }),
|
||||
prisma.notification.count({ where: { createdBy: teacher.id } }),
|
||||
prisma.courseware.count({ where: { ownerId: teacher.id } }),
|
||||
]);
|
||||
if (ownedClasses > 0) {
|
||||
throw ApiError.conflict(
|
||||
@@ -127,5 +128,7 @@ export async function deleteTeacher(userId: string): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
if (coursewares > 0) throw ApiError.conflict(`「${teacher.name}」还有 ${coursewares} 个课件,请先清理课件再删除账号`);
|
||||
|
||||
await prisma.user.delete({ where: { id: teacher.id } });
|
||||
}
|
||||
Reference in new issue
Block a user