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; type ModuleLocation = { module: LearningCourseModule; root: string; kind: 'legacy' | 'frozen'; }; function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === 'object' && !Array.isArray(value); } function readEntry(zip: AdmZip, name: string): Promise { 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) => error ? rejectData(error) : resolveData(data)); }); } async function readJson(zip: AdmZip, name: string): Promise { try { return JSON.parse((await readEntry(zip, name)).toString('utf8')) as unknown; } catch (error) { throw new Error( `${name} 无效${error instanceof Error ? `:${error.message}` : ''}`, { cause: error }, ); } } 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> { return isRecord(value) && Array.isArray(value.modules) ? value.modules.filter(isRecord) : []; } async function frozenModuleMetadata( zip: AdmZip, root: string, fallback: LearningCourseModule, ): Promise { 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 || 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 { const roots = manifestRoots(zip); 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) { 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 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): 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): 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; 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 结构无效'); } 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(); for (const [relativePath, mediaValue] of Object.entries(manifestValue.mediaIndex)) { if (!isRecord(mediaValue) || 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 = { 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 { 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) || !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; scenes: unknown[] }, }; }