feat: integrate learning module
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

This commit is contained in:
inman
2026-08-16 20:52:16 +08:00
parent 26b52d76e3
commit 01bee3188b
107 changed files with 6318 additions and 12063 deletions

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