feat: 完善课表档案、课件归档、提醒与客户端更新并发布 1.4.1
- 重写课表档案工作区并合并时间表、课表与完整档案导入导出 - 修复课前/上下课提醒遮罩与通知收回冲突 - 增加课件文件夹分类、教师归档路径和客户端本机归档 - 打通客户端下载/安装/完成状态上报并在管理端展示更新过程 - 修复侧栏折叠裁切、课时多选与上传控件样式 - 统一版本到 1.4.1
This commit is contained in:
1 parent
2612a912ef
commit
6347d1e5aa
59 files changed
+2251
-584
No files matched your search
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@classhelper/server",
|
||||
"version": "1.4.0",
|
||||
"version": "1.4.1",
|
||||
"private": true,
|
||||
"description": "ClassHelper后端服务:REST API + Socket.IO 实时推送 + Prisma 数据访问",
|
||||
"type": "module",
|
||||
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
ALTER TABLE "Courseware" ADD COLUMN "archivePath" TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE "Courseware" ADD COLUMN "folderPath" TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE "ClientDevice" ADD COLUMN "updateProgress" TEXT NOT NULL DEFAULT '';
|
||||
CREATE TABLE "CoursewareFolder" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"ownerId" TEXT NOT NULL,
|
||||
"path" TEXT NOT NULL,
|
||||
FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
CREATE UNIQUE INDEX "CoursewareFolder_ownerId_path_key" ON "CoursewareFolder"("ownerId", "path");
|
||||
@@ -35,6 +35,7 @@ datasource db {
|
||||
/// 账号:管理员 / 教师 / ClassHelper 班级端。**学生不在这里**(见 Student)。
|
||||
model User {
|
||||
coursewares Courseware[]
|
||||
coursewareFolders CoursewareFolder[]
|
||||
id String @id @default(cuid())
|
||||
/// 登录名:教师即**工号**,班级端即班级码
|
||||
username String @unique
|
||||
@@ -530,6 +531,7 @@ model ClientDevice {
|
||||
lastIp String @default("")
|
||||
/// 客户端上报的当前状态说明(如"上课中/已隐藏",仅展示用)
|
||||
stateNote String @default("")
|
||||
updateProgress String @default("")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -730,6 +732,8 @@ model Courseware {
|
||||
category String
|
||||
extension String
|
||||
archiveDate String
|
||||
archivePath String @default("")
|
||||
folderPath String @default("")
|
||||
storagePath String @unique
|
||||
sha256 String
|
||||
createdAt DateTime @default(now())
|
||||
@@ -739,6 +743,15 @@ model Courseware {
|
||||
@@index([ownerId, createdAt])
|
||||
}
|
||||
|
||||
/// 教师自建目录与文件可见权限分开;目录本身不授予班级访问权。
|
||||
model CoursewareFolder {
|
||||
id String @id @default(cuid())
|
||||
ownerId String
|
||||
path String
|
||||
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
|
||||
@@unique([ownerId, path])
|
||||
}
|
||||
|
||||
model CoursewareVisibility {
|
||||
coursewareId String
|
||||
classId String
|
||||
|
||||
@@ -25,6 +25,24 @@ if (!dbEntry) {
|
||||
|
||||
/** 需要保证存在的列(新增迁移时在这里追加一行) */
|
||||
const COLUMNS = [
|
||||
{
|
||||
table: 'Courseware',
|
||||
column: 'archivePath',
|
||||
sql: `ALTER TABLE "Courseware" ADD COLUMN "archivePath" TEXT NOT NULL DEFAULT ''`,
|
||||
note: '上传日期归档路径',
|
||||
},
|
||||
{
|
||||
table: 'Courseware',
|
||||
column: 'folderPath',
|
||||
sql: `ALTER TABLE "Courseware" ADD COLUMN "folderPath" TEXT NOT NULL DEFAULT ''`,
|
||||
note: '自建分类目录',
|
||||
},
|
||||
{
|
||||
table: 'ClientDevice',
|
||||
column: 'updateProgress',
|
||||
sql: `ALTER TABLE "ClientDevice" ADD COLUMN "updateProgress" TEXT NOT NULL DEFAULT ''`,
|
||||
note: '设备更新进度',
|
||||
},
|
||||
{
|
||||
table: 'Schedule',
|
||||
column: 'weekParity',
|
||||
|
||||
@@ -91,7 +91,7 @@ async function request(route: string, token = tokens[0], body?: unknown, method
|
||||
}
|
||||
const bytes = Buffer.from('ClassHelper 课件流式上传验收\n'.repeat(3000));
|
||||
async function upload(
|
||||
options: Partial<{ name: string; size: number; classIds: string[]; mode: string }> = {},
|
||||
options: Partial<{ name: string; size: number; classIds: string[]; mode: string; folderPath: string }> = {},
|
||||
token = tokens[0],
|
||||
data = bytes,
|
||||
) {
|
||||
@@ -166,10 +166,42 @@ try {
|
||||
check('API 不暴露服务端路径', !('storagePath' in item));
|
||||
const stored = await prisma.courseware.findUniqueOrThrow({ where: { id: item.id } });
|
||||
check(
|
||||
'文件按日期和类型保存',
|
||||
stored.storagePath.startsWith(path.join(item.archiveDate, 'presentation')) &&
|
||||
'文件按教师、年、月、日、上传时间保存',
|
||||
/^Teachers\/课件教师甲\/\d{4}\/\d{2}\/\d{2}\/\d{2}-\d{2}-\d{2}\/第1课 演示\.pptx$/.test(
|
||||
item.archivePath || '',
|
||||
) &&
|
||||
stored.storagePath.endsWith(path.join(...item.archivePath!.split('/'))) &&
|
||||
(await fs.readFile(path.join(process.env.COURSEWARE_DIR!, stored.storagePath))).equals(bytes),
|
||||
);
|
||||
const folder = await request('/courseware/folders', tokens[0], { path: '地理/必修一' });
|
||||
check('教师可创建多层分类文件夹', folder.status === 201 && folder.data.path === '地理/必修一');
|
||||
check('文件夹按教师隔离', (await request('/courseware/folders', tokens[1])).data.length === 0);
|
||||
check(
|
||||
'拒绝目录穿越文件夹',
|
||||
(await request('/courseware/folders', tokens[0], { path: '../越界' })).status === 422,
|
||||
);
|
||||
check(
|
||||
'班级不能创建文件夹',
|
||||
(await request('/courseware/folders', classTokens[0], { path: '越权' })).status === 403,
|
||||
);
|
||||
const moved = await request(
|
||||
`/courseware/${item.id}/folder`,
|
||||
tokens[0],
|
||||
{ folderPath: folder.data.path },
|
||||
'PATCH',
|
||||
);
|
||||
check(
|
||||
'课件可分类到自建文件夹且日期归档保持稳定',
|
||||
moved.data.folderPath === folder.data.path && moved.data.archivePath === item.archivePath,
|
||||
);
|
||||
check(
|
||||
'不能借用其他教师文件夹上传',
|
||||
(await upload({ folderPath: folder.data.path, classIds: [] }, tokens[1])).status === 400,
|
||||
);
|
||||
check(
|
||||
'不能移动其他教师课件',
|
||||
(await request(`/courseware/${item.id}/folder`, tokens[1], { folderPath: '' }, 'PATCH')).status === 403,
|
||||
);
|
||||
check(
|
||||
'班级仅看到自己可见的课件',
|
||||
(await list(classTokens[0]!)).length === 1 && (await list(classTokens[1]!)).length === 0,
|
||||
@@ -266,6 +298,83 @@ try {
|
||||
});
|
||||
check('收回写权限立即禁止上传', (await upload()).status === 403);
|
||||
await prisma.permissionGroupItem.create({ data: { groupId: defaultGroup.id, key: 'courseware:write' } });
|
||||
const device = await prisma.clientDevice.findUniqueOrThrow({ where: { deviceKey: 'courseware-device-1' } });
|
||||
await prisma.clientDevice.update({ where: { id: device.id }, data: { updateTargetVersion: '2.0.0' } });
|
||||
const updateProgress = {
|
||||
version: '2.0.0',
|
||||
stage: 'downloading',
|
||||
downloadedBytes: 50,
|
||||
totalBytes: 100,
|
||||
percent: 50,
|
||||
error: '',
|
||||
};
|
||||
check(
|
||||
'客户端可上报更新下载进度',
|
||||
(
|
||||
await request('/clients/update-progress', classTokens[0], {
|
||||
deviceKey: device.deviceKey,
|
||||
progress: updateProgress,
|
||||
})
|
||||
).status === 200,
|
||||
);
|
||||
check(
|
||||
'其他班级不能冒报设备更新进度',
|
||||
(
|
||||
await request('/clients/update-progress', classTokens[1], {
|
||||
deviceKey: device.deviceKey,
|
||||
progress: updateProgress,
|
||||
})
|
||||
).status === 403,
|
||||
);
|
||||
await request('/clients/update-progress', classTokens[0], {
|
||||
deviceKey: device.deviceKey,
|
||||
progress: { ...updateProgress, stage: 'installing', percent: 100 },
|
||||
});
|
||||
await request('/clients/update-progress', classTokens[0], {
|
||||
deviceKey: device.deviceKey,
|
||||
progress: updateProgress,
|
||||
});
|
||||
let state = await prisma.clientDevice.findUniqueOrThrow({ where: { id: device.id } });
|
||||
check('迟到的下载状态不覆盖安装中状态', JSON.parse(state.updateProgress).stage === 'installing');
|
||||
await request('/clients/heartbeat', classTokens[0], {
|
||||
deviceKey: device.deviceKey,
|
||||
clientVersion: '2.0.0',
|
||||
});
|
||||
state = await prisma.clientDevice.findUniqueOrThrow({ where: { id: device.id } });
|
||||
check(
|
||||
'新版上线心跳确认完成并记录实际版本',
|
||||
JSON.parse(state.updateProgress).stage === 'completed' &&
|
||||
JSON.parse(state.updateProgress).version === '2.0.0',
|
||||
);
|
||||
await prisma.clientDevice.update({
|
||||
where: { id: device.id },
|
||||
data: { updateTargetVersion: '', updateProgress: '' },
|
||||
});
|
||||
await request('/clients/update-progress', classTokens[0], {
|
||||
deviceKey: device.deviceKey,
|
||||
progress: updateProgress,
|
||||
});
|
||||
state = await prisma.clientDevice.findUniqueOrThrow({ where: { id: device.id } });
|
||||
check('取消目标后忽略旧任务进度', state.updateProgress === '');
|
||||
const teacherDeviceKey = 'courseware-teacher-device';
|
||||
await request('/clients/heartbeat', tokens[0], { deviceKey: teacherDeviceKey, clientVersion: '1.0.0' });
|
||||
let teacherDevice = await prisma.clientDevice.findUniqueOrThrow({ where: { deviceKey: teacherDeviceKey } });
|
||||
check('教师电脑设备不绑定班级', teacherDevice.classId === null);
|
||||
await prisma.clientDevice.update({
|
||||
where: { id: teacherDevice.id },
|
||||
data: { updateTargetVersion: '2.0.0' },
|
||||
});
|
||||
check(
|
||||
'教师电脑可上报自己的更新进度',
|
||||
(
|
||||
await request('/clients/update-progress', tokens[0], {
|
||||
deviceKey: teacherDeviceKey,
|
||||
progress: { ...updateProgress, version: '2.0.0' },
|
||||
})
|
||||
).status === 200,
|
||||
);
|
||||
teacherDevice = await prisma.clientDevice.findUniqueOrThrow({ where: { deviceKey: teacherDeviceKey } });
|
||||
check('教师电脑更新进度已记录', JSON.parse(teacherDevice.updateProgress).stage === 'downloading');
|
||||
const snapshot = await exportSnapshot(prisma, 'sqlite');
|
||||
check(
|
||||
'备份包含课件、可见范围、推送和回执',
|
||||
@@ -275,7 +384,13 @@ try {
|
||||
snapshot.data.CoursewareVisibility.length > 0,
|
||||
);
|
||||
const oldSnapshot = structuredClone(snapshot);
|
||||
for (const key of ['Courseware', 'CoursewarePush', 'CoursewareReceipt', 'CoursewareVisibility'])
|
||||
for (const key of [
|
||||
'Courseware',
|
||||
'CoursewareFolder',
|
||||
'CoursewarePush',
|
||||
'CoursewareReceipt',
|
||||
'CoursewareVisibility',
|
||||
])
|
||||
delete (oldSnapshot.data as Record<string, unknown>)[key];
|
||||
assertValidSnapshot(oldSnapshot);
|
||||
check('允许读取升级前的旧版快照', true);
|
||||
@@ -319,7 +434,9 @@ try {
|
||||
await restoreSnapshot(prisma, 'sqlite', snapshot);
|
||||
check(
|
||||
'恢复数据库后保留课件共享和回执数据',
|
||||
(await prisma.courseware.count()) === 3 && (await prisma.coursewareReceipt.count()) === 1,
|
||||
(await prisma.courseware.count()) === 3 &&
|
||||
(await prisma.coursewareReceipt.count()) === 1 &&
|
||||
(await prisma.coursewareFolder.count()) === 1,
|
||||
);
|
||||
console.log(`=== 课件隔离验收:${count}/${count} 项通过 ===`);
|
||||
} finally {
|
||||
|
||||
@@ -3223,6 +3223,7 @@ async function main() {
|
||||
'AppSetting',
|
||||
'ClientDevice',
|
||||
'Courseware',
|
||||
'CoursewareFolder',
|
||||
'CoursewareVisibility',
|
||||
'CoursewarePush',
|
||||
'CoursewareReceipt',
|
||||
|
||||
@@ -421,6 +421,27 @@ try {
|
||||
assert.equal(rows[0].weekCountDivTotal, 4);
|
||||
assert.equal(rows[0].layout.points.length, 4);
|
||||
});
|
||||
const archive = await request(`/class-plans/profile?classId=${cls.id}`);
|
||||
const beforeArchive = imported.find((p: { name: string }) => p.name === '上游四周');
|
||||
const exportedPlan = archive.ClassPlans[beforeArchive.id];
|
||||
check('完整档案导出保留四种时间点、空课位置与轮换规则', () => {
|
||||
assert.equal(archive.TimeLayouts[exportedPlan.TimeLayoutId].Layouts.length, 4);
|
||||
assert.equal(exportedPlan.TimeRule.WeekCountDivTotal, 4);
|
||||
assert.equal(
|
||||
exportedPlan.Classes.length,
|
||||
beforeArchive.layout.points.filter((p: { timeType: string }) => p.timeType === 'LESSON').length,
|
||||
);
|
||||
});
|
||||
await request('/class-plans/import/profile', 'POST', { classId: cls.id, payload: archive }, undefined, 201);
|
||||
const afterArchive = await request(`/class-plans?classId=${cls.id}`);
|
||||
check('导出档案可重新导入且保留课程与周次范围', () => {
|
||||
const restored = afterArchive.find(
|
||||
(p: { id: string; name: string }) => p.name === '上游四周' && p.id !== beforeArchive.id,
|
||||
);
|
||||
assert.equal(restored.weekStart, beforeArchive.weekStart);
|
||||
assert.equal(restored.weekEnd, beforeArchive.weekEnd);
|
||||
assert.equal(restored.entries.length, beforeArchive.entries.length);
|
||||
});
|
||||
let enabled = false;
|
||||
const os = {
|
||||
platform: 'win32',
|
||||
|
||||
@@ -60,6 +60,7 @@ export const SNAPSHOT_TABLES = [
|
||||
'Grade',
|
||||
'ClientDevice',
|
||||
'Courseware',
|
||||
'CoursewareFolder',
|
||||
'CoursewareVisibility',
|
||||
'CoursewarePush',
|
||||
'CoursewareReceipt',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { importProfile } from './profile-import.service.js';
|
||||
import { exportProfile } from './profile-export.service.js';
|
||||
import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { prisma } from '../../lib/db.js';
|
||||
@@ -82,6 +83,9 @@ layouts.delete(
|
||||
|
||||
const plans = Router();
|
||||
plans.use(authenticate());
|
||||
plans.get('/profile', validate({ query: z.object({ classId: z.string().min(1) }) }), async (req, res) =>
|
||||
sendOk(res, await exportProfile(getAuthUser(req), validatedQuery<{ classId: string }>(req).classId)),
|
||||
);
|
||||
plans.post(
|
||||
'/import/profile',
|
||||
requirePermission('schedule:write'),
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { prisma } from '../../lib/db.js';
|
||||
import { assertClassAccess } from '../../lib/access.js';
|
||||
import type { TokenPayload } from '../../lib/jwt.js';
|
||||
|
||||
/** 按时间点外键生成完整档案,空课、停用课程与非上课时间点均保留。 */
|
||||
export async function exportProfile(user: TokenPayload, classId: string) {
|
||||
await assertClassAccess(user, classId);
|
||||
const [cls, layouts, plans, subjects, groups, scheduled, setting] = await Promise.all([
|
||||
prisma.class.findUniqueOrThrow({ where: { id: classId } }),
|
||||
prisma.timeLayout.findMany({
|
||||
where: { OR: [{ classId }, { classId: null }] },
|
||||
include: { points: { orderBy: { position: 'asc' } } },
|
||||
}),
|
||||
prisma.classPlan.findMany({ where: { classId }, include: { entries: true } }),
|
||||
prisma.course.findMany({ where: { classId } }),
|
||||
prisma.classPlanGroup.findMany({ where: { classId } }),
|
||||
prisma.scheduledClassPlan.findMany({ where: { classId } }),
|
||||
prisma.appSetting.findUnique({ where: { key: `schedule.group.${classId}` } }),
|
||||
]);
|
||||
let selection: {
|
||||
selected?: string;
|
||||
temporary?: string;
|
||||
expireTime?: string;
|
||||
temporaryPlan?: string;
|
||||
temporaryPlanDate?: string;
|
||||
} = {};
|
||||
try {
|
||||
selection = JSON.parse(setting?.value || '{}');
|
||||
} catch {
|
||||
/* 使用默认群组 */
|
||||
}
|
||||
const types = ['LESSON', 'BREAK', 'DIVIDER', 'ACTION'];
|
||||
const actions = (value: string) => {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
return {
|
||||
Name: cls.name,
|
||||
Subjects: Object.fromEntries(
|
||||
subjects.map((s) => [s.id, { Name: s.name, Initial: s.initial, IsOutDoor: s.isOutDoor }]),
|
||||
),
|
||||
TimeLayouts: Object.fromEntries(
|
||||
layouts.map((l) => [
|
||||
l.id,
|
||||
{
|
||||
Name: l.name,
|
||||
IsOverlay: l.isOverlay,
|
||||
OverlaySourceId: l.overlaySourceId,
|
||||
Layouts: l.points.map((p) => ({
|
||||
Name: p.name,
|
||||
StartTime: p.startTime,
|
||||
EndTime: p.endTime,
|
||||
TimeType: types.indexOf(p.timeType),
|
||||
IsHideDefault: p.isHideDefault,
|
||||
DefaultClassId: p.defaultClassId,
|
||||
BreakName: p.breakName,
|
||||
ActionSet: actions(p.actionSet),
|
||||
})),
|
||||
},
|
||||
]),
|
||||
),
|
||||
ClassPlans: Object.fromEntries(
|
||||
plans.map((p) => [
|
||||
p.id,
|
||||
{
|
||||
Name: p.name,
|
||||
TimeLayoutId: p.timeLayoutId,
|
||||
AssociatedGroup: p.associatedGroup,
|
||||
TimeRule: {
|
||||
WeekDay: p.weekday,
|
||||
WeekCountDiv: p.weekCountDiv,
|
||||
WeekCountDivTotal: p.weekCountDivTotal,
|
||||
},
|
||||
WeekStart: p.weekStart,
|
||||
WeekEnd: p.weekEnd,
|
||||
IsEnabled: p.isEnabled,
|
||||
IsOverlay: p.isOverlay,
|
||||
OverlaySourceId: p.overlaySourceId,
|
||||
OverlayDate: p.overlayDate,
|
||||
Classes: (layouts.find((l) => l.id === p.timeLayoutId)?.points || [])
|
||||
.filter((point) => point.timeType === 'LESSON')
|
||||
.map((point) => {
|
||||
const e = p.entries.find((entry) => entry.timeLayoutItemId === point.id);
|
||||
return {
|
||||
SubjectId: e?.courseId || '',
|
||||
IsEnabled: e?.isEnabled ?? true,
|
||||
IsChangedClass: e?.isChangedClass ?? false,
|
||||
Location: e?.location || '',
|
||||
};
|
||||
}),
|
||||
},
|
||||
]),
|
||||
),
|
||||
ClassPlanGroups: Object.fromEntries(groups.map((g) => [g.id, { Name: g.name, IsGlobal: g.isGlobal }])),
|
||||
SelectedClassPlanGroupId: selection.selected || groups.find((g) => g.isDefault)?.id || '',
|
||||
TempClassPlanGroupId: selection.temporary || '',
|
||||
IsTempClassPlanGroupEnabled: !!selection.temporary,
|
||||
TempClassPlanGroupExpireTime: selection.expireTime,
|
||||
TempClassPlanId: selection.temporaryPlan,
|
||||
TempClassPlanSetupTime: selection.temporaryPlanDate,
|
||||
OrderedSchedules: Object.fromEntries(scheduled.map((s) => [s.date, { ClassPlanId: s.classPlanId }])),
|
||||
};
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { assertCanManageSchedule } from '../../lib/access.js';
|
||||
import { ApiError } from '../../lib/http.js';
|
||||
import type { TokenPayload } from '../../lib/jwt.js';
|
||||
import { notifyScheduleChanged } from './class-plans.service.js';
|
||||
import { getMaxTermWeeks } from '../../lib/term.js';
|
||||
const record = z.record(z.string(), z.unknown());
|
||||
function object(value: unknown): Record<string, unknown> {
|
||||
const parsed = record.safeParse(value);
|
||||
@@ -39,6 +40,7 @@ export async function importProfile(user: TokenPayload, classId: string, payload
|
||||
if (Object.keys(plans).length > 500 || Object.keys(layouts).length > 100)
|
||||
throw ApiError.badRequest('导入课表过多');
|
||||
const cls = await prisma.class.findUniqueOrThrow({ where: { id: classId } });
|
||||
const maxWeeks = await getMaxTermWeeks();
|
||||
const warnings: string[] = [];
|
||||
const stable = (kind: string, id: string) =>
|
||||
`profile-${kind}-${createHash('sha256')
|
||||
@@ -183,6 +185,14 @@ export async function importProfile(user: TokenPayload, classId: string, payload
|
||||
)
|
||||
throw ApiError.badRequest('导入启用规则不合法(支持1–9周轮换)');
|
||||
const previous = await tx.classPlan.findUnique({ where: { id: stable('plan', id) } });
|
||||
const range = z
|
||||
.object({
|
||||
start: z.number().int().min(1).max(maxWeeks),
|
||||
end: z.number().int().min(1).max(maxWeeks),
|
||||
})
|
||||
.refine((r) => r.start <= r.end)
|
||||
.safeParse({ start: p.WeekStart ?? 1, end: Math.min(Number(p.WeekEnd ?? maxWeeks), maxWeeks) });
|
||||
if (!range.success) throw ApiError.badRequest('导入课表生效周次无效');
|
||||
const data = {
|
||||
classId,
|
||||
name: String(p.Name || '导入课表').slice(0, 64),
|
||||
@@ -191,8 +201,11 @@ export async function importProfile(user: TokenPayload, classId: string, payload
|
||||
weekday,
|
||||
weekCountDiv,
|
||||
weekCountDivTotal,
|
||||
weekStart: range.data.start,
|
||||
weekEnd: range.data.end,
|
||||
isEnabled: p.IsEnabled !== false,
|
||||
isOverlay: p.IsOverlay === true,
|
||||
overlayDate: typeof p.OverlayDate === 'string' && isDayKey(p.OverlayDate) ? p.OverlayDate : null,
|
||||
};
|
||||
const plan = await tx.classPlan.upsert({
|
||||
where: { id: stable('plan', id) },
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Router } from 'express';
|
||||
import type { ClientUpdateProgressDto } from '@classhelper/shared';
|
||||
import { sendCreated, sendOk } from '../../lib/http.js';
|
||||
import { idParamSchema } from '../../lib/schemas.js';
|
||||
import { authenticate, getAuthUser, requireAnyPermission, requirePermission } from '../../middleware/auth.js';
|
||||
@@ -7,6 +8,7 @@ import { defineModule } from '../module.types.js';
|
||||
import {
|
||||
clientCommandAckSchema,
|
||||
clientHeartbeatSchema,
|
||||
clientProgressSchema,
|
||||
createClientCommandSchema,
|
||||
listClientDevicesQuerySchema,
|
||||
pushClientUpdateSchema,
|
||||
@@ -20,6 +22,14 @@ import * as clientService from './clients.service.js';
|
||||
|
||||
const router = Router();
|
||||
router.use(authenticate());
|
||||
router.post('/update-progress', validate({ body: clientProgressSchema }), async (req, res) => {
|
||||
const input = validatedBody<{
|
||||
deviceKey: string;
|
||||
progress: ClientUpdateProgressDto;
|
||||
}>(req);
|
||||
await clientService.reportUpdateProgress(getAuthUser(req), input.deviceKey, input.progress);
|
||||
sendOk(res, null);
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/clients/heartbeat - 客户端每 30 秒上报一次
|
||||
|
||||
@@ -11,6 +11,24 @@ const clientCommandKindSchema = z.enum(
|
||||
|
||||
/** 设备标识(客户端主进程生成并持久化的 UUID) */
|
||||
const deviceKeySchema = z.string().trim().min(8, '设备标识不合法').max(80, '设备标识不合法');
|
||||
export const updateProgressSchema = z.object({
|
||||
version: z.string().trim().max(40),
|
||||
stage: z.enum([
|
||||
'idle',
|
||||
'downloading',
|
||||
'verifying',
|
||||
'ready',
|
||||
'waiting',
|
||||
'installing',
|
||||
'failed',
|
||||
'completed',
|
||||
]),
|
||||
downloadedBytes: z.number().int().min(0),
|
||||
totalBytes: z.number().int().min(0),
|
||||
percent: z.number().min(0).max(100).nullable(),
|
||||
error: z.string().max(1000),
|
||||
});
|
||||
export const clientProgressSchema = z.object({ deviceKey: deviceKeySchema, progress: updateProgressSchema });
|
||||
|
||||
/** `POST /api/clients/heartbeat`:客户端每 30 秒一次的上报 */
|
||||
export const clientHeartbeatSchema = z.object({
|
||||
@@ -22,6 +40,7 @@ export const clientHeartbeatSchema = z.object({
|
||||
autoUpdate: z.boolean().optional(),
|
||||
pendingVersion: z.string().trim().max(40).nullable().optional(),
|
||||
stateNote: z.string().trim().max(120).optional(),
|
||||
updateProgress: updateProgressSchema.optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,6 +2,8 @@ import {
|
||||
CLIENT_COMMAND_EXPIRE_MS,
|
||||
CLIENT_COMMAND_LABELS,
|
||||
CLIENT_ONLINE_WINDOW_MS,
|
||||
compareVersions,
|
||||
type ClientUpdateProgressDto,
|
||||
type ClientUpdateMode,
|
||||
type ClientCommandAckRequest,
|
||||
type ClientCommandDto,
|
||||
@@ -47,6 +49,7 @@ type DeviceRow = {
|
||||
lastHeartbeatAt: Date | null;
|
||||
lastIp: string;
|
||||
stateNote: string;
|
||||
updateProgress: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
class?: { id: string; name: string } | null;
|
||||
@@ -153,6 +156,25 @@ export async function heartbeat(
|
||||
})) as unknown as DeviceRow;
|
||||
|
||||
const targetVersion = device.updateTargetVersion.trim();
|
||||
if (targetVersion && input.clientVersion && compareVersions(input.clientVersion, targetVersion) >= 0) {
|
||||
await prisma.clientDevice.updateMany({
|
||||
where: { id: device.id, updateTargetVersion: targetVersion },
|
||||
data: {
|
||||
pendingVersion: '',
|
||||
updateProgress: JSON.stringify({
|
||||
version: input.clientVersion,
|
||||
stage: 'completed',
|
||||
downloadedBytes: 0,
|
||||
totalBytes: 0,
|
||||
percent: 100,
|
||||
error: '',
|
||||
updatedAt: now.toISOString(),
|
||||
}),
|
||||
},
|
||||
});
|
||||
} else if (input.updateProgress && input.updateProgress.stage !== 'idle') {
|
||||
await reportUpdateProgress(user, input.deviceKey, input.updateProgress);
|
||||
}
|
||||
const [command, update] = await Promise.all([
|
||||
findPendingCommand(device.id, now),
|
||||
targetVersion ? clientReleases.resolveUpdateInfo(targetVersion) : Promise.resolve(null),
|
||||
@@ -176,6 +198,46 @@ export async function heartbeat(
|
||||
};
|
||||
}
|
||||
|
||||
export async function reportUpdateProgress(
|
||||
user: TokenPayload,
|
||||
deviceKey: string,
|
||||
progress: ClientUpdateProgressDto,
|
||||
) {
|
||||
const device = await prisma.clientDevice.findUnique({ where: { deviceKey } });
|
||||
// 教师/管理员电脑不绑定班级(会话与设备两侧都是 null),同样允许上报自己的进度。
|
||||
if (!device || device.classId !== resolveClassId(user)) throw ApiError.forbidden('设备不属于当前班级');
|
||||
// 已取消或更换目标的迟到进度不得覆盖当前任务。
|
||||
if (progress.version !== device.updateTargetVersion || progress.stage === 'completed') return;
|
||||
const previous = readUpdateProgress(device.updateProgress);
|
||||
if (previous?.version === progress.version && previous.stage === 'completed') return;
|
||||
const stages = ['idle', 'downloading', 'verifying', 'ready', 'waiting', 'installing', 'completed'];
|
||||
if (
|
||||
previous?.version === progress.version &&
|
||||
previous.stage !== 'failed' &&
|
||||
progress.stage !== 'failed' &&
|
||||
!progress.error &&
|
||||
stages.indexOf(progress.stage) < stages.indexOf(previous.stage)
|
||||
)
|
||||
return;
|
||||
await prisma.clientDevice.updateMany({
|
||||
where: {
|
||||
id: device.id,
|
||||
updateTargetVersion: progress.version,
|
||||
updateProgress: device.updateProgress,
|
||||
},
|
||||
data: {
|
||||
updateProgress: JSON.stringify({ ...progress, updatedAt: new Date().toISOString() }),
|
||||
},
|
||||
});
|
||||
}
|
||||
function readUpdateProgress(raw: string): ClientUpdateProgressDto | null {
|
||||
try {
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** POST /api/clients/commands/:id/ack - 客户端执行完远控命令后的回执 */
|
||||
export async function ackCommand(
|
||||
commandId: string,
|
||||
@@ -263,6 +325,7 @@ export async function listDevices(
|
||||
lastHeartbeatAt: heartbeat ? heartbeat.toISOString() : null,
|
||||
lastIp: row.lastIp,
|
||||
stateNote: row.stateNote,
|
||||
updateProgress: readUpdateProgress(row.updateProgress),
|
||||
online: Boolean(heartbeat && now.getTime() - heartbeat.getTime() < CLIENT_ONLINE_WINDOW_MS),
|
||||
pendingCommand: pending ? toCommandDto(pending) : null,
|
||||
lastCommand: last ? toCommandDto(last) : null,
|
||||
@@ -390,6 +453,7 @@ export async function pushUpdate(
|
||||
where: { id: device.id },
|
||||
data: {
|
||||
updateTargetVersion: target,
|
||||
updateProgress: '',
|
||||
updateMode: clear ? 'idle' : input.mode,
|
||||
updateNotBefore:
|
||||
!clear && input.mode === 'after-school'
|
||||
|
||||
@@ -10,7 +10,13 @@ import {
|
||||
} 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 {
|
||||
coursewareDeviceQuery,
|
||||
coursewareScopeSchema,
|
||||
coursewareUploadQuery,
|
||||
coursewareFolderSchema,
|
||||
coursewareMoveSchema,
|
||||
} from './courseware.schemas.js';
|
||||
import type { CoursewareUploadRequest } from '@classhelper/shared';
|
||||
import * as service from './courseware.service.js';
|
||||
const router = Router();
|
||||
@@ -22,6 +28,26 @@ router.get('/', requireStaffPermission('courseware:read'), async (req, res) =>
|
||||
router.get('/classes', ...staff, async (req, res) =>
|
||||
sendOk(res, await service.teachingClasses(getAuthUser(req))),
|
||||
);
|
||||
router.get('/folders', ...staff, async (req, res) =>
|
||||
sendOk(res, await service.listFolders(getAuthUser(req))),
|
||||
);
|
||||
router.post('/folders', ...staff, validate({ body: coursewareFolderSchema }), async (req, res) =>
|
||||
sendCreated(res, await service.createFolder(getAuthUser(req), validatedBody<{ path: string }>(req).path)),
|
||||
);
|
||||
router.patch(
|
||||
'/:id/folder',
|
||||
...staff,
|
||||
validate({ params: idParamSchema, body: coursewareMoveSchema }),
|
||||
async (req, res) =>
|
||||
sendOk(
|
||||
res,
|
||||
await service.moveCourseware(
|
||||
getAuthUser(req),
|
||||
validatedParams<{ id: string }>(req).id,
|
||||
validatedBody<{ folderPath: string }>(req).folderPath,
|
||||
),
|
||||
),
|
||||
);
|
||||
router.get(
|
||||
'/transfers',
|
||||
requireRole('CLASS_DEVICE'),
|
||||
|
||||
@@ -2,6 +2,27 @@ 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) });
|
||||
const folderPath = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(240)
|
||||
.refine(
|
||||
(value) =>
|
||||
value
|
||||
.split('/')
|
||||
.every(
|
||||
(part) =>
|
||||
part.length > 0 &&
|
||||
part.length <= 60 &&
|
||||
!/[<>:"\\|?*\u0000-\u001f]/.test(part) &&
|
||||
!/[. ]$/.test(part) &&
|
||||
!/^(con|prn|aux|nul|com[0-9]|lpt[0-9])(?:\.|$)/i.test(part),
|
||||
),
|
||||
'文件夹名称无效,请使用 / 分隔子文件夹',
|
||||
);
|
||||
export const coursewareFolderSchema = z.object({ path: folderPath });
|
||||
export const coursewareMoveSchema = z.object({ folderPath: folderPath.or(z.literal('')) });
|
||||
export const coursewareUploadSchema = coursewareScopeSchema
|
||||
.extend({
|
||||
name: z
|
||||
@@ -15,6 +36,7 @@ export const coursewareUploadSchema = coursewareScopeSchema
|
||||
),
|
||||
size: z.number().int().min(1).max(COURSEWARE_MAX_BYTES),
|
||||
mode: z.enum(['cloud', 'push']),
|
||||
folderPath: folderPath.or(z.literal('')).optional(),
|
||||
})
|
||||
.refine((input) => input.mode !== 'push' || input.classIds.length > 0, '推送时请选择授课班级');
|
||||
export const coursewareUploadQuery = z.object({
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { Request } from 'express';
|
||||
import {
|
||||
COURSEWARE_MAX_BYTES,
|
||||
coursewareCategory,
|
||||
coursewareArchivePath,
|
||||
dayKeyLocal,
|
||||
SOCKET_EVENTS,
|
||||
type CoursewareDto,
|
||||
@@ -37,6 +38,8 @@ function dto(row: RecordWithRelations): CoursewareDto {
|
||||
category: row.category as CoursewareDto['category'],
|
||||
extension: row.extension,
|
||||
archiveDate: row.archiveDate,
|
||||
archivePath: row.archivePath || coursewareArchivePath(row.owner.name, row.createdAt, row.name),
|
||||
folderPath: row.folderPath,
|
||||
sha256: row.sha256,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
teacher: row.owner,
|
||||
@@ -104,13 +107,18 @@ export async function listCourseware(user: TokenPayload) {
|
||||
}
|
||||
export async function uploadCourseware(user: TokenPayload, input: CoursewareUploadRequest, req: Request) {
|
||||
const classIds = await validateClasses(user, input.classIds);
|
||||
await assertFolder(user, input.folderPath || '');
|
||||
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 uploadedAt = new Date();
|
||||
const owner = await prisma.user.findUniqueOrThrow({ where: { id: user.sub }, select: { name: true } });
|
||||
const archivePath = coursewareArchivePath(owner.name, uploadedAt, input.name);
|
||||
const category = coursewareCategory(input.name),
|
||||
archiveDate = dayKeyLocal(new Date());
|
||||
archiveDate = dayKeyLocal(uploadedAt);
|
||||
const extension = path.extname(input.name).slice(1).toLowerCase();
|
||||
const relative = path.join(archiveDate, category, randomUUID());
|
||||
// UUID 隔离同名教师或同秒上传;对外归档路径始终使用上传时的教师名和时间。
|
||||
const relative = path.join(randomUUID(), archivePath);
|
||||
const file = path.join(coursewareRoot, relative),
|
||||
partial = `${file}.part`;
|
||||
await fsp.mkdir(path.dirname(file), { recursive: true });
|
||||
@@ -142,6 +150,9 @@ export async function uploadCourseware(user: TokenPayload, input: CoursewareUplo
|
||||
category,
|
||||
extension,
|
||||
archiveDate,
|
||||
archivePath,
|
||||
folderPath: input.folderPath || '',
|
||||
createdAt: uploadedAt,
|
||||
storagePath: relative,
|
||||
sha256: hash.digest('hex'),
|
||||
visibility: { create: classIds.map((classId) => ({ classId })) },
|
||||
@@ -156,6 +167,35 @@ export async function uploadCourseware(user: TokenPayload, input: CoursewareUplo
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
async function assertFolder(user: TokenPayload, folderPath: string) {
|
||||
if (
|
||||
folderPath &&
|
||||
!(await prisma.coursewareFolder.findUnique({
|
||||
where: { ownerId_path: { ownerId: user.sub, path: folderPath } },
|
||||
}))
|
||||
)
|
||||
throw ApiError.badRequest('请选择自己创建的文件夹');
|
||||
}
|
||||
export async function listFolders(user: TokenPayload) {
|
||||
const rows = await prisma.coursewareFolder.findMany({
|
||||
where: { ownerId: user.sub },
|
||||
orderBy: { path: 'asc' },
|
||||
});
|
||||
return rows.map((row) => ({ id: row.id, path: row.path }));
|
||||
}
|
||||
export async function createFolder(user: TokenPayload, folderPath: string) {
|
||||
const row = await prisma.coursewareFolder.upsert({
|
||||
where: { ownerId_path: { ownerId: user.sub, path: folderPath } },
|
||||
create: { ownerId: user.sub, path: folderPath },
|
||||
update: {},
|
||||
});
|
||||
return { id: row.id, path: row.path };
|
||||
}
|
||||
export async function moveCourseware(user: TokenPayload, id: string, folderPath: string) {
|
||||
await owned(user, id);
|
||||
await assertFolder(user, folderPath);
|
||||
return dto(await prisma.courseware.update({ where: { id }, data: { folderPath }, include }));
|
||||
}
|
||||
export async function setVisibility(user: TokenPayload, id: string, ids: string[]) {
|
||||
await owned(user, id);
|
||||
const classIds = await validateClasses(user, ids);
|
||||
|
||||
Reference in new issue
Block a user