feat(courseware,docs): 完善课件传输与岛编辑并统一全仓文档

- 增加课件上传、班级可见范围、设备补收与传输进度
- 整合自定义灵动岛编辑器和相关客户端交互
- 同步三端契约、数据库迁移、部署配置与隔离回归
- 按源码统一文档与官网说明,移除过时任务书和审查记录
- 保持产品版本为 1.4.0
This commit is contained in:
LoveChengke committed 2026-10-18 11:53:37 +08:00
1 parent 536379036a
commit c7f215a09c
127 files changed
+4334 -8482

No files matched your search

+1 -1
View File
@@ -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');
+51
View File
@@ -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])
}
+1
View File
@@ -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();
}
+4
View File
@@ -3222,6 +3222,10 @@ async function main() {
'UserPermissionGroup',
'AppSetting',
'ClientDevice',
'Courseware',
'CoursewareVisibility',
'CoursewarePush',
'CoursewareReceipt',
'ClientCommand',
'ClientRelease',
'AiAudit',
+3
View File
@@ -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 依赖它精确控制进程) */
+7 -1
View File
@@ -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;
+1 -1
View File
@@ -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(), '获取大模型设置成功');
+1 -1
View File
@@ -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, '布置作业', {
+2
View File
@@ -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 } });
}