merge: integrate remote learning module safely
This commit is contained in:
479
electron/api/routes/learning.ts
Normal file
479
electron/api/routes/learning.ts
Normal file
@@ -0,0 +1,479 @@
|
||||
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<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new InvalidLearningDataError();
|
||||
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, 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<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();
|
||||
try {
|
||||
return JSON.parse(raw) as unknown;
|
||||
} catch {
|
||||
throw new InvalidLearningDataError();
|
||||
}
|
||||
}
|
||||
|
||||
async function readBoundedResponseJson(response: Response): Promise<unknown> {
|
||||
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<boolean> {
|
||||
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<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) {
|
||||
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();
|
||||
@@ -24,6 +24,13 @@ type CreateProjectInput = {
|
||||
type PublishProjectSourceInput = {
|
||||
projectId?: unknown;
|
||||
project?: unknown;
|
||||
cover?: unknown;
|
||||
};
|
||||
|
||||
type ProjectCoverUpload = {
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
bytes: Buffer;
|
||||
};
|
||||
|
||||
type DownloadAssetInput = {
|
||||
@@ -50,6 +57,9 @@ type AgentAvatarUploadInput = {
|
||||
|
||||
const MAX_AGENT_AVATAR_BYTES = 4 * 1024 * 1024;
|
||||
const AGENT_AVATAR_MIME_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']);
|
||||
const MAX_PROJECT_COVER_BYTES = 10 * 1024 * 1024;
|
||||
const PROJECT_COVER_MIME_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']);
|
||||
const SAFE_PROJECT_STATUSES = new Set(['draft', 'published']);
|
||||
|
||||
function readRequiredString(value: unknown, field: string): string {
|
||||
if (typeof value !== 'string' || !value.trim()) {
|
||||
@@ -62,6 +72,15 @@ function readOptionalString(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function readOptionalInteger(value: unknown, field: string, min: number, max: number): number | undefined {
|
||||
if (value === undefined || value === null || value === '') return undefined;
|
||||
const numeric = typeof value === 'number' ? value : Number(value);
|
||||
if (!Number.isInteger(numeric) || numeric < min || numeric > max) {
|
||||
throw new Error(`Invalid ${field}`);
|
||||
}
|
||||
return numeric;
|
||||
}
|
||||
|
||||
function readRequiredHeader(req: IncomingMessage, name: string): string {
|
||||
const value = req.headers[name.toLowerCase()];
|
||||
const firstValue = Array.isArray(value) ? value[0] : value;
|
||||
@@ -248,20 +267,28 @@ function readNullableStringField(
|
||||
return typeof value === 'string' ? value : undefined;
|
||||
}
|
||||
|
||||
function projectSafeProject(value: unknown): Record<string, unknown> | null {
|
||||
function projectSafeProject(
|
||||
value: unknown,
|
||||
options: { requireStatus?: boolean } = {},
|
||||
): Record<string, unknown> | null {
|
||||
if (!isRecord(value)) return null;
|
||||
const appId = readOptionalString(value.app_id);
|
||||
const title = readOptionalString(value.title);
|
||||
const summary = readOptionalString(value.summary);
|
||||
if (!appId || !title || !summary) return null;
|
||||
|
||||
const status = readOptionalString(value.status);
|
||||
if ((options.requireStatus && !status) || (status && !SAFE_PROJECT_STATUSES.has(status))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const projected: Record<string, unknown> = { app_id: appId, title, summary };
|
||||
for (const field of [
|
||||
'description',
|
||||
'cover_url',
|
||||
'category',
|
||||
'age_band',
|
||||
'difficulty',
|
||||
'status',
|
||||
'updated_at',
|
||||
'play_url',
|
||||
'runtime_url',
|
||||
@@ -279,6 +306,11 @@ function projectSafeProject(value: unknown): Record<string, unknown> | null {
|
||||
const fieldValue = value[field];
|
||||
if (fieldValue === null || typeof fieldValue === 'string') projected[field] = fieldValue;
|
||||
}
|
||||
if (status) projected.status = status;
|
||||
|
||||
if (Number.isInteger(value.creator_age)) {
|
||||
projected.creator_age = value.creator_age;
|
||||
}
|
||||
|
||||
const versionName = readOptionalString(value.version_name);
|
||||
if (value.version_name !== undefined) projected.version_name = versionName ?? null;
|
||||
@@ -356,7 +388,7 @@ function projectSafeVersion(value: unknown): Record<string, unknown> | null {
|
||||
|
||||
function projectSafeStatusPayload(value: unknown): Record<string, unknown> | null {
|
||||
if (!isRecord(value) || !Array.isArray(value.versions)) return null;
|
||||
const project = projectSafeProject(value.project);
|
||||
const project = projectSafeProject(value.project, { requireStatus: true });
|
||||
if (!project) return null;
|
||||
const versions = value.versions.map(projectSafeVersion);
|
||||
if (versions.some((version) => version === null)) return null;
|
||||
@@ -846,6 +878,10 @@ const LOCAL_PREVIEW_BINDING_WARNING = {
|
||||
code: 'LOCAL_PREVIEW_BINDING_SAVE_FAILED',
|
||||
message: '已提交云端,但本机预览绑定保存失败;可重新打开项目/重新提交。',
|
||||
} as const;
|
||||
const PUBLISHED_METADATA_PRESERVED = {
|
||||
code: 'PUBLISHED_METADATA_PRESERVED',
|
||||
message: '作品已发布;本次仅提交新版本,名称、简介、作者信息和封面保持不变。',
|
||||
} as const;
|
||||
|
||||
function createFallbackVersionName(now = new Date()): string {
|
||||
return `v${now.toISOString().replace(/\D/g, '').slice(0, 14)}`;
|
||||
@@ -960,16 +996,33 @@ function readProjectMetadata(value: unknown): Record<string, unknown> | null {
|
||||
title: readRequiredString(source.title, 'project.title'),
|
||||
summary: readRequiredString(source.summary, 'project.summary'),
|
||||
};
|
||||
for (const field of ['cover_url', 'category', 'age_band', 'difficulty'] as const) {
|
||||
const description = readOptionalString(source.description);
|
||||
if (description) metadata.description = description;
|
||||
for (const field of ['cover_url', 'category', 'age_band', 'difficulty', 'creator_name'] as const) {
|
||||
const fieldValue = readOptionalString(source[field]);
|
||||
if (fieldValue) metadata[field] = fieldValue;
|
||||
}
|
||||
const creatorAge = readOptionalInteger(source.creator_age, 'project.creator_age', 1, 150);
|
||||
if (creatorAge !== undefined) metadata.creator_age = creatorAge;
|
||||
return metadata;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readProjectCoverUpload(value: unknown): ProjectCoverUpload | null {
|
||||
if (value === undefined || value === null) return null;
|
||||
if (!isRecord(value)) return null;
|
||||
const fileName = readOptionalString(value.fileName);
|
||||
const mimeType = readOptionalString(value.mimeType)?.toLowerCase();
|
||||
const dataBase64 = readOptionalString(value.dataBase64);
|
||||
if (!fileName || !mimeType || !dataBase64 || !PROJECT_COVER_MIME_TYPES.has(mimeType)) return null;
|
||||
if (dataBase64.length > Math.ceil(MAX_PROJECT_COVER_BYTES * 4 / 3) + 4) return null;
|
||||
const bytes = Buffer.from(dataBase64, 'base64');
|
||||
if (bytes.length === 0 || bytes.length > MAX_PROJECT_COVER_BYTES) return null;
|
||||
return { fileName, mimeType, bytes };
|
||||
}
|
||||
|
||||
async function handlePublishProjectSource(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
@@ -984,7 +1037,19 @@ async function handlePublishProjectSource(
|
||||
|
||||
const projectId = readOptionalString(body.projectId);
|
||||
const projectMetadata = readProjectMetadata(body.project);
|
||||
if (!projectId || !projectMetadata) {
|
||||
const projectCover = readProjectCoverUpload(body.cover);
|
||||
const submittedProjectStatusPresent = isRecord(body.project) && body.project.status !== undefined;
|
||||
const submittedProjectStatus = isRecord(body.project)
|
||||
? readOptionalString(body.project.status)
|
||||
: undefined;
|
||||
const expectsExistingMetadata = submittedProjectStatus
|
||||
? SAFE_PROJECT_STATUSES.has(submittedProjectStatus)
|
||||
: false;
|
||||
if (
|
||||
!projectId
|
||||
|| !projectMetadata
|
||||
|| (submittedProjectStatusPresent && !expectsExistingMetadata)
|
||||
) {
|
||||
sendPublishSourceFailure(
|
||||
res,
|
||||
400,
|
||||
@@ -993,6 +1058,15 @@ async function handlePublishProjectSource(
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (body.cover !== undefined && body.cover !== null && !projectCover) {
|
||||
sendPublishSourceFailure(
|
||||
res,
|
||||
400,
|
||||
'PROJECT_COVER_INVALID',
|
||||
'封面图片无效,请重新选择 PNG、JPEG 或 WebP 图片。',
|
||||
);
|
||||
return;
|
||||
}
|
||||
const appId = projectMetadata.app_id as string;
|
||||
const localProject = (await ctx.opencodeProjectStore.listProjects())
|
||||
.find((candidate) => candidate.id === projectId);
|
||||
@@ -1008,8 +1082,72 @@ async function handlePublishProjectSource(
|
||||
await ctx.agentBrowser.preflightStaticArtifact(prepared.staticArtifact);
|
||||
const versionName = await readAutomaticVersionName(localProject.path);
|
||||
const idempotencyKey = `makelore-${randomUUID()}`;
|
||||
let existingMetadataPreserved = false;
|
||||
let publishedMetadataPreserved = false;
|
||||
|
||||
const createResponse = await proxyAwareFetch(createWorksUrl('/api/projects').toString(), {
|
||||
const ownershipPreflight = await proxyAwareFetch(
|
||||
createWorksUrl(`/api/projects/mine/${encodeURIComponent(appId)}/status`).toString(),
|
||||
{
|
||||
method: 'GET',
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
},
|
||||
);
|
||||
if (ownershipPreflight.ok) {
|
||||
const confirmedOwnership = projectSafeStatusPayload(await readResponsePayload(ownershipPreflight));
|
||||
if (!confirmedOwnership) {
|
||||
sendPublishSourceFailure(
|
||||
res,
|
||||
502,
|
||||
'PROJECT_OWNERSHIP_UNCONFIRMED',
|
||||
'这个作品的归属暂时无法确认,请稍后重试。',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!expectsExistingMetadata) {
|
||||
sendPublishSourceFailure(
|
||||
res,
|
||||
409,
|
||||
'PROJECT_METADATA_CONFLICT',
|
||||
'作品状态已变化,本次未提交版本;请重新打开发布窗口确认现有资料。',
|
||||
);
|
||||
return;
|
||||
}
|
||||
existingMetadataPreserved = true;
|
||||
publishedMetadataPreserved = (confirmedOwnership.project as Record<string, unknown>).status === 'published';
|
||||
} else if (ownershipPreflight.status === 404) {
|
||||
await ownershipPreflight.body?.cancel().catch(() => undefined);
|
||||
if (expectsExistingMetadata) {
|
||||
sendPublishSourceFailure(
|
||||
res,
|
||||
409,
|
||||
'PROJECT_METADATA_CONFLICT',
|
||||
'作品状态已变化,本次未提交版本;请重新打开发布窗口确认现有资料。',
|
||||
);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
await sendPublishSourceUpstreamError(
|
||||
res,
|
||||
ownershipPreflight,
|
||||
'PROJECT_OWNERSHIP_UNCONFIRMED',
|
||||
'这个作品的归属暂时无法确认,请稍后重试。',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (projectCover && !existingMetadataPreserved) {
|
||||
// The cover contract has no delete or atomic project attachment, so uploading
|
||||
// before a conflicting create could leave an unreferenced private object.
|
||||
sendPublishSourceFailure(
|
||||
res,
|
||||
503,
|
||||
'WORKS_SQUARE_UNAVAILABLE',
|
||||
'发布服务暂时无法安全保存封面,请稍后重试。',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const createResponse = existingMetadataPreserved ? null : await proxyAwareFetch(createWorksUrl('/api/projects').toString(), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
@@ -1017,7 +1155,7 @@ async function handlePublishProjectSource(
|
||||
},
|
||||
body: JSON.stringify(projectMetadata),
|
||||
});
|
||||
if (!createResponse.ok && createResponse.status !== 409) {
|
||||
if (createResponse && !createResponse.ok && createResponse.status !== 409) {
|
||||
await sendPublishSourceUpstreamError(
|
||||
res,
|
||||
createResponse,
|
||||
@@ -1026,9 +1164,9 @@ async function handlePublishProjectSource(
|
||||
);
|
||||
return;
|
||||
}
|
||||
await createResponse.body?.cancel().catch(() => undefined);
|
||||
await createResponse?.body?.cancel().catch(() => undefined);
|
||||
|
||||
if (createResponse.status === 409) {
|
||||
if (createResponse?.status === 409) {
|
||||
const ownershipResponse = await proxyAwareFetch(
|
||||
createWorksUrl(`/api/projects/mine/${encodeURIComponent(appId)}/status`).toString(),
|
||||
{
|
||||
@@ -1045,7 +1183,23 @@ async function handlePublishProjectSource(
|
||||
);
|
||||
return;
|
||||
}
|
||||
await ownershipResponse.body?.cancel().catch(() => undefined);
|
||||
const ownershipPayload = projectSafeStatusPayload(await readResponsePayload(ownershipResponse));
|
||||
if (!ownershipPayload) {
|
||||
sendPublishSourceFailure(
|
||||
res,
|
||||
502,
|
||||
'PROJECT_OWNERSHIP_UNCONFIRMED',
|
||||
'这个作品的归属暂时无法确认,请稍后重试。',
|
||||
);
|
||||
return;
|
||||
}
|
||||
sendPublishSourceFailure(
|
||||
res,
|
||||
409,
|
||||
'PROJECT_METADATA_CONFLICT',
|
||||
'作品状态已变化,本次未提交版本;请重新打开发布窗口确认现有资料。',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const uploadResponse = await uploadSourceProjectVersion({
|
||||
@@ -1102,6 +1256,7 @@ async function handlePublishProjectSource(
|
||||
package: rendererPackageSummary,
|
||||
upload: uploadPayload,
|
||||
...(bindingWarning ? { binding_warning: bindingWarning } : {}),
|
||||
...(publishedMetadataPreserved ? { metadata_disposition: PUBLISHED_METADATA_PRESERVED } : {}),
|
||||
});
|
||||
} finally {
|
||||
await prepared?.dispose().catch(() => undefined);
|
||||
|
||||
@@ -10,6 +10,7 @@ import { handleAuthRoutes } from './routes/auth';
|
||||
import { handleOpencodeRoutes } from './routes/opencode';
|
||||
import { handleImageWorkspaceRoutes } from './routes/image-workspace';
|
||||
import { handleImagePromptMuseumRoutes } from './routes/image-prompt-museum';
|
||||
import { handleLearningRoutes } from './routes/learning';
|
||||
import { handleWorksRoutes } from './routes/works';
|
||||
import { handleUserSyncRoutes } from './routes/user-sync';
|
||||
import { handleSettingsRoutes } from './routes/settings';
|
||||
@@ -36,6 +37,7 @@ const coreRouteHandlers: RouteHandler[] = [
|
||||
handleAuthRoutes,
|
||||
handleImageWorkspaceRoutes,
|
||||
handleImagePromptMuseumRoutes,
|
||||
handleLearningRoutes,
|
||||
handleWorksRoutes,
|
||||
handleAgentBrowserRoutes,
|
||||
handleUserSyncRoutes,
|
||||
|
||||
Reference in New Issue
Block a user