Files
makelore/electron/services/learning-course-library.ts
inman 01bee3188b
Some checks failed
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled
feat: integrate learning module
2026-08-16 20:52:16 +08:00

341 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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}` : ''}`,
);
}
},
};
}