import { WORKS_SQUARE_CONFIG } from '../api/works-config'; import { proxyAwareFetch } from '../utils/proxy-fetch'; import { getValidWorksSquareAccessToken } from './works-square-session'; import { LEARNING_MATERIAL_MAX_FILE_BYTES, LEARNING_MATERIAL_MAX_FILES, LEARNING_MATERIAL_MAX_TOTAL_BYTES, LEARNING_GENERATION_CONTRACT_VERSION, type LearningGeneration, type LearningGenerationOptions, type LearningGenerationUploadRequest, } from '../../shared/learning'; type Dependencies = { fetchImpl?: typeof fetch; getAccessToken?: typeof getValidWorksSquareAccessToken; apiBaseUrl?: string; }; function record(value: unknown): Record { return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; } function nullableString(value: unknown, maxLength: number): string | null { if (value === null) return null; if (typeof value !== 'string' || value.length > maxLength) throw new Error('课程任务创建失败'); return value; } function nullableInteger(value: unknown, min: number, max: number): number | null { if (value === null) return null; if (!Number.isSafeInteger(value) || Number(value) < min || Number(value) > max) { throw new Error('课程任务创建失败'); } return Number(value); } function projectGeneration(value: unknown): LearningGeneration { const generation = record(value); const contractVersion = generation.contractVersion === undefined ? undefined : Number.isSafeInteger(generation.contractVersion) && Number(generation.contractVersion) === LEARNING_GENERATION_CONTRACT_VERSION ? Number(generation.contractVersion) : null; const jobId = typeof generation.jobId === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(generation.jobId) ? generation.jobId : null; const status = typeof generation.status === 'string' && /^[a-z][a-z0-9_-]{0,63}$/.test(generation.status) ? generation.status : null; const mode = generation.mode === undefined ? undefined : generation.mode === 'single' || generation.mode === 'large' ? generation.mode : null; const progress = generation.progress === null ? null : generation.progress; const courseId = generation.courseId === null ? null : typeof generation.courseId === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(generation.courseId) ? generation.courseId : undefined; if (contractVersion === null || !jobId || !status || mode === null || typeof generation.done !== 'boolean' || courseId === undefined || (progress !== null && (typeof progress !== 'number' || !Number.isFinite(progress) || progress < 0 || progress > 100))) { throw new Error('课程任务创建失败'); } return { ...(contractVersion === undefined ? {} : { contractVersion }), jobId, status, ...(mode === undefined ? {} : { mode }), step: nullableString(generation.step, 128), progress, message: nullableString(generation.message, 2_000), scenesGenerated: nullableInteger(generation.scenesGenerated, 0, 100_000), totalScenes: nullableInteger(generation.totalScenes, 0, 100_000), courseId, error: generation.error === null ? null : (nullableString(generation.error, 2_000), '课程生成失败'), done: generation.done, }; } const INVALID_UPLOAD_ERROR = '课程任务创建失败'; const MATERIAL_ID_PATTERN = /^[A-Za-z0-9_.:-]{1,128}$/; const MIME_TYPE_PATTERN = /^[A-Za-z0-9!#$&^_.+-]+\/[A-Za-z0-9!#$&^_.+-]+$/; const MAX_MATERIAL_TIMESTAMP = 8_640_000_000_000_000; function invalidUpload(): never { throw new Error(INVALID_UPLOAD_ERROR); } function hasControlCharacter(value: string): boolean { return [...value].some((character) => { const code = character.codePointAt(0) ?? 0; return code <= 31 || code === 127; }); } function validateOptions(value: unknown): LearningGenerationOptions { const options = record(value); const rawRequirement = typeof options.requirement === 'string' ? options.requirement : ''; const requirement = rawRequirement.trim(); if (!requirement || rawRequirement.length > 4_000) invalidUpload(); const booleanKeys = [ 'enableWebSearch', 'enableImageGeneration', 'enableVideoGeneration', 'enableTTS', 'interactiveMode', 'taskEngineMode', ] as const; for (const key of booleanKeys) { if (typeof options[key] !== 'boolean') invalidUpload(); } return { requirement, enableWebSearch: options.enableWebSearch as boolean, enableImageGeneration: options.enableImageGeneration as boolean, enableVideoGeneration: options.enableVideoGeneration as boolean, enableTTS: options.enableTTS as boolean, interactiveMode: options.interactiveMode as boolean, taskEngineMode: options.taskEngineMode as boolean, }; } function validateMaterials(value: unknown): Array<{ name: string; mimeType: string; order: number; bytes: Uint8Array; }> { if (!Array.isArray(value) || value.length > LEARNING_MATERIAL_MAX_FILES) invalidUpload(); let totalBytes = 0; const ids = new Set(); const orders = new Set(); const validated = value.map((candidate) => { const material = record(candidate); const id = material.id; const name = material.name; const mimeType = material.mimeType; const size = material.size; const lastModified = material.lastModified; const order = material.order; const bytes = material.bytes; if (typeof id !== 'string' || !MATERIAL_ID_PATTERN.test(id) || ids.has(id) || typeof name !== 'string' || !name.trim() || name.length > 255 || name.includes('/') || name.includes('\\') || hasControlCharacter(name) || typeof mimeType !== 'string' || mimeType.length > 128 || !MIME_TYPE_PATTERN.test(mimeType) || !Number.isSafeInteger(size) || Number(size) <= 0 || Number(size) > LEARNING_MATERIAL_MAX_FILE_BYTES || !Number.isSafeInteger(lastModified) || Number(lastModified) < 0 || Number(lastModified) > MAX_MATERIAL_TIMESTAMP || !Number.isSafeInteger(order) || Number(order) < 1 || Number(order) > value.length || orders.has(Number(order)) || !(bytes instanceof Uint8Array) || bytes.byteLength !== Number(size)) invalidUpload(); ids.add(id); orders.add(Number(order)); totalBytes += bytes.byteLength; if (totalBytes > LEARNING_MATERIAL_MAX_TOTAL_BYTES) invalidUpload(); const projectedBytes = new Uint8Array(bytes.byteLength); projectedBytes.set(bytes); return { name, mimeType, order: Number(order), bytes: projectedBytes }; }).sort((left, right) => left.order - right.order); return validated; } export function createLearningGenerationClient(dependencies: Dependencies = {}) { const fetchImpl = dependencies.fetchImpl ?? proxyAwareFetch; const getAccessToken = dependencies.getAccessToken ?? getValidWorksSquareAccessToken; const apiBaseUrl = (dependencies.apiBaseUrl ?? WORKS_SQUARE_CONFIG.apiBaseUrl).replace(/\/+$/, ''); async function start( input: LearningGenerationUploadRequest, assertCurrentAccount: () => void = () => undefined, ): Promise { const upload = record(input); const options = validateOptions(upload.options); const materials = validateMaterials(upload.materials); assertCurrentAccount(); const token = await getAccessToken({ fetchImpl }).catch(() => { throw new Error('课程任务创建失败'); }); assertCurrentAccount(); if (!token) throw new Error('请先登录'); const form = materials.length > 0 ? new FormData() : null; if (form) { form.set('options', JSON.stringify(options)); for (const { name, mimeType, bytes } of materials) { form.append( 'materials', new Blob([bytes.buffer], { type: mimeType }), name, ); } } const request = (accessToken: string) => { assertCurrentAccount(); return fetchImpl(`${apiBaseUrl}/api/learning/generations`, { method: 'POST', headers: { Accept: 'application/json', Authorization: `Bearer ${accessToken}`, ...(form ? {} : { 'Content-Type': 'application/json' }), }, body: form ?? JSON.stringify(options), redirect: 'manual', }); }; let response = await request(token).catch(() => { throw new Error('课程任务创建失败'); }); assertCurrentAccount(); if (response.status === 401) { await response.body?.cancel().catch(() => undefined); assertCurrentAccount(); const refreshed = await getAccessToken({ fetchImpl, forceRefresh: true }).catch(() => null); assertCurrentAccount(); if (refreshed) { response = await request(refreshed).catch(() => { throw new Error('课程任务创建失败'); }); assertCurrentAccount(); } } const payload = await response.json().catch(() => null); if (!response.ok || !payload) throw new Error('课程任务创建失败'); return projectGeneration(payload); } return { start }; }