feat: integrate learning module
This commit is contained in:
134
electron/api/routes/learning.ts
Normal file
134
electron/api/routes/learning.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
import type { HostApiContext } from '../context';
|
||||
import { parseJsonBody, 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';
|
||||
|
||||
type Dependencies = {
|
||||
fetchImpl?: typeof fetch;
|
||||
getAccessToken?: typeof getValidWorksSquareAccessToken;
|
||||
apiBaseUrl?: string;
|
||||
};
|
||||
|
||||
function isLearningPath(pathname: string): boolean {
|
||||
return pathname === `${LOCAL_ROOT}/courses`
|
||||
|| pathname === `${LOCAL_ROOT}/courses/mine`
|
||||
|| pathname === `${LOCAL_ROOT}/progress`
|
||||
|| /^\/api\/works\/learning\/courses\/[^/]+$/.test(pathname)
|
||||
|| /^\/api\/works\/learning\/courses\/[^/]+\/progress$/.test(pathname)
|
||||
|| pathname === `${LOCAL_ROOT}/generations`
|
||||
|| /^\/api\/works\/learning\/generations\/[^/]+$/.test(pathname)
|
||||
|| /^\/api\/works\/learning\/generations\/[^/]+\/(?:cancel|resume|finalize)$/.test(pathname);
|
||||
}
|
||||
|
||||
function upstreamPath(pathname: string): string {
|
||||
return `${UPSTREAM_ROOT}${pathname.slice(LOCAL_ROOT.length)}`;
|
||||
}
|
||||
|
||||
function allowedQuery(url: URL): string {
|
||||
const query = new URLSearchParams();
|
||||
for (const key of ['limit', 'offset']) {
|
||||
const value = url.searchParams.get(key);
|
||||
if (value && /^\d{1,3}$/.test(value)) query.set(key, value);
|
||||
}
|
||||
const encoded = query.toString();
|
||||
return encoded ? `?${encoded}` : '';
|
||||
}
|
||||
|
||||
function errorFields(payload: unknown, status: number): { code: string; error: string } {
|
||||
const record = payload && typeof payload === 'object' && !Array.isArray(payload)
|
||||
? payload as Record<string, unknown>
|
||||
: {};
|
||||
const detail = record.detail && typeof record.detail === 'object' && !Array.isArray(record.detail)
|
||||
? record.detail as Record<string, unknown>
|
||||
: {};
|
||||
return {
|
||||
code: typeof detail.code === 'string' ? detail.code : `LEARNING_HTTP_${status}`,
|
||||
error: typeof detail.message === 'string'
|
||||
? detail.message
|
||||
: typeof record.detail === 'string'
|
||||
? record.detail
|
||||
: typeof record.error === 'string'
|
||||
? record.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> {
|
||||
if (!isLearningPath(url.pathname)) return false;
|
||||
const isProgressWrite = req.method === 'PUT'
|
||||
&& /^\/api\/works\/learning\/courses\/[^/]+\/progress$/.test(url.pathname);
|
||||
const isGenerationStart = req.method === 'POST' && url.pathname === `${LOCAL_ROOT}/generations`;
|
||||
const isGenerationFinalize = req.method === 'POST'
|
||||
&& /^\/api\/works\/learning\/generations\/[^/]+\/finalize$/.test(url.pathname);
|
||||
const isGenerationControl = req.method === 'POST'
|
||||
&& /^\/api\/works\/learning\/generations\/[^/]+\/(?:cancel|resume)$/.test(url.pathname);
|
||||
if (req.method !== 'GET' && !isProgressWrite && !isGenerationStart && !isGenerationFinalize && !isGenerationControl) {
|
||||
sendJson(res, 405, { success: false, code: 'LEARNING_METHOD_NOT_ALLOWED', error: '不支持的学习请求' });
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const token = await getAccessToken({ fetchImpl });
|
||||
if (!token) {
|
||||
sendJson(res, 401, { success: false, code: 'LEARNING_AUTH_REQUIRED', error: '请先登录' });
|
||||
return true;
|
||||
}
|
||||
const body = isProgressWrite || isGenerationStart
|
||||
? await parseJsonBody<unknown>(req)
|
||||
: undefined;
|
||||
const request = (accessToken: string) => fetchImpl(
|
||||
`${apiBaseUrl}${upstreamPath(url.pathname)}${allowedQuery(url)}`,
|
||||
{
|
||||
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) response = await request(refreshed);
|
||||
}
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok || payload === null) {
|
||||
const fields = errorFields(payload, response.status || 502);
|
||||
sendJson(res, response.status || 502, { success: false, status: response.status, ...fields });
|
||||
return true;
|
||||
}
|
||||
sendJson(res, response.status, { success: true, data: payload });
|
||||
return true;
|
||||
} catch (error) {
|
||||
sendJson(res, 502, {
|
||||
success: false,
|
||||
status: 502,
|
||||
code: 'LEARNING_UNAVAILABLE',
|
||||
error: error instanceof Error ? error.message : '学习服务暂时不可用',
|
||||
});
|
||||
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,8 @@ 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']);
|
||||
|
||||
function readRequiredString(value: unknown, field: string): string {
|
||||
if (typeof value !== 'string' || !value.trim()) {
|
||||
@@ -62,6 +71,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;
|
||||
@@ -257,6 +275,7 @@ function projectSafeProject(value: unknown): Record<string, unknown> | null {
|
||||
|
||||
const projected: Record<string, unknown> = { app_id: appId, title, summary };
|
||||
for (const field of [
|
||||
'description',
|
||||
'cover_url',
|
||||
'category',
|
||||
'age_band',
|
||||
@@ -280,6 +299,10 @@ function projectSafeProject(value: unknown): Record<string, unknown> | null {
|
||||
if (fieldValue === null || typeof fieldValue === 'string') projected[field] = fieldValue;
|
||||
}
|
||||
|
||||
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;
|
||||
projected.playable = false;
|
||||
@@ -960,16 +983,56 @@ 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 uploadProjectCover(
|
||||
accessToken: string,
|
||||
upload: ProjectCoverUpload,
|
||||
): Promise<string | null> {
|
||||
const form = new FormData();
|
||||
form.set(
|
||||
'file',
|
||||
new Blob([new Uint8Array(upload.bytes)], { type: upload.mimeType }),
|
||||
upload.fileName,
|
||||
);
|
||||
const response = await proxyAwareFetch(createWorksUrl('/api/projects/covers').toString(), {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
body: form,
|
||||
});
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel().catch(() => undefined);
|
||||
return null;
|
||||
}
|
||||
const payload = await readResponsePayload(response);
|
||||
return isRecord(payload) ? readOptionalString(payload.cover_url) ?? null : null;
|
||||
}
|
||||
|
||||
async function handlePublishProjectSource(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
@@ -984,6 +1047,7 @@ async function handlePublishProjectSource(
|
||||
|
||||
const projectId = readOptionalString(body.projectId);
|
||||
const projectMetadata = readProjectMetadata(body.project);
|
||||
const projectCover = readProjectCoverUpload(body.cover);
|
||||
if (!projectId || !projectMetadata) {
|
||||
sendPublishSourceFailure(
|
||||
res,
|
||||
@@ -993,6 +1057,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);
|
||||
@@ -1009,6 +1082,20 @@ async function handlePublishProjectSource(
|
||||
const versionName = await readAutomaticVersionName(localProject.path);
|
||||
const idempotencyKey = `makelore-${randomUUID()}`;
|
||||
|
||||
if (projectCover) {
|
||||
const coverUrl = await uploadProjectCover(accessToken, projectCover);
|
||||
if (!coverUrl) {
|
||||
sendPublishSourceFailure(
|
||||
res,
|
||||
502,
|
||||
'PROJECT_COVER_UPLOAD_REJECTED',
|
||||
'封面上传失败,请稍后重试。',
|
||||
);
|
||||
return;
|
||||
}
|
||||
projectMetadata.cover_url = coverUrl;
|
||||
}
|
||||
|
||||
const createResponse = await proxyAwareFetch(createWorksUrl('/api/projects').toString(), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -1045,7 +1132,39 @@ 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;
|
||||
}
|
||||
if (ownershipPayload.project.status !== 'published') {
|
||||
const updateResponse = await proxyAwareFetch(
|
||||
createWorksUrl(`/api/projects/mine/${encodeURIComponent(appId)}`).toString(),
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(projectMetadata),
|
||||
},
|
||||
);
|
||||
if (!updateResponse.ok && updateResponse.status !== 409) {
|
||||
await sendPublishSourceUpstreamError(
|
||||
res,
|
||||
updateResponse,
|
||||
'PROJECT_METADATA_UPDATE_REJECTED',
|
||||
'平台没有保存作品信息,请修改后重试。',
|
||||
);
|
||||
return;
|
||||
}
|
||||
await updateResponse.body?.cancel().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
const uploadResponse = await uploadSourceProjectVersion({
|
||||
|
||||
@@ -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