import type { IncomingMessage, ServerResponse } from 'node:http'; import type { HostApiContext } from '../context'; import { sendJson } from '../route-utils'; import { WORKS_SQUARE_CONFIG } from '../works-config'; import { getValidWorksSquareAccessToken } from '../../services/works-square-session'; import { proxyAwareFetch } from '../../utils/proxy-fetch'; const LOCAL_ROOT = '/api/works/learning'; const UPSTREAM_ROOT = '/api/learning'; const COURSE_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/; const JOB_ID_PATTERN = /^(?!\.{1,2}$)[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; const MODULE_ID_PATTERN = /^(?!\.{1,2}$)[A-Za-z0-9._-]{1,128}$/; const SHA256_PATTERN = /^[0-9a-f]{64}$/; const MAX_REQUEST_BYTES = 16 * 1024; const MAX_RESPONSE_BYTES = 2 * 1024 * 1024; const MAX_COURSES = 500; const MAX_PROGRESS_ITEMS = 5_000; const MAX_MODULES = 500; type Dependencies = { fetchImpl?: typeof fetch; getAccessToken?: typeof getValidWorksSquareAccessToken; apiBaseUrl?: string; }; type LearningRoute = | { kind: 'course-list'; upstreamPath: string } | { kind: 'course-detail'; upstreamPath: string; courseId: string } | { kind: 'progress-list'; upstreamPath: string } | { kind: 'progress-write'; upstreamPath: string; courseId: string } | { kind: 'generation-start'; upstreamPath: string } | { kind: 'generation-read'; upstreamPath: string; jobId: string } | { kind: 'generation-control'; upstreamPath: string; jobId: string } | { kind: 'generation-finalize'; upstreamPath: string; jobId: string }; class InvalidLearningDataError extends Error {} function matchLearningRoute(pathname: string): LearningRoute | null { if (pathname === `${LOCAL_ROOT}/courses` || pathname === `${LOCAL_ROOT}/courses/mine`) { return { kind: 'course-list', upstreamPath: `${UPSTREAM_ROOT}${pathname.slice(LOCAL_ROOT.length)}` }; } if (pathname === `${LOCAL_ROOT}/progress`) { return { kind: 'progress-list', upstreamPath: `${UPSTREAM_ROOT}/progress` }; } if (pathname === `${LOCAL_ROOT}/generations`) { return { kind: 'generation-start', upstreamPath: `${UPSTREAM_ROOT}/generations` }; } const courseMatch = pathname.match(/^\/api\/works\/learning\/courses\/([^/]+?)(\/progress)?$/); if (courseMatch) { const courseId = courseMatch[1]; if (!COURSE_ID_PATTERN.test(courseId)) return null; return courseMatch[2] ? { kind: 'progress-write', upstreamPath: `${UPSTREAM_ROOT}/courses/${courseId}/progress`, courseId } : { kind: 'course-detail', upstreamPath: `${UPSTREAM_ROOT}/courses/${courseId}`, courseId }; } const generationMatch = pathname.match(/^\/api\/works\/learning\/generations\/([^/]+?)(\/(?:cancel|resume|finalize))?$/); if (!generationMatch) return null; const jobId = generationMatch[1]; if (!JOB_ID_PATTERN.test(jobId)) return null; const suffix = generationMatch[2] ?? ''; const upstreamPath = `${UPSTREAM_ROOT}/generations/${jobId}${suffix}`; if (suffix === '/finalize') return { kind: 'generation-finalize', upstreamPath, jobId }; if (suffix) return { kind: 'generation-control', upstreamPath, jobId }; return { kind: 'generation-read', upstreamPath, jobId }; } function allowedQuery(url: URL, route: LearningRoute): string { if (route.kind !== 'course-list' && route.kind !== 'progress-list') return ''; const query = new URLSearchParams(); const limit = url.searchParams.get('limit'); if (limit && /^\d{1,3}$/.test(limit) && Number(limit) <= 500) query.set('limit', limit); const offset = url.searchParams.get('offset'); if (offset && /^\d{1,7}$/.test(offset)) query.set('offset', offset); const encoded = query.toString(); return encoded ? `?${encoded}` : ''; } function asRecord(value: unknown): Record { if (!value || typeof value !== 'object' || Array.isArray(value)) throw new InvalidLearningDataError(); return value as Record; } function boundedArray(value: unknown, maximum: number): unknown[] { if (!Array.isArray(value) || value.length > maximum) throw new InvalidLearningDataError(); return value; } function boundedString(value: unknown, maximum: number, allowEmpty = false): string { if (typeof value !== 'string' || value.length > maximum || (!allowEmpty && !value.trim())) { throw new InvalidLearningDataError(); } return value; } function nullableString(value: unknown, maximum: number, allowEmpty = true): string | null { if (value === null) return null; return boundedString(value, maximum, allowEmpty); } function boundedInteger(value: unknown, minimum: number, maximum: number): number { if (!Number.isSafeInteger(value) || (value as number) < minimum || (value as number) > maximum) { throw new InvalidLearningDataError(); } return value as number; } function boundedNumber(value: unknown, minimum: number, maximum: number): number { if (typeof value !== 'number' || !Number.isFinite(value) || value < minimum || value > maximum) { throw new InvalidLearningDataError(); } return value; } function nullableInteger(value: unknown, minimum: number, maximum: number): number | null { return value === null ? null : boundedInteger(value, minimum, maximum); } function isoTimestamp(value: unknown): string { const timestamp = boundedString(value, 64); if (!Number.isFinite(Date.parse(timestamp))) throw new InvalidLearningDataError(); return timestamp; } function identifier(value: unknown, pattern: RegExp): string { if (typeof value !== 'string' || !pattern.test(value)) throw new InvalidLearningDataError(); return value; } function hash(value: unknown): string { return identifier(value, SHA256_PATTERN); } function projectCapabilities(value: unknown): Record { const capabilities = asRecord(value); if (capabilities.modular === true) { if (capabilities.orderedModules !== true || capabilities.productionStagePerModule !== true) { throw new InvalidLearningDataError(); } return { modular: true, orderedModules: true, productionStagePerModule: true }; } if (capabilities.modular !== undefined && capabilities.modular !== false) throw new InvalidLearningDataError(); const sceneKinds = boundedArray(capabilities.sceneKinds, 4).map((kind) => { if (!['slide', 'interactive', 'quiz', 'pbl'].includes(String(kind))) throw new InvalidLearningDataError(); return kind as string; }); if (new Set(sceneKinds).size !== sceneKinds.length || typeof capabilities.hasAudio !== 'boolean' || typeof capabilities.hasWhiteboard !== 'boolean' || typeof capabilities.hasAgent !== 'boolean') { throw new InvalidLearningDataError(); } return { ...(capabilities.modular === false ? { modular: false } : {}), sceneKinds, hasAudio: capabilities.hasAudio, hasWhiteboard: capabilities.hasWhiteboard, hasAgent: capabilities.hasAgent, }; } function projectCourseModule(value: unknown): Record { const module = asRecord(value); return { moduleId: identifier(module.moduleId, MODULE_ID_PATTERN), title: boundedString(module.title, 300), summary: nullableString(module.summary, 2_000), sceneCount: boundedInteger(module.sceneCount, 0, 100_000), contentHash: hash(module.contentHash), }; } function projectCourse(value: unknown): Record { const course = asRecord(value); const origin = boundedString(course.origin, 32); const status = boundedString(course.status, 32); if (!['user_single', 'ops_large'].includes(origin) || !['generating', 'ready', 'published', 'failed', 'archived'].includes(status)) { throw new InvalidLearningDataError(); } const modules = course.modules === undefined ? undefined : boundedArray(course.modules, MAX_MODULES).map(projectCourseModule); if (modules && new Set(modules.map((module) => module.moduleId)).size !== modules.length) { throw new InvalidLearningDataError(); } return { id: identifier(course.id, COURSE_ID_PATTERN), origin, status, title: boundedString(course.title, 300), summary: nullableString(course.summary, 2_000), language: nullableString(course.language, 64), contentHash: hash(course.contentHash), archiveSha256: hash(course.archiveSha256), archiveBytes: boundedInteger(course.archiveBytes, 1, 10 * 1024 * 1024 * 1024), formatVersion: boundedInteger(course.formatVersion, 1, 1_000), minPlayerVersion: boundedString(course.minPlayerVersion, 64), sceneCount: boundedInteger(course.sceneCount, 0, 100_000), ...(modules === undefined ? {} : { modules }), capabilities: projectCapabilities(course.capabilities), createdAt: isoTimestamp(course.createdAt), publishedAt: course.publishedAt === null ? null : isoTimestamp(course.publishedAt), }; } function projectProgress(value: unknown): Record { const progress = asRecord(value); if (typeof progress.completed !== 'boolean') throw new InvalidLearningDataError(); const moduleId = progress.moduleId === undefined ? undefined : progress.moduleId === null ? null : identifier(progress.moduleId, MODULE_ID_PATTERN); const moduleContentHash = progress.moduleContentHash === undefined ? undefined : progress.moduleContentHash === null ? null : hash(progress.moduleContentHash); return { courseId: identifier(progress.courseId, COURSE_ID_PATTERN), contentHash: hash(progress.contentHash), ...(moduleId === undefined ? {} : { moduleId }), ...(moduleContentHash === undefined ? {} : { moduleContentHash }), sceneOrder: boundedInteger(progress.sceneOrder, 0, 1_000_000), actionIndex: nullableInteger(progress.actionIndex, 0, 1_000_000), positionMs: nullableInteger(progress.positionMs, 0, 31_536_000_000), completed: progress.completed, updatedAt: isoTimestamp(progress.updatedAt), }; } function projectGeneration(value: unknown): Record { const generation = asRecord(value); const mode = generation.mode === undefined ? undefined : boundedString(generation.mode, 16); if (mode !== undefined && mode !== 'single' && mode !== 'large') throw new InvalidLearningDataError(); if (typeof generation.done !== 'boolean') throw new InvalidLearningDataError(); const contractVersion = generation.contractVersion === undefined ? undefined : boundedInteger(generation.contractVersion, 1, 100); const rawError = generation.error === null ? null : boundedString(generation.error, 4_000); return { ...(contractVersion === undefined ? {} : { contractVersion }), jobId: identifier(generation.jobId, JOB_ID_PATTERN), status: boundedString(generation.status, 64), ...(mode === undefined ? {} : { mode }), step: nullableString(generation.step, 128), progress: generation.progress === null ? null : boundedNumber(generation.progress, 0, 100), message: nullableString(generation.message, 2_000), scenesGenerated: nullableInteger(generation.scenesGenerated, 0, 100_000), totalScenes: nullableInteger(generation.totalScenes, 0, 100_000), courseId: generation.courseId === null ? null : identifier(generation.courseId, COURSE_ID_PATTERN), error: rawError === null ? null : '课程生成失败,请稍后重试', done: generation.done, }; } function projectGenerationOptions(value: unknown): Record { const options = asRecord(value); const booleanKeys = [ 'enableWebSearch', 'enableImageGeneration', 'enableVideoGeneration', 'enableTTS', 'interactiveMode', 'taskEngineMode', ] as const; for (const key of booleanKeys) { if (typeof options[key] !== 'boolean') throw new InvalidLearningDataError(); } return { requirement: boundedString(options.requirement, 4_000).trim(), enableWebSearch: options.enableWebSearch, enableImageGeneration: options.enableImageGeneration, enableVideoGeneration: options.enableVideoGeneration, enableTTS: options.enableTTS, interactiveMode: options.interactiveMode, taskEngineMode: options.taskEngineMode, }; } function projectProgressWrite(value: unknown): Record { const progress = asRecord(value); if (typeof progress.completed !== 'boolean') throw new InvalidLearningDataError(); const moduleId = progress.moduleId === undefined ? undefined : progress.moduleId === null ? null : identifier(progress.moduleId, MODULE_ID_PATTERN); return { contentHash: hash(progress.contentHash), ...(moduleId === undefined ? {} : { moduleId }), sceneOrder: boundedInteger(progress.sceneOrder, 0, 1_000_000), actionIndex: nullableInteger(progress.actionIndex, 0, 1_000_000), positionMs: nullableInteger(progress.positionMs, 0, 31_536_000_000), completed: progress.completed, }; } async function readBoundedJsonBody(req: IncomingMessage): Promise { const chunks: Buffer[] = []; let bytes = 0; for await (const chunk of req) { const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); bytes += buffer.byteLength; if (bytes > MAX_REQUEST_BYTES) throw new InvalidLearningDataError(); chunks.push(buffer); } const raw = Buffer.concat(chunks).toString('utf8').trim(); if (!raw) throw new InvalidLearningDataError(); try { return JSON.parse(raw) as unknown; } catch { throw new InvalidLearningDataError(); } } async function readBoundedResponseJson(response: Response): Promise { if (!response.body) throw new InvalidLearningDataError(); const reader = response.body.getReader(); const chunks: Uint8Array[] = []; let bytes = 0; try { while (true) { const { done, value } = await reader.read(); if (done) break; bytes += value.byteLength; if (bytes > MAX_RESPONSE_BYTES) throw new InvalidLearningDataError(); chunks.push(value); } } finally { reader.releaseLock(); } const combined = new Uint8Array(bytes); let offset = 0; for (const chunk of chunks) { combined.set(chunk, offset); offset += chunk.byteLength; } try { return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(combined)) as unknown; } catch { throw new InvalidLearningDataError(); } } function projectSuccess(route: LearningRoute, payload: unknown): unknown { switch (route.kind) { case 'course-list': return boundedArray(payload, MAX_COURSES).map(projectCourse); case 'course-detail': { const projected = projectCourse(payload); if (projected.id !== route.courseId) throw new InvalidLearningDataError(); return projected; } case 'generation-finalize': return projectCourse(payload); case 'progress-list': return boundedArray(payload, MAX_PROGRESS_ITEMS).map(projectProgress); case 'progress-write': { const projected = projectProgress(payload); if (projected.courseId !== route.courseId) throw new InvalidLearningDataError(); return projected; } case 'generation-start': return projectGeneration(payload); case 'generation-read': case 'generation-control': { const projected = projectGeneration(payload); if (projected.jobId !== route.jobId) throw new InvalidLearningDataError(); return projected; } } } function safeError(status: number): { status: number; code: string; error: string } { if (status === 400 || status === 422) { return { status, code: 'LEARNING_INVALID_REQUEST', error: '学习请求参数无效' }; } if (status === 401) return { status, code: 'LEARNING_AUTH_REQUIRED', error: '请先登录' }; if (status === 403) return { status, code: 'LEARNING_FORBIDDEN', error: '没有权限执行此学习操作' }; if (status === 404) return { status, code: 'LEARNING_NOT_FOUND', error: '课程或任务不存在' }; if (status === 409) return { status, code: 'LEARNING_CONFLICT', error: '课程或任务状态已变化,请刷新后重试' }; if (status === 413) return { status, code: 'LEARNING_REQUEST_TOO_LARGE', error: '学习请求内容过大' }; if (status === 429) return { status, code: 'LEARNING_RATE_LIMITED', error: '请求过于频繁,请稍后再试' }; const safeStatus = status >= 400 && status <= 599 ? status : 502; return { status: safeStatus, code: 'LEARNING_UNAVAILABLE', error: '学习服务暂时不可用' }; } function sendSafeError(res: ServerResponse, status: number): void { const error = safeError(status); sendJson(res, error.status, { success: false, ...error }); } function sendInvalidResponse(res: ServerResponse): void { sendJson(res, 502, { success: false, status: 502, code: 'LEARNING_INVALID_RESPONSE', error: '学习服务返回了无效数据', }); } export function createLearningRouteHandler(dependencies: Dependencies = {}) { const fetchImpl = dependencies.fetchImpl ?? proxyAwareFetch; const getAccessToken = dependencies.getAccessToken ?? getValidWorksSquareAccessToken; const apiBaseUrl = (dependencies.apiBaseUrl ?? WORKS_SQUARE_CONFIG.apiBaseUrl).replace(/\/+$/, ''); return async function handleLearningRoutes( req: IncomingMessage, res: ServerResponse, url: URL, _ctx: HostApiContext, ): Promise { const route = matchLearningRoute(url.pathname); if (!route) return false; const expectedMethod = route.kind === 'progress-write' ? 'PUT' : route.kind === 'generation-start' || route.kind === 'generation-control' || route.kind === 'generation-finalize' ? 'POST' : 'GET'; if (req.method !== expectedMethod) { sendJson(res, 405, { success: false, status: 405, code: 'LEARNING_METHOD_NOT_ALLOWED', error: '不支持的学习请求', }); return true; } try { let body: Record | undefined; if (route.kind === 'generation-start') body = projectGenerationOptions(await readBoundedJsonBody(req)); if (route.kind === 'progress-write') body = projectProgressWrite(await readBoundedJsonBody(req)); const token = await getAccessToken({ fetchImpl }); if (!token) { sendSafeError(res, 401); return true; } const request = (accessToken: string) => fetchImpl( `${apiBaseUrl}${route.upstreamPath}${allowedQuery(url, route)}`, { method: req.method, headers: { Accept: 'application/json', Authorization: `Bearer ${accessToken}`, ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), }, ...(body === undefined ? {} : { body: JSON.stringify(body) }), 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) { sendSafeError(res, 401); return true; } response = await request(refreshed); } if (!response.ok) { await response.body?.cancel().catch(() => undefined); sendSafeError(res, response.status); return true; } try { const payload = await readBoundedResponseJson(response); sendJson(res, response.status, { success: true, data: projectSuccess(route, payload) }); } catch { sendInvalidResponse(res); } return true; } catch (error) { if (error instanceof InvalidLearningDataError) { sendSafeError(res, 400); } else { sendSafeError(res, 502); } return true; } }; } export const handleLearningRoutes = createLearningRouteHandler();