feat: integrate learning module
This commit is contained in:
340
electron/services/learning-course-library.ts
Normal file
340
electron/services/learning-course-library.ts
Normal file
@@ -0,0 +1,340 @@
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { mkdir, open, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import { dirname, join } from 'node:path';
|
||||
import AdmZip from 'adm-zip';
|
||||
import type { InstalledLearningCourse, LearningClassroomPayload, LearningCourse } from '../../shared/learning';
|
||||
import { WORKS_SQUARE_CONFIG } from '../api/works-config';
|
||||
import { proxyAwareFetch } from '../utils/proxy-fetch';
|
||||
import { getValidWorksSquareAccessToken } from './works-square-session';
|
||||
import { readLearningPackageClassroom } from './learning-package-consumer';
|
||||
import { registerLearningCoursePackage } from './learning-player-server';
|
||||
|
||||
const COURSE_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
|
||||
const MODULE_ID_PATTERN = /^(?!\.{1,2}$)[A-Za-z0-9._-]{1,128}$/;
|
||||
const SHA256_PATTERN = /^[0-9a-f]{64}$/;
|
||||
|
||||
export class LearningCourseLibraryError extends Error {
|
||||
constructor(readonly code: string, message: string) {
|
||||
super(message);
|
||||
this.name = 'LearningCourseLibraryError';
|
||||
}
|
||||
}
|
||||
|
||||
type Dependencies = {
|
||||
rootDirectory: string;
|
||||
fetchImpl?: typeof fetch;
|
||||
getAccessToken?: typeof getValidWorksSquareAccessToken;
|
||||
apiBaseUrl?: string;
|
||||
now?: () => Date;
|
||||
};
|
||||
|
||||
function courseDirectory(root: string, course: LearningCourse): string {
|
||||
return join(root, 'learning', 'courses', course.id, course.contentHash);
|
||||
}
|
||||
|
||||
function assertCourseId(courseId: string): void {
|
||||
if (!COURSE_ID_PATTERN.test(courseId)) {
|
||||
throw new LearningCourseLibraryError('LEARNING_COURSE_ID_INVALID', '课程编号无效');
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function parseCourse(payload: unknown): LearningCourse {
|
||||
const capabilities = isRecord(payload) && isRecord(payload.capabilities) ? payload.capabilities : null;
|
||||
const hasSingleCapabilities = Boolean(capabilities
|
||||
&& (capabilities.modular === undefined || capabilities.modular === false)
|
||||
&& Array.isArray(capabilities.sceneKinds)
|
||||
&& capabilities.sceneKinds.every((kind) => ['slide', 'interactive', 'quiz', 'pbl'].includes(String(kind)))
|
||||
&& typeof capabilities.hasAudio === 'boolean'
|
||||
&& typeof capabilities.hasWhiteboard === 'boolean'
|
||||
&& typeof capabilities.hasAgent === 'boolean');
|
||||
const hasLargeCapabilities = Boolean(capabilities
|
||||
&& capabilities.modular === true
|
||||
&& capabilities.orderedModules === true
|
||||
&& capabilities.productionStagePerModule === true);
|
||||
const modules = isRecord(payload) && Array.isArray(payload.modules) ? payload.modules : null;
|
||||
const moduleIds = modules?.flatMap((module) => isRecord(module) && typeof module.moduleId === 'string'
|
||||
? [module.moduleId]
|
||||
: []) ?? [];
|
||||
if (!isRecord(payload)
|
||||
|| typeof payload.id !== 'string'
|
||||
|| !COURSE_ID_PATTERN.test(payload.id)
|
||||
|| !['user_single', 'ops_large'].includes(String(payload.origin))
|
||||
|| !['generating', 'ready', 'published', 'failed', 'archived'].includes(String(payload.status))
|
||||
|| typeof payload.title !== 'string'
|
||||
|| !payload.title.trim()
|
||||
|| (payload.summary !== null && typeof payload.summary !== 'string')
|
||||
|| (payload.language !== null && typeof payload.language !== 'string')
|
||||
|| typeof payload.contentHash !== 'string'
|
||||
|| !SHA256_PATTERN.test(payload.contentHash)
|
||||
|| typeof payload.archiveSha256 !== 'string'
|
||||
|| !SHA256_PATTERN.test(payload.archiveSha256)
|
||||
|| typeof payload.archiveBytes !== 'number'
|
||||
|| !Number.isSafeInteger(payload.archiveBytes)
|
||||
|| payload.archiveBytes <= 0
|
||||
|| !Number.isSafeInteger(payload.formatVersion)
|
||||
|| Number(payload.formatVersion) < 1
|
||||
|| typeof payload.minPlayerVersion !== 'string'
|
||||
|| !payload.minPlayerVersion.trim()
|
||||
|| !Number.isSafeInteger(payload.sceneCount)
|
||||
|| Number(payload.sceneCount) < 0
|
||||
|| (!hasSingleCapabilities && !hasLargeCapabilities)
|
||||
|| typeof payload.createdAt !== 'string'
|
||||
|| (payload.publishedAt !== null && typeof payload.publishedAt !== 'string')
|
||||
|| (modules && new Set(moduleIds).size !== modules.length)
|
||||
|| (payload.modules !== undefined && (!modules || modules.some((module) => (
|
||||
!isRecord(module)
|
||||
|| typeof module.moduleId !== 'string'
|
||||
|| !MODULE_ID_PATTERN.test(module.moduleId)
|
||||
|| typeof module.title !== 'string'
|
||||
|| (module.summary !== null && typeof module.summary !== 'string')
|
||||
|| typeof module.sceneCount !== 'number'
|
||||
|| !Number.isSafeInteger(module.sceneCount)
|
||||
|| module.sceneCount < 0
|
||||
|| typeof module.contentHash !== 'string'
|
||||
|| !SHA256_PATTERN.test(module.contentHash)
|
||||
))))) {
|
||||
throw new LearningCourseLibraryError('LEARNING_COURSE_INVALID', '课程信息不完整');
|
||||
}
|
||||
return payload as LearningCourse;
|
||||
}
|
||||
|
||||
function parseInstalled(payload: unknown): InstalledLearningCourse | null {
|
||||
if (!isRecord(payload)
|
||||
|| payload.schemaVersion !== 1
|
||||
|| typeof payload.archivePath !== 'string'
|
||||
|| typeof payload.installedAt !== 'string') return null;
|
||||
try {
|
||||
return { ...payload, course: parseCourse(payload.course) } as InstalledLearningCourse;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function safeJson(response: Response): Promise<unknown> {
|
||||
return response.json().catch(() => null);
|
||||
}
|
||||
|
||||
function upstreamError(payload: unknown, status: number): LearningCourseLibraryError {
|
||||
const detail = isRecord(payload) && isRecord(payload.detail) ? payload.detail : null;
|
||||
return new LearningCourseLibraryError(
|
||||
detail && typeof detail.code === 'string' ? detail.code : `LEARNING_HTTP_${status}`,
|
||||
detail && typeof detail.message === 'string' ? detail.message : '学习服务暂时不可用',
|
||||
);
|
||||
}
|
||||
|
||||
async function streamVerifiedArchive(
|
||||
response: Response,
|
||||
temporaryPath: string,
|
||||
course: LearningCourse,
|
||||
): Promise<void> {
|
||||
if (!response.body) {
|
||||
throw new LearningCourseLibraryError('LEARNING_DOWNLOAD_EMPTY', '课程包内容为空');
|
||||
}
|
||||
const handle = await open(temporaryPath, 'wx');
|
||||
const hash = createHash('sha256');
|
||||
let bytes = 0;
|
||||
const signature: number[] = [];
|
||||
const reader = response.body.getReader();
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
bytes += value.byteLength;
|
||||
if (bytes > course.archiveBytes) {
|
||||
throw new LearningCourseLibraryError('LEARNING_ARCHIVE_SIZE_MISMATCH', '课程包大小校验失败');
|
||||
}
|
||||
for (const byte of value.subarray(0, Math.max(0, 4 - signature.length))) signature.push(byte);
|
||||
hash.update(value);
|
||||
await handle.write(value);
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
await handle.close();
|
||||
}
|
||||
if (bytes !== course.archiveBytes) {
|
||||
throw new LearningCourseLibraryError('LEARNING_ARCHIVE_SIZE_MISMATCH', '课程包大小校验失败');
|
||||
}
|
||||
if (signature.length < 4 || signature[0] !== 0x50 || signature[1] !== 0x4b
|
||||
|| !((signature[2] === 0x03 && signature[3] === 0x04)
|
||||
|| (signature[2] === 0x05 && signature[3] === 0x06)
|
||||
|| (signature[2] === 0x07 && signature[3] === 0x08))) {
|
||||
throw new LearningCourseLibraryError('LEARNING_ARCHIVE_INVALID', '课程包不是有效的 ZIP 文件');
|
||||
}
|
||||
if (hash.digest('hex') !== course.archiveSha256) {
|
||||
throw new LearningCourseLibraryError('LEARNING_ARCHIVE_HASH_MISMATCH', '课程包完整性校验失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function fileSha256(path: string): Promise<string> {
|
||||
const hash = createHash('sha256');
|
||||
for await (const chunk of createReadStream(path)) hash.update(chunk);
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
export function createLearningCourseLibrary(dependencies: Dependencies) {
|
||||
const fetchImpl = dependencies.fetchImpl ?? proxyAwareFetch;
|
||||
const getAccessToken = dependencies.getAccessToken ?? getValidWorksSquareAccessToken;
|
||||
const apiBaseUrl = (dependencies.apiBaseUrl ?? WORKS_SQUARE_CONFIG.apiBaseUrl).replace(/\/+$/, '');
|
||||
const now = dependencies.now ?? (() => new Date());
|
||||
const activeDownloads = new Map<string, Promise<InstalledLearningCourse>>();
|
||||
|
||||
async function authorizedFetch(path: string): Promise<Response> {
|
||||
const token = await getAccessToken({ fetchImpl });
|
||||
if (!token) throw new LearningCourseLibraryError('LEARNING_AUTH_REQUIRED', '请先登录');
|
||||
const request = (accessToken: string) => fetchImpl(`${apiBaseUrl}${path}`, {
|
||||
headers: { Accept: path.endsWith('/download') ? 'application/zip' : 'application/json', Authorization: `Bearer ${accessToken}` },
|
||||
redirect: 'manual',
|
||||
});
|
||||
let response = await request(token);
|
||||
if (response.status === 401) {
|
||||
await response.body?.cancel().catch(() => undefined);
|
||||
const refreshed = await getAccessToken({ fetchImpl, forceRefresh: true });
|
||||
if (refreshed) response = await request(refreshed);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
async function followDownloadRedirect(response: Response): Promise<Response> {
|
||||
if (![301, 302, 303, 307, 308].includes(response.status)) return response;
|
||||
const location = response.headers.get('location');
|
||||
await response.body?.cancel().catch(() => undefined);
|
||||
if (!location) throw new LearningCourseLibraryError('LEARNING_DOWNLOAD_REDIRECT_INVALID', '课程下载地址无效');
|
||||
const target = new URL(location, apiBaseUrl);
|
||||
const base = new URL(apiBaseUrl);
|
||||
if (target.protocol !== 'https:' && !(target.protocol === 'http:' && target.origin === base.origin)) {
|
||||
throw new LearningCourseLibraryError('LEARNING_DOWNLOAD_REDIRECT_INVALID', '课程下载地址不安全');
|
||||
}
|
||||
return fetchImpl(target, { headers: { Accept: 'application/zip' }, redirect: 'follow' });
|
||||
}
|
||||
|
||||
async function install(courseId: string): Promise<InstalledLearningCourse> {
|
||||
assertCourseId(courseId);
|
||||
const metadataResponse = await authorizedFetch(`/api/learning/courses/${encodeURIComponent(courseId)}`);
|
||||
const metadataPayload = await safeJson(metadataResponse);
|
||||
if (!metadataResponse.ok) throw upstreamError(metadataPayload, metadataResponse.status);
|
||||
const course = parseCourse(metadataPayload);
|
||||
if (course.id !== courseId) {
|
||||
throw new LearningCourseLibraryError('LEARNING_COURSE_ID_MISMATCH', '课程信息与请求不匹配');
|
||||
}
|
||||
|
||||
const targetDirectory = courseDirectory(dependencies.rootDirectory, course);
|
||||
const archivePath = join(targetDirectory, 'course.makelore-course.zip');
|
||||
const descriptorPath = join(targetDirectory, 'installed.json');
|
||||
try {
|
||||
const existing = parseInstalled(JSON.parse(await readFile(descriptorPath, 'utf8')));
|
||||
if (existing && existing.course.archiveSha256 === course.archiveSha256
|
||||
&& (await stat(archivePath)).size === course.archiveBytes
|
||||
&& await fileSha256(archivePath) === course.archiveSha256) return existing;
|
||||
} catch {
|
||||
// Missing or incomplete installs are replaced atomically below.
|
||||
}
|
||||
|
||||
await mkdir(targetDirectory, { recursive: true });
|
||||
const temporaryArchive = join(targetDirectory, `.course-${randomUUID()}.partial`);
|
||||
const temporaryDescriptor = join(targetDirectory, `.installed-${randomUUID()}.partial`);
|
||||
try {
|
||||
let downloadResponse = await authorizedFetch(`/api/learning/courses/${encodeURIComponent(courseId)}/download`);
|
||||
downloadResponse = await followDownloadRedirect(downloadResponse);
|
||||
if (!downloadResponse.ok) {
|
||||
const payload = await safeJson(downloadResponse);
|
||||
throw upstreamError(payload, downloadResponse.status);
|
||||
}
|
||||
await streamVerifiedArchive(downloadResponse, temporaryArchive, course);
|
||||
const installed: InstalledLearningCourse = {
|
||||
schemaVersion: 1,
|
||||
course,
|
||||
archivePath,
|
||||
installedAt: now().toISOString(),
|
||||
};
|
||||
await writeFile(temporaryDescriptor, JSON.stringify(installed, null, 2), { encoding: 'utf8', flag: 'wx' });
|
||||
await rename(temporaryArchive, archivePath);
|
||||
await rename(temporaryDescriptor, descriptorPath);
|
||||
return installed;
|
||||
} catch (error) {
|
||||
await Promise.all([
|
||||
rm(temporaryArchive, { force: true }),
|
||||
rm(temporaryDescriptor, { force: true }),
|
||||
]);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
download(courseId: string): Promise<InstalledLearningCourse> {
|
||||
assertCourseId(courseId);
|
||||
const existing = activeDownloads.get(courseId);
|
||||
if (existing) return existing;
|
||||
const pending = install(courseId).finally(() => activeDownloads.delete(courseId));
|
||||
activeDownloads.set(courseId, pending);
|
||||
return pending;
|
||||
},
|
||||
|
||||
async listInstalled(): Promise<InstalledLearningCourse[]> {
|
||||
const coursesRoot = join(dependencies.rootDirectory, 'learning', 'courses');
|
||||
let courseDirectories;
|
||||
try {
|
||||
courseDirectories = await readdir(coursesRoot, { withFileTypes: true });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const records: InstalledLearningCourse[] = [];
|
||||
for (const courseEntry of courseDirectories) {
|
||||
if (!courseEntry.isDirectory() || !COURSE_ID_PATTERN.test(courseEntry.name)) continue;
|
||||
const hashRoot = join(coursesRoot, courseEntry.name);
|
||||
const hashDirectories = await readdir(hashRoot, { withFileTypes: true }).catch(() => []);
|
||||
for (const hashEntry of hashDirectories) {
|
||||
if (!hashEntry.isDirectory() || !SHA256_PATTERN.test(hashEntry.name)) continue;
|
||||
const descriptorPath = join(hashRoot, hashEntry.name, 'installed.json');
|
||||
try {
|
||||
const record = parseInstalled(JSON.parse(await readFile(descriptorPath, 'utf8')));
|
||||
if (!record
|
||||
|| record.course.id !== courseEntry.name
|
||||
|| record.course.contentHash !== hashEntry.name
|
||||
|| dirname(record.archivePath) !== join(hashRoot, hashEntry.name)
|
||||
|| (await stat(record.archivePath)).size !== record.course.archiveBytes) continue;
|
||||
records.push(record);
|
||||
} catch {
|
||||
// Ignore incomplete or corrupted entries; they can be downloaded again.
|
||||
}
|
||||
}
|
||||
}
|
||||
return records.sort((left, right) => right.installedAt.localeCompare(left.installedAt));
|
||||
},
|
||||
|
||||
async readClassroom(courseId: string, moduleId?: string): Promise<LearningClassroomPayload> {
|
||||
assertCourseId(courseId);
|
||||
const installed = (await this.listInstalled()).find((record) => record.course.id === courseId);
|
||||
if (!installed) {
|
||||
throw new LearningCourseLibraryError('LEARNING_COURSE_NOT_INSTALLED', '课程尚未下载到本机');
|
||||
}
|
||||
if (await fileSha256(installed.archivePath) !== installed.course.archiveSha256) {
|
||||
throw new LearningCourseLibraryError('LEARNING_ARCHIVE_HASH_MISMATCH', '本地课程包完整性校验失败,请重新下载');
|
||||
}
|
||||
const zip = new AdmZip(installed.archivePath);
|
||||
for (const entry of zip.getEntries()) {
|
||||
const parts = entry.entryName.replace(/\\/g, '/').split('/');
|
||||
if (entry.entryName.startsWith('/') || parts.includes('..')) {
|
||||
throw new LearningCourseLibraryError('LEARNING_ARCHIVE_INVALID', '课程包包含不安全路径');
|
||||
}
|
||||
}
|
||||
registerLearningCoursePackage(
|
||||
installed.course.id,
|
||||
installed.course.contentHash,
|
||||
installed.archivePath,
|
||||
);
|
||||
try {
|
||||
return await readLearningPackageClassroom(zip, installed.course, moduleId);
|
||||
} catch (error) {
|
||||
throw new LearningCourseLibraryError(
|
||||
'LEARNING_CLASSROOM_INVALID',
|
||||
`课程播放数据无效${error instanceof Error ? `:${error.message}` : ''}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user