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

+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 } });
}