521 lines
21 KiB
TypeScript
521 lines
21 KiB
TypeScript
import AdmZip from 'adm-zip';
|
|
import type {
|
|
LearningClassroomPayload,
|
|
LearningCourse,
|
|
LearningCourseModule,
|
|
} from '../../shared/learning';
|
|
|
|
const SHA256_PATTERN = /^[0-9a-f]{64}$/;
|
|
const MODULE_ID_PATTERN = /^(?!\.{1,2}$)[A-Za-z0-9._-]{1,128}$/;
|
|
const MAX_DOCUMENT_BYTES = 64 * 1024 * 1024;
|
|
const MAX_ARCHIVE_ENTRIES = 4_096;
|
|
const MAX_ARCHIVE_UNCOMPRESSED_BYTES = 512 * 1024 * 1024;
|
|
const MAX_ENTRY_NAME_BYTES = 1_024;
|
|
const MAX_COMPRESSION_RATIO = 200;
|
|
const MAX_MODULES = 128;
|
|
const MAX_SCENES_PER_MODULE = 10_000;
|
|
const MAX_JSON_DEPTH = 64;
|
|
const MAX_JSON_NODES = 1_000_000;
|
|
const UNSAFE_MEDIA_ERROR = '课程包包含不安全媒体资源';
|
|
|
|
/**
|
|
* Keep this list exactly in sync with COURSE_ASSET_MIME_TYPES in
|
|
* learning-player-server.ts. A media entry must be a passive browser resource
|
|
* that the player can serve, never a document, script, stylesheet, or plugin
|
|
* payload. The MIME value is part of the signed course manifest, so an
|
|
* extension alone is not sufficient for admission.
|
|
*/
|
|
const COURSE_MEDIA_MIME_BY_EXTENSION: Readonly<Record<string, string>> = {
|
|
'.gif': 'image/gif',
|
|
'.jpeg': 'image/jpeg',
|
|
'.jpg': 'image/jpeg',
|
|
'.aac': 'audio/aac',
|
|
'.m4a': 'audio/mp4',
|
|
'.mp3': 'audio/mpeg',
|
|
'.mp4': 'video/mp4',
|
|
'.ogg': 'audio/ogg',
|
|
'.otf': 'font/otf',
|
|
'.png': 'image/png',
|
|
'.ttf': 'font/ttf',
|
|
'.wav': 'audio/wav',
|
|
'.webm': 'video/webm',
|
|
'.webp': 'image/webp',
|
|
'.woff': 'font/woff',
|
|
'.woff2': 'font/woff2',
|
|
};
|
|
const COURSE_MEDIA_ROOTS = new Set(['audio', 'media', 'fonts']);
|
|
const SAFE_MEDIA_SEGMENT_PATTERN = /^[A-Za-z0-9_-]+$/;
|
|
|
|
type ModuleLocation = {
|
|
module: LearningCourseModule;
|
|
root: string;
|
|
kind: 'legacy' | 'frozen';
|
|
};
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
}
|
|
|
|
function validateArchiveMetadata(zip: AdmZip): void {
|
|
const entries = zip.getEntries();
|
|
if (entries.length === 0 || entries.length > MAX_ARCHIVE_ENTRIES) {
|
|
throw new Error('课程包结构过于复杂');
|
|
}
|
|
let totalSize = 0;
|
|
for (const entry of entries) {
|
|
const size = entry.header.size;
|
|
const compressedSize = entry.header.compressedSize;
|
|
if (!Number.isSafeInteger(size) || size < 0
|
|
|| !Number.isSafeInteger(compressedSize) || compressedSize < 0
|
|
|| Buffer.byteLength(entry.entryName, 'utf8') > MAX_ENTRY_NAME_BYTES
|
|
|| (!entry.isDirectory && size > MAX_DOCUMENT_BYTES)) {
|
|
throw new Error('课程包资源超出限制');
|
|
}
|
|
totalSize += size;
|
|
if (!Number.isSafeInteger(totalSize) || totalSize > MAX_ARCHIVE_UNCOMPRESSED_BYTES) {
|
|
throw new Error('课程包资源超出限制');
|
|
}
|
|
if (!entry.isDirectory && size >= 1024 * 1024
|
|
&& (compressedSize === 0 || size / compressedSize > MAX_COMPRESSION_RATIO)) {
|
|
throw new Error('课程包压缩数据异常');
|
|
}
|
|
}
|
|
}
|
|
|
|
function validateJsonComplexity(value: unknown): void {
|
|
const pending: Array<{ value: unknown; depth: number }> = [{ value, depth: 0 }];
|
|
let nodes = 0;
|
|
while (pending.length > 0) {
|
|
const current = pending.pop()!;
|
|
nodes += 1;
|
|
if (current.depth > MAX_JSON_DEPTH || nodes > MAX_JSON_NODES) {
|
|
throw new Error('课程包 JSON 结构过于复杂');
|
|
}
|
|
if (Array.isArray(current.value)) {
|
|
for (const child of current.value) pending.push({ value: child, depth: current.depth + 1 });
|
|
} else if (isRecord(current.value)) {
|
|
for (const child of Object.values(current.value)) pending.push({ value: child, depth: current.depth + 1 });
|
|
}
|
|
}
|
|
}
|
|
|
|
function readEntry(zip: AdmZip, name: string): Promise<Buffer> {
|
|
const entry = zip.getEntry(name);
|
|
if (!entry || entry.isDirectory || entry.header.size > MAX_DOCUMENT_BYTES) {
|
|
return Promise.reject(new Error(`课程包缺少 ${name}`));
|
|
}
|
|
return new Promise((resolveData, rejectData) => {
|
|
entry.getDataAsync((data, error) => {
|
|
if (error || data.byteLength !== entry.header.size || data.byteLength > MAX_DOCUMENT_BYTES) {
|
|
rejectData(new Error('课程包资源读取失败'));
|
|
} else {
|
|
resolveData(data);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
async function readJson(zip: AdmZip, name: string): Promise<unknown> {
|
|
try {
|
|
const value = JSON.parse((await readEntry(zip, name)).toString('utf8')) as unknown;
|
|
validateJsonComplexity(value);
|
|
return value;
|
|
} catch {
|
|
throw new Error(`${name} 无效`);
|
|
}
|
|
}
|
|
|
|
function safeModuleId(value: unknown, fallback: string): string {
|
|
return typeof value === 'string' && MODULE_ID_PATTERN.test(value) ? value : fallback;
|
|
}
|
|
|
|
function safeCount(value: unknown, fallback: number): number {
|
|
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : fallback;
|
|
}
|
|
|
|
function safeHash(value: unknown, fallback: string): string {
|
|
return typeof value === 'string' && SHA256_PATTERN.test(value) ? value : fallback;
|
|
}
|
|
|
|
function normalizeRoot(value: unknown): string | null {
|
|
if (typeof value !== 'string') return null;
|
|
const root = value.replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
|
|
if (!root || root.split('/').some((part) => part === '.' || part === '..')) return null;
|
|
return `${root}/`;
|
|
}
|
|
|
|
function manifestRoots(zip: AdmZip): string[] {
|
|
return zip.getEntries()
|
|
.map((entry) => entry.entryName.replace(/\\/g, '/'))
|
|
.flatMap((name) => {
|
|
if (name === 'manifest.json') return [''];
|
|
const match = name.match(/^(modules\/[^/]+\/)manifest\.json$/);
|
|
return match ? [match[1]] : [];
|
|
})
|
|
.sort((left, right) => left.localeCompare(right, undefined, { numeric: true }));
|
|
}
|
|
|
|
function courseJsonModules(value: unknown): Array<Record<string, unknown>> {
|
|
return isRecord(value) && Array.isArray(value.modules)
|
|
? value.modules.filter(isRecord)
|
|
: [];
|
|
}
|
|
|
|
async function frozenModuleMetadata(
|
|
zip: AdmZip,
|
|
root: string,
|
|
fallback: LearningCourseModule,
|
|
): Promise<LearningCourseModule> {
|
|
const bundle = await readJson(zip, `${root}bundle.json`);
|
|
const meta = isRecord(bundle) && isRecord(bundle.meta) ? bundle.meta : null;
|
|
if (!meta
|
|
|| typeof meta.coursewareId !== 'string'
|
|
|| !MODULE_ID_PATTERN.test(meta.coursewareId)
|
|
|| !Number.isSafeInteger(meta.sceneCount)
|
|
|| Number(meta.sceneCount) < 0
|
|
|| Number(meta.sceneCount) > MAX_SCENES_PER_MODULE
|
|
|| typeof meta.contentHash !== 'string'
|
|
|| !SHA256_PATTERN.test(meta.contentHash)) {
|
|
throw new Error(`${root}bundle.json 模块身份无效`);
|
|
}
|
|
return {
|
|
moduleId: meta.coursewareId,
|
|
title: typeof meta.stageName === 'string' && meta.stageName.trim() ? meta.stageName : fallback.title,
|
|
summary: fallback.summary,
|
|
sceneCount: Number(meta.sceneCount),
|
|
contentHash: meta.contentHash,
|
|
};
|
|
}
|
|
|
|
async function resolveModuleLocations(zip: AdmZip, course: LearningCourse): Promise<ModuleLocation[]> {
|
|
const roots = manifestRoots(zip);
|
|
if (roots.length > MAX_MODULES || (course.modules?.length ?? 0) > MAX_MODULES) {
|
|
throw new Error('课程包模块数量超出限制');
|
|
}
|
|
const rawCourseJson = zip.getEntry('course.json') ? await readJson(zip, 'course.json') : null;
|
|
const descriptors = courseJsonModules(rawCourseJson);
|
|
const declared = course.modules?.length ? course.modules : descriptors.map((value, index) => ({
|
|
moduleId: safeModuleId(value.moduleId ?? value.id, `module-${index + 1}`),
|
|
title: typeof value.title === 'string' && value.title.trim() ? value.title : `模块 ${index + 1}`,
|
|
summary: typeof value.summary === 'string' ? value.summary : null,
|
|
sceneCount: safeCount(value.sceneCount, 0),
|
|
contentHash: safeHash(value.contentHash, course.contentHash),
|
|
}));
|
|
|
|
if (declared.length > 0) {
|
|
if (declared.length > MAX_MODULES) throw new Error('课程包模块数量超出限制');
|
|
const locations: ModuleLocation[] = [];
|
|
for (const [index, module] of declared.entries()) {
|
|
const descriptor = descriptors.find((value) => value.moduleId === module.moduleId || value.id === module.moduleId)
|
|
?? descriptors[index];
|
|
const preferredRoot = normalizeRoot(descriptor?.path ?? descriptor?.directory);
|
|
const candidates = [
|
|
preferredRoot,
|
|
`modules/${module.moduleId}/`,
|
|
`modules/${index}/`,
|
|
`modules/${index + 1}/`,
|
|
roots[index],
|
|
].filter((value): value is string => value !== null && value !== undefined);
|
|
const root = candidates.find((candidate) => zip.getEntry(`${candidate}manifest.json`));
|
|
if (!root) throw new Error(`课程模块“${module.title}”缺少 frozen manifest`);
|
|
const metadata = await frozenModuleMetadata(zip, root, module);
|
|
if (metadata.moduleId !== module.moduleId || metadata.contentHash !== module.contentHash) {
|
|
throw new Error(`课程模块“${module.title}”内容校验不一致`);
|
|
}
|
|
locations.push({ module: { ...module, ...metadata, moduleId: module.moduleId }, root, kind: 'frozen' });
|
|
}
|
|
return locations;
|
|
}
|
|
|
|
if (roots.length > 0) {
|
|
return Promise.all(roots.map(async (root, index) => {
|
|
const fallback: LearningCourseModule = {
|
|
moduleId: roots.length === 1 ? 'main' : `module-${index + 1}`,
|
|
title: roots.length === 1 ? course.title : `模块 ${index + 1}`,
|
|
summary: roots.length === 1 ? course.summary : null,
|
|
sceneCount: roots.length === 1 ? course.sceneCount : 0,
|
|
contentHash: course.contentHash,
|
|
};
|
|
const module = await frozenModuleMetadata(zip, root, fallback);
|
|
return { module, root, kind: 'frozen' as const };
|
|
}));
|
|
}
|
|
|
|
if (zip.getEntry('classroom.json')) {
|
|
return [{
|
|
module: {
|
|
moduleId: 'main',
|
|
title: course.title,
|
|
summary: course.summary,
|
|
sceneCount: course.sceneCount,
|
|
contentHash: course.contentHash,
|
|
},
|
|
root: '',
|
|
kind: 'legacy',
|
|
}];
|
|
}
|
|
throw new Error('课程包缺少可播放模块');
|
|
}
|
|
|
|
function assetUrl(course: LearningCourse, entryName: string): string {
|
|
const encodedPath = entryName.split('/').map(encodeURIComponent).join('/');
|
|
return `/course-assets/${encodeURIComponent(course.id)}/${course.contentHash}/${encodedPath}`;
|
|
}
|
|
|
|
function hasMediaControlCharacter(value: string): boolean {
|
|
for (const character of value) {
|
|
const code = character.charCodeAt(0);
|
|
if ((code >= 0 && code <= 0x1f)
|
|
|| (code >= 0x7f && code <= 0x9f)
|
|
|| code === 0x2028
|
|
|| code === 0x2029) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function validateCourseMediaEntry(relativePath: string, mimeType: unknown): void {
|
|
if (typeof relativePath !== 'string'
|
|
|| !relativePath
|
|
|| relativePath.includes('\\')
|
|
|| relativePath.startsWith('/')
|
|
|| relativePath.endsWith('/')
|
|
|| relativePath.includes('//')
|
|
|| hasMediaControlCharacter(relativePath)) {
|
|
throw new Error(UNSAFE_MEDIA_ERROR);
|
|
}
|
|
|
|
const segments = relativePath.split('/');
|
|
if (segments.some((segment) => !segment || segment === '.' || segment === '..')) {
|
|
throw new Error(UNSAFE_MEDIA_ERROR);
|
|
}
|
|
// `location.root` is authoritative for the module and is prepended below.
|
|
// Keeping the manifest key module-relative prevents a duplicate
|
|
// `modules/<id>/` prefix in the immutable player URL.
|
|
if (!COURSE_MEDIA_ROOTS.has(segments[0] ?? '') || segments.length <= 1) {
|
|
throw new Error(UNSAFE_MEDIA_ERROR);
|
|
}
|
|
const filename = segments.at(-1)!;
|
|
const directorySegments = segments.slice(1, -1);
|
|
if (directorySegments.some((segment) => !SAFE_MEDIA_SEGMENT_PATTERN.test(segment))) {
|
|
throw new Error(UNSAFE_MEDIA_ERROR);
|
|
}
|
|
|
|
const extensionStart = filename.lastIndexOf('.');
|
|
if (extensionStart <= 0 || extensionStart === filename.length - 1) {
|
|
throw new Error(UNSAFE_MEDIA_ERROR);
|
|
}
|
|
const stem = filename.slice(0, extensionStart);
|
|
const extension = filename.slice(extensionStart).toLowerCase();
|
|
// A second suffix is ambiguous (for example `lesson.html.png`). Reject it
|
|
// even when the final extension is otherwise safe.
|
|
if (!SAFE_MEDIA_SEGMENT_PATTERN.test(stem) || stem.includes('.')) {
|
|
throw new Error(UNSAFE_MEDIA_ERROR);
|
|
}
|
|
const expectedMimeTypes = COURSE_MEDIA_MIME_BY_EXTENSION[extension];
|
|
const normalizedMimeType = typeof mimeType === 'string' ? mimeType.trim().toLowerCase() : '';
|
|
if (!expectedMimeTypes
|
|
|| hasMediaControlCharacter(normalizedMimeType)
|
|
|| expectedMimeTypes !== normalizedMimeType) {
|
|
throw new Error(UNSAFE_MEDIA_ERROR);
|
|
}
|
|
}
|
|
|
|
function mediaRefFromPath(path: string, mimeType?: unknown): string {
|
|
const relative = path.startsWith('media/') ? path.slice('media/'.length) : path;
|
|
const suffix = typeof mimeType === 'string' ? mimeType.split('/')[1] : '';
|
|
if (suffix && relative.toLowerCase().endsWith(`.${suffix.toLowerCase()}`)) {
|
|
return relative.slice(0, -suffix.length - 1);
|
|
}
|
|
return relative.replace(/\.[^/.]+$/, '');
|
|
}
|
|
|
|
function rewriteDeep(value: unknown, mediaUrls: Map<string, string>): unknown {
|
|
if (typeof value === 'string') return mediaUrls.get(value) ?? value;
|
|
if (Array.isArray(value)) return value.map((item) => rewriteDeep(item, mediaUrls));
|
|
if (!isRecord(value)) return value;
|
|
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, rewriteDeep(item, mediaUrls)]));
|
|
}
|
|
|
|
function rewriteVideoManifest(value: unknown, mediaUrls: Map<string, string>): unknown {
|
|
if (!isRecord(value)) return value;
|
|
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [
|
|
mediaUrls.get(key) ?? key,
|
|
rewriteDeep(entry, mediaUrls),
|
|
]));
|
|
}
|
|
|
|
async function buildFrozenClassroom(
|
|
zip: AdmZip,
|
|
course: LearningCourse,
|
|
location: ModuleLocation,
|
|
): Promise<{ stage: Record<string, unknown>; scenes: unknown[] }> {
|
|
const [manifestValue, bundleValue] = await Promise.all([
|
|
readJson(zip, `${location.root}manifest.json`),
|
|
readJson(zip, `${location.root}bundle.json`),
|
|
readJson(zip, `${location.root}quiz/quiz.json`),
|
|
readJson(zip, `${location.root}knowledge/knowledge.json`),
|
|
]);
|
|
if (!isRecord(manifestValue)
|
|
|| !isRecord(manifestValue.stage)
|
|
|| !Array.isArray(manifestValue.scenes)
|
|
|| !Array.isArray(manifestValue.agents)
|
|
|| manifestValue.agents.some((agent) => !isRecord(agent))
|
|
|| !isRecord(manifestValue.mediaIndex)) {
|
|
throw new Error('Frozen manifest 结构无效');
|
|
}
|
|
if (manifestValue.scenes.length > MAX_SCENES_PER_MODULE) {
|
|
throw new Error('课程包场景数量超出限制');
|
|
}
|
|
const bundle = isRecord(bundleValue) ? bundleValue : {};
|
|
const meta = isRecord(bundle.meta) ? bundle.meta : {};
|
|
const completeness = isRecord(bundle.completeness) ? bundle.completeness : {};
|
|
if (completeness.complete !== true) throw new Error('课程模块资源不完整,无法离线播放');
|
|
if (safeCount(meta.sceneCount, -1) !== manifestValue.scenes.length) {
|
|
throw new Error('课程模块场景数量不一致');
|
|
}
|
|
if (safeHash(meta.contentHash, '') !== location.module.contentHash) {
|
|
throw new Error('课程模块内容哈希不一致');
|
|
}
|
|
|
|
const mediaUrls = new Map<string, string>();
|
|
for (const [relativePath, mediaValue] of Object.entries(manifestValue.mediaIndex)) {
|
|
if (!isRecord(mediaValue)) throw new Error(UNSAFE_MEDIA_ERROR);
|
|
validateCourseMediaEntry(relativePath, mediaValue.mimeType);
|
|
if (mediaValue.missing === true) continue;
|
|
const fullPath = `${location.root}${relativePath}`;
|
|
if (!zip.getEntry(fullPath)) throw new Error(`课程模块缺少资源 ${relativePath}`);
|
|
const url = assetUrl(course, fullPath);
|
|
mediaUrls.set(relativePath, url);
|
|
if (mediaValue.type === 'generated' || mediaValue.type === 'image') {
|
|
mediaUrls.set(mediaRefFromPath(relativePath, mediaValue.mimeType), url);
|
|
}
|
|
}
|
|
|
|
const stageId = `learning_${course.id}_${location.module.moduleId}`;
|
|
const agentIds = manifestValue.agents.map((_agent, index) => `${stageId}_a${index}`);
|
|
const agents = manifestValue.agents.filter(isRecord).map((agent, index) => ({
|
|
id: agentIds[index],
|
|
name: typeof agent.name === 'string' ? agent.name : `Agent ${index + 1}`,
|
|
role: typeof agent.role === 'string' ? agent.role : 'teacher',
|
|
persona: typeof agent.persona === 'string' ? agent.persona : '',
|
|
avatar: typeof agent.avatar === 'string' ? agent.avatar : '/avatars/teacher.png',
|
|
color: typeof agent.color === 'string' ? agent.color : '#3b82f6',
|
|
priority: typeof agent.priority === 'number' ? agent.priority : index,
|
|
...(isRecord(agent.voiceConfig) ? { voiceConfig: agent.voiceConfig } : {}),
|
|
...(isRecord(agent.voiceDesign) ? { voiceDesign: agent.voiceDesign } : {}),
|
|
}));
|
|
const fallbackAgentIndex = manifestValue.agents.findIndex((agent) => isRecord(agent) && agent.role !== 'teacher');
|
|
const stageSource = manifestValue.stage;
|
|
const stage: Record<string, unknown> = {
|
|
id: stageId,
|
|
name: typeof stageSource.name === 'string' ? stageSource.name : location.module.title,
|
|
...(typeof stageSource.description === 'string' ? { description: stageSource.description } : {}),
|
|
...(typeof stageSource.language === 'string' ? { languageDirective: stageSource.language } : {}),
|
|
...(typeof stageSource.style === 'string' ? { style: stageSource.style } : {}),
|
|
createdAt: typeof stageSource.createdAt === 'number' ? stageSource.createdAt : Date.now(),
|
|
updatedAt: typeof stageSource.updatedAt === 'number' ? stageSource.updatedAt : Date.now(),
|
|
agentIds,
|
|
generatedAgentConfigs: agents,
|
|
...(Array.isArray(stageSource.whiteboard)
|
|
? { whiteboard: rewriteDeep(stageSource.whiteboard, mediaUrls) }
|
|
: {}),
|
|
...(stageSource.interactiveMode === true ? { interactiveMode: true } : {}),
|
|
...(stageSource.taskEngineMode === true ? { taskEngineMode: true } : {}),
|
|
...(stageSource.videoManifest
|
|
? { videoManifest: rewriteVideoManifest(stageSource.videoManifest, mediaUrls) }
|
|
: {}),
|
|
};
|
|
const scenes = manifestValue.scenes.map((sceneValue, index) => {
|
|
if (!isRecord(sceneValue) || !isRecord(sceneValue.content)) {
|
|
throw new Error(`课程模块第 ${index + 1} 个场景无效`);
|
|
}
|
|
const actions = Array.isArray(sceneValue.actions) ? sceneValue.actions.map((actionValue) => {
|
|
if (!isRecord(actionValue)) return actionValue;
|
|
if (actionValue.type === 'speech' && typeof actionValue.audioRef === 'string') {
|
|
const { audioRef, ...rest } = actionValue;
|
|
const audioUrl = mediaUrls.get(audioRef);
|
|
if (!audioUrl) throw new Error(`课程旁白资源缺失:${audioRef}`);
|
|
return { ...rest, audioUrl };
|
|
}
|
|
if (actionValue.type === 'discussion') {
|
|
const { agentIndex, agentId: legacyAgentId, ...rest } = actionValue;
|
|
const indexValue = typeof agentIndex === 'number' ? agentIndex : fallbackAgentIndex;
|
|
const agentId = agentIds[indexValue] || (typeof legacyAgentId === 'string' ? legacyAgentId : undefined);
|
|
return { ...rest, ...(agentId ? { agentId } : {}) };
|
|
}
|
|
return rewriteDeep(actionValue, mediaUrls);
|
|
}) : undefined;
|
|
const multiAgent = isRecord(sceneValue.multiAgent) && sceneValue.multiAgent.enabled === true
|
|
? {
|
|
enabled: true,
|
|
agentIds: Array.isArray(sceneValue.multiAgent.agentIndices)
|
|
? sceneValue.multiAgent.agentIndices
|
|
.map((value) => typeof value === 'number' ? agentIds[value] : undefined)
|
|
.filter(Boolean)
|
|
: [],
|
|
...(typeof sceneValue.multiAgent.directorPrompt === 'string'
|
|
? { directorPrompt: sceneValue.multiAgent.directorPrompt }
|
|
: {}),
|
|
}
|
|
: undefined;
|
|
return {
|
|
id: `${stageId}_s${index}`,
|
|
stageId,
|
|
title: typeof sceneValue.title === 'string' ? sceneValue.title : `场景 ${index + 1}`,
|
|
order: safeCount(sceneValue.order, index),
|
|
type: typeof sceneValue.type === 'string' ? sceneValue.type : sceneValue.content.type,
|
|
content: rewriteDeep(sceneValue.content, mediaUrls),
|
|
...(actions ? { actions } : {}),
|
|
...(Array.isArray(sceneValue.whiteboards)
|
|
? { whiteboards: rewriteDeep(sceneValue.whiteboards, mediaUrls) }
|
|
: {}),
|
|
...(multiAgent ? { multiAgent } : {}),
|
|
createdAt: Date.now(),
|
|
updatedAt: Date.now(),
|
|
};
|
|
});
|
|
return { stage, scenes };
|
|
}
|
|
|
|
export async function readLearningPackageClassroom(
|
|
zip: AdmZip,
|
|
course: LearningCourse,
|
|
requestedModuleId?: string,
|
|
): Promise<LearningClassroomPayload> {
|
|
validateArchiveMetadata(zip);
|
|
const locations = await resolveModuleLocations(zip, course);
|
|
const selected = requestedModuleId
|
|
? locations.find((location) => location.module.moduleId === requestedModuleId)
|
|
: locations[0];
|
|
if (!selected) throw new Error('找不到指定课程模块');
|
|
const classroomValue = selected.kind === 'legacy'
|
|
? await readJson(zip, 'classroom.json')
|
|
: await buildFrozenClassroom(zip, course, selected);
|
|
if (isRecord(classroomValue)
|
|
&& Array.isArray(classroomValue.scenes)
|
|
&& classroomValue.scenes.length > MAX_SCENES_PER_MODULE) {
|
|
throw new Error('课程包场景数量超出限制');
|
|
}
|
|
if (!isRecord(classroomValue)
|
|
|| !isRecord(classroomValue.stage)
|
|
|| typeof classroomValue.stage.id !== 'string'
|
|
|| !Array.isArray(classroomValue.scenes)
|
|
|| classroomValue.scenes.length !== selected.module.sceneCount) {
|
|
throw new Error('课程播放数据与模块信息不匹配');
|
|
}
|
|
return {
|
|
courseId: course.id,
|
|
courseContentHash: course.contentHash,
|
|
contentHash: course.contentHash,
|
|
// Works treats modules as an aggregate-course concept. Keep the local
|
|
// synthetic `main` descriptor for playback metadata, but omit it from
|
|
// cloud progress/runtime context for a single-course package.
|
|
moduleId: course.capabilities.modular === true ? selected.module.moduleId : null,
|
|
moduleContentHash: selected.module.contentHash,
|
|
modules: locations.map((location) => location.module),
|
|
classroom: classroomValue as { stage: Record<string, unknown>; scenes: unknown[] },
|
|
};
|
|
}
|