feat(learning): replace courses with project catalog

This commit is contained in:
2026-08-20 00:08:04 +08:00
parent 2cb8a7aef4
commit 38db1589e2
56 changed files with 1741 additions and 8725 deletions

View File

@@ -1,78 +1,98 @@
import { app, dialog, type SaveDialogOptions } from 'electron';
import type { IncomingMessage, ServerResponse } from 'node:http';
import { join } from 'node:path';
import {
LEARNING_ARCHIVE_MAX_BYTES,
LEARNING_MEDIA_MAX_BYTES,
type LearningProjectDetail,
} from '../../../shared/learning';
import {
LearningProjectDownloadError,
safeLearningProjectArchiveFileName,
saveLearningProjectArchive,
} from '../../services/learning-project-download';
import {
getValidWorksSquareAccessToken,
getWorksSquareAccountBinding,
isCurrentWorksSquareAccountBinding,
type WorksSquareAccountBinding,
} from '../../services/works-square-session';
import { proxyAwareFetch } from '../../utils/proxy-fetch';
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 LOCAL_ROOT = '/api/works/learning/projects';
const UPSTREAM_ROOT = '/api/learning/projects';
const PROJECT_ID = '[A-Za-z0-9][A-Za-z0-9._-]{0,127}';
const MEDIA_ID = '[A-Za-z0-9][A-Za-z0-9._-]{0,127}';
const PROJECT_ID_PATTERN = new RegExp(`^${PROJECT_ID}$`);
const SHA256_PATTERN = /^[0-9a-f]{64}$/;
const MAX_REQUEST_BYTES = 16 * 1024;
const MEDIA_URL_PATTERN = new RegExp(`^/api/learning/projects/${PROJECT_ID}/media/${MEDIA_ID}$`);
const LOCAL_MEDIA_PATH_PATTERN = new RegExp(`^${LOCAL_ROOT}/(${PROJECT_ID})/media/(${MEDIA_ID})$`);
const LOCAL_DOWNLOAD_PATH_PATTERN = new RegExp(`^${LOCAL_ROOT}/(${PROJECT_ID})/download$`);
const LOCAL_DETAIL_PATH_PATTERN = new RegExp(`^${LOCAL_ROOT}/(${PROJECT_ID})$`);
const MAX_LIST_ITEMS = 48;
const MAX_TAGS = 16;
const MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
const MAX_COURSES = 500;
const MAX_PROGRESS_ITEMS = 5_000;
const MAX_MODULES = 500;
const TRUSTED_MEDIA_MIME_TYPES = new Set([
'image/avif',
'image/gif',
'image/jpeg',
'image/png',
'image/webp',
]);
type LearningRoute =
| { kind: 'list'; upstreamPath: string }
| { kind: 'detail'; upstreamPath: string; projectId: string }
| { kind: 'media'; upstreamPath: string; projectId: string }
| { kind: 'download'; upstreamPath: string; projectId: string };
type Dependencies = {
fetchImpl?: typeof fetch;
getAccessToken?: typeof getValidWorksSquareAccessToken;
getAccountBinding?: () => WorksSquareAccountBinding | null;
isCurrentAccountBinding?: (value: WorksSquareAccountBinding) => boolean;
apiBaseUrl?: string;
chooseDestination?: (ctx: HostApiContext, fileName: string) => Promise<string | null>;
saveArchive?: typeof saveLearningProjectArchive;
};
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)}` };
function matchRoute(pathname: string): LearningRoute | null {
if (pathname === LOCAL_ROOT) return { kind: 'list', upstreamPath: UPSTREAM_ROOT };
const media = LOCAL_MEDIA_PATH_PATTERN.exec(pathname);
if (media) {
return {
kind: 'media',
projectId: media[1],
upstreamPath: `${UPSTREAM_ROOT}/${media[1]}/media/${media[2]}`,
};
}
if (pathname === `${LOCAL_ROOT}/progress`) {
return { kind: 'progress-list', upstreamPath: `${UPSTREAM_ROOT}/progress` };
const download = LOCAL_DOWNLOAD_PATH_PATTERN.exec(pathname);
if (download) {
return {
kind: 'download',
projectId: download[1],
upstreamPath: `${UPSTREAM_ROOT}/${download[1]}`,
};
}
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 };
const detail = LOCAL_DETAIL_PATH_PATTERN.exec(pathname);
return detail
? { kind: 'detail', projectId: detail[1], upstreamPath: `${UPSTREAM_ROOT}/${detail[1]}` }
: null;
}
function allowedQuery(url: URL, route: LearningRoute): string {
if (route.kind !== 'course-list' && route.kind !== 'progress-list') return '';
function listQuery(url: URL): string {
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 cursor = url.searchParams.get('cursor')?.trim();
if (cursor && cursor.length <= 1024) query.set('cursor', cursor);
const limit = url.searchParams.get('limit')?.trim();
if (limit && /^\d{1,2}$/.test(limit) && Number(limit) >= 1 && Number(limit) <= MAX_LIST_ITEMS) {
query.set('limit', limit);
}
const encoded = query.toString();
return encoded ? `?${encoded}` : '';
}
@@ -82,21 +102,15 @@ function asRecord(value: unknown): Record<string, unknown> {
return value as Record<string, unknown>;
}
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): string {
if (typeof value !== 'string') throw new InvalidLearningDataError();
const normalized = value.trim();
if (!normalized || normalized.length > maximum) throw new InvalidLearningDataError();
return normalized;
}
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 nullableString(value: unknown, maximum: number): string | null {
return value === null ? null : boundedString(value, maximum);
}
function boundedInteger(value: unknown, minimum: number, maximum: number): number {
@@ -106,276 +120,158 @@ function boundedInteger(value: unknown, minimum: number, maximum: number): numbe
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<string, unknown> {
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<string, unknown> {
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<string, unknown> {
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<string, unknown> {
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<string, unknown> {
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<string, unknown> {
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<string, unknown> {
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<unknown> {
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();
function mediaUrl(value: unknown): string {
const url = boundedString(value, 2048);
if (MEDIA_URL_PATTERN.test(url)) return url;
let parsed: URL;
try {
return JSON.parse(raw) as unknown;
parsed = new URL(url);
} catch {
throw new InvalidLearningDataError();
}
if (parsed.protocol !== 'https:' || parsed.username || parsed.password) throw new InvalidLearningDataError();
return parsed.toString();
}
async function readBoundedResponseJson(response: Response): Promise<unknown> {
function projectImage(value: unknown): Record<string, unknown> {
const image = asRecord(value);
const width = image.width === undefined ? undefined : boundedInteger(image.width, 1, 32_768);
const height = image.height === undefined ? undefined : boundedInteger(image.height, 1, 32_768);
return {
url: mediaUrl(image.url),
alt: boundedString(image.alt, 500),
...(width === undefined ? {} : { width }),
...(height === undefined ? {} : { height }),
};
}
function projectSummary(value: unknown): Record<string, unknown> {
const project = asRecord(value);
const id = boundedString(project.id, 128);
if (!PROJECT_ID_PATTERN.test(id)) throw new InvalidLearningDataError();
const tags = Array.isArray(project.tags) && project.tags.length <= MAX_TAGS
? project.tags.map((tag) => boundedString(tag, 64))
: (() => { throw new InvalidLearningDataError(); })();
if (new Set(tags).size !== tags.length) throw new InvalidLearningDataError();
return {
id,
name: boundedString(project.name, 200),
summary: boundedString(project.summary, 2_000),
cover: projectImage(project.cover),
tags,
version: nullableString(project.version, 64),
archiveBytes: boundedInteger(project.archiveBytes, 1, LEARNING_ARCHIVE_MAX_BYTES),
publishedAt: isoTimestamp(project.publishedAt),
updatedAt: isoTimestamp(project.updatedAt),
};
}
function projectDetail(value: unknown): LearningProjectDetail {
const project = asRecord(value);
const summary = projectSummary(project);
const archiveSha256 = boundedString(project.archiveSha256, 64);
if (!SHA256_PATTERN.test(archiveSha256)) throw new InvalidLearningDataError();
const archiveFileName = boundedString(project.archiveFileName, 160);
if (!archiveFileName.toLowerCase().endsWith('.zip')) throw new InvalidLearningDataError();
return {
...summary,
readmeMarkdown: boundedString(project.readmeMarkdown, 500_000),
archiveFileName,
archiveSha256,
} as LearningProjectDetail;
}
function unwrapEnvelope(value: unknown): unknown {
const envelope = asRecord(value);
if (envelope.success !== true || envelope.data === undefined) throw new InvalidLearningDataError();
return envelope.data;
}
function projectPage(value: unknown): Record<string, unknown> {
const page = asRecord(unwrapEnvelope(value));
if (!Array.isArray(page.items) || page.items.length > MAX_LIST_ITEMS) throw new InvalidLearningDataError();
const nextCursor = page.nextCursor === null ? null : boundedString(page.nextCursor, 1024);
const total = page.total === undefined ? undefined : boundedInteger(page.total, 0, Number.MAX_SAFE_INTEGER);
return {
items: page.items.map(projectSummary),
nextCursor,
...(total === undefined ? {} : { total }),
};
}
async function readBoundedJson(response: Response): Promise<unknown> {
const declaredLength = Number(response.headers.get('content-length'));
if (Number.isFinite(declaredLength) && declaredLength > MAX_RESPONSE_BYTES) {
await response.body?.cancel().catch(() => undefined);
throw new InvalidLearningDataError();
}
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);
let size = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
size += value.byteLength;
if (size > MAX_RESPONSE_BYTES) {
await reader.cancel().catch(() => undefined);
throw new InvalidLearningDataError();
}
} finally {
reader.releaseLock();
}
const combined = new Uint8Array(bytes);
let offset = 0;
for (const chunk of chunks) {
combined.set(chunk, offset);
offset += chunk.byteLength;
chunks.push(value);
}
try {
return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(combined)) as unknown;
return JSON.parse(Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))).toString('utf8')) 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 mediaMimeType(response: Response): string | null {
const mimeType = response.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase();
return mimeType && TRUSTED_MEDIA_MIME_TYPES.has(mimeType) ? mimeType : null;
}
async function readBoundedMedia(response: Response): Promise<Buffer | null> {
const declaredLength = Number(response.headers.get('content-length'));
if (Number.isFinite(declaredLength) && declaredLength > LEARNING_MEDIA_MAX_BYTES) {
await response.body?.cancel().catch(() => undefined);
return null;
}
if (!response.body) return null;
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let size = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
size += value.byteLength;
if (size > LEARNING_MEDIA_MAX_BYTES) {
await reader.cancel().catch(() => undefined);
return null;
}
chunks.push(value);
}
return size > 0 ? Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))) : null;
}
function safeError(status: number): { status: number; code: string; error: string } {
if (status === 400 || status === 422) {
return { status, code: 'LEARNING_INVALID_REQUEST', error: '学习请求参数无效' };
}
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: '请求过于频繁,请稍后再试' };
if (status === 403) return { status, code: 'LEARNING_FORBIDDEN', error: '没有权限访问学习项目' };
if (status === 404) return { status, code: 'LEARNING_PROJECT_NOT_FOUND', error: '学习项目不存在或已下架' };
if (status === 409) return { status, code: 'LEARNING_CONFLICT', error: '学习项目状态已变化,请刷新后重试' };
const safeStatus = status >= 400 && status <= 599 ? status : 502;
return { status: safeStatus, code: 'LEARNING_UNAVAILABLE', error: '学习服务暂时不可用' };
return {
status: safeStatus,
code: 'LEARNING_UNAVAILABLE',
error: safeStatus === 429 ? '请求过于频繁,请稍后再试' : '学习项目服务暂时不可用',
};
}
function sendSafeError(res: ServerResponse, status: number): void {
@@ -388,86 +284,156 @@ function sendInvalidResponse(res: ServerResponse): void {
success: false,
status: 502,
code: 'LEARNING_INVALID_RESPONSE',
error: '学习服务返回了无效数据',
error: '学习项目服务返回了无效数据',
});
}
async function defaultChooseDestination(ctx: HostApiContext, fileName: string): Promise<string | null> {
const options: SaveDialogOptions = {
defaultPath: join(app.getPath('downloads'), fileName),
filters: [
{ name: 'ZIP 项目压缩包', extensions: ['zip'] },
{ name: '所有文件', extensions: ['*'] },
],
};
const mainWindow = ctx.mainWindow && !ctx.mainWindow.isDestroyed() ? ctx.mainWindow : null;
const selection = mainWindow
? await dialog.showSaveDialog(mainWindow, options)
: await dialog.showSaveDialog(options);
return selection.canceled || !selection.filePath ? null : selection.filePath;
}
export function createLearningRouteHandler(dependencies: Dependencies = {}) {
const fetchImpl = dependencies.fetchImpl ?? proxyAwareFetch;
const getAccessToken = dependencies.getAccessToken ?? getValidWorksSquareAccessToken;
const getAccountBinding = dependencies.getAccountBinding ?? getWorksSquareAccountBinding;
const isCurrentAccountBinding = dependencies.isCurrentAccountBinding ?? isCurrentWorksSquareAccountBinding;
const apiBaseUrl = (dependencies.apiBaseUrl ?? WORKS_SQUARE_CONFIG.apiBaseUrl).replace(/\/+$/, '');
const chooseDestination = dependencies.chooseDestination ?? defaultChooseDestination;
const saveArchive = dependencies.saveArchive ?? saveLearningProjectArchive;
async function authorizedRequest(path: string, accept: string): Promise<Response> {
const token = await getAccessToken({ fetchImpl });
if (!token) throw new LearningProjectDownloadError(401, 'LEARNING_AUTH_REQUIRED', '请先登录');
const request = (accessToken: string) => fetchImpl(`${apiBaseUrl}${path}`, {
method: 'GET',
headers: { Accept: accept, 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) throw new LearningProjectDownloadError(401, 'LEARNING_AUTH_REQUIRED', '请先登录');
response = await request(refreshed);
}
return response;
}
async function readDetail(route: Extract<LearningRoute, { kind: 'detail' | 'download' }>): Promise<LearningProjectDetail> {
const response = await authorizedRequest(route.upstreamPath, 'application/json');
if (!response.ok) {
await response.body?.cancel().catch(() => undefined);
throw new LearningProjectDownloadError(response.status, 'LEARNING_PROJECT_REQUEST_FAILED', '学习项目暂时无法读取');
}
const detail = projectDetail(unwrapEnvelope(await readBoundedJson(response)));
if (detail.id !== route.projectId) throw new InvalidLearningDataError();
return detail;
}
return async function handleLearningRoutes(
req: IncomingMessage,
res: ServerResponse,
url: URL,
_ctx: HostApiContext,
ctx: HostApiContext,
): Promise<boolean> {
const route = matchLearningRoute(url.pathname);
const route = matchRoute(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';
const expectedMethod = route.kind === 'download' ? 'POST' : 'GET';
if (req.method !== expectedMethod) {
sendJson(res, 405, {
success: false,
status: 405,
code: 'LEARNING_METHOD_NOT_ALLOWED',
error: '不支持的学习请求',
error: '不支持的学习项目请求',
});
return true;
}
try {
let body: Record<string, unknown> | 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) {
if (route.kind === 'download') {
const binding = getAccountBinding();
if (!binding) {
sendSafeError(res, 401);
return true;
}
response = await request(refreshed);
const detail = await readDetail(route);
if (!isCurrentAccountBinding(binding)) throw new LearningProjectDownloadError(409, 'LEARNING_ACCOUNT_CHANGED', '登录账号已更改,请重试');
const fileName = safeLearningProjectArchiveFileName(detail.archiveFileName, detail.id);
const destinationPath = await chooseDestination(ctx, fileName);
if (!destinationPath) {
sendJson(res, 200, { success: true, data: { status: 'cancelled' } });
return true;
}
await saveArchive({
project: detail,
destinationPath,
binding,
fetchImpl,
getAccessToken,
isCurrentAccountBinding,
apiBaseUrl,
});
sendJson(res, 200, { success: true, data: { status: 'saved' } });
return true;
}
const response = route.kind === 'list'
? await authorizedRequest(`${route.upstreamPath}${listQuery(url)}`, 'application/json')
: await authorizedRequest(route.upstreamPath, route.kind === 'media'
? 'image/avif,image/webp,image/png,image/jpeg,image/gif'
: 'application/json');
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);
if (route.kind === 'media') {
const mimeType = mediaMimeType(response);
if (!mimeType) {
await response.body?.cancel().catch(() => undefined);
sendInvalidResponse(res);
return true;
}
const bytes = await readBoundedMedia(response);
if (!bytes) {
sendInvalidResponse(res);
return true;
}
sendJson(res, 200, { dataBase64: bytes.toString('base64'), mimeType });
return true;
}
if (route.kind === 'list') {
sendJson(res, 200, { success: true, data: projectPage(await readBoundedJson(response)) });
return true;
}
const detail = projectDetail(unwrapEnvelope(await readBoundedJson(response)));
if (detail.id !== route.projectId) throw new InvalidLearningDataError();
sendJson(res, 200, { success: true, data: detail });
return true;
} catch (error) {
if (error instanceof InvalidLearningDataError) {
sendSafeError(res, 400);
sendInvalidResponse(res);
} else if (error instanceof LearningProjectDownloadError) {
sendJson(res, error.status, {
success: false,
status: error.status,
code: error.code,
error: error.message,
});
} else {
sendSafeError(res, 502);
}