实现 Makelore 一键提交审核

需求:让非专业用户在项目操作区一次提交,运营审核通过后直接发布。

实现:由 Electron Main 完成安全打包、自动版本、幂等重试和状态脱敏;补齐友好失败反馈、唯一提交入口及隔离 Electron E2E fixture。
This commit is contained in:
2026-08-08 14:44:08 +08:00
parent 7b23cce67a
commit 724290e864
18 changed files with 3056 additions and 31 deletions

View File

@@ -1,5 +1,7 @@
import type { IncomingMessage, ServerResponse } from 'http';
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
import { randomUUID } from 'node:crypto';
import { lstat, mkdir, mkdtemp, open, readFile, rm, stat, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { basename, extname, isAbsolute, join, normalize, resolve } from 'node:path';
import type { HostApiContext } from '../context';
import { parseJsonBody, sendJson } from '../route-utils';
@@ -7,6 +9,11 @@ import { proxyAwareFetch } from '../../utils/proxy-fetch';
import { WORKS_SQUARE_CONFIG } from '../works-config';
import { readWorksDeployCheck } from '../../opencode/works-square-deploy-check';
import { readWorksPublishFile } from '../../opencode/works-publish-file';
import {
createStaticProjectPackage,
ProjectPackageError,
} from '../../services/project-packager';
import { getValidWorksSquareAccessToken } from '../../services/works-square-session';
type CreateProjectInput = {
accessToken?: unknown;
@@ -21,6 +28,11 @@ type UploadProjectVersionInput = {
zipFilePath?: unknown;
};
type PublishProjectSourceInput = {
projectId?: unknown;
project?: unknown;
};
type DownloadAssetInput = {
projectId?: unknown;
};
@@ -59,6 +71,11 @@ function readRequiredHeader(req: IncomingMessage, name: string): string {
return readRequiredString(firstValue, name);
}
function readOptionalHeader(req: IncomingMessage, name: string): string | undefined {
const value = req.headers[name.toLowerCase()];
return readOptionalString(Array.isArray(value) ? value[0] : value);
}
function normalizeWorksBase(value = WORKS_SQUARE_CONFIG.apiBaseUrl): string {
const apiBase = value.replace(/\/+$/, '');
if (!/^https?:\/\//i.test(apiBase)) {
@@ -110,6 +127,43 @@ async function sendUpstreamError(
});
}
function sendPublishSourceFailure(
res: ServerResponse,
status: number,
code: string,
error: string,
): void {
sendJson(res, 200, { success: false, status, code, error });
}
async function sendPublishSourceUpstreamError(
res: ServerResponse,
response: Response,
fallbackCode: string,
fallbackMessage: string,
): Promise<void> {
let code = fallbackCode;
let error = fallbackMessage;
if (response.status === 401) {
code = 'AUTH_REQUIRED';
error = '登录状态已失效,请重新登录。';
} else if (response.status === 403) {
code = 'PUBLISH_FORBIDDEN';
error = '当前账号不能发布这个作品。';
} else if (response.status === 409) {
code = 'PROJECT_SUBMISSION_CONFLICT';
error = '这个作品已有版本正在构建或审核。';
} else if (response.status === 413) {
code = 'ARCHIVE_TOO_LARGE';
error = '项目压缩包超过平台限制。';
} else if (response.status === 408 || response.status >= 500) {
code = 'WORKS_SQUARE_UNAVAILABLE';
error = '发布服务暂时不可用,请稍后重试。';
}
await response.body?.cancel().catch(() => undefined);
sendPublishSourceFailure(res, response.status, code, error);
}
function appendOptionalSearchParam(target: URL, source: URLSearchParams, name: string): void {
const value = source.get(name);
if (value !== null && value.trim()) {
@@ -125,6 +179,123 @@ function unwrapPayload(payload: unknown, field: string): unknown {
return payload;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function readNullableStringField(
source: Record<string, unknown>,
field: string,
): string | null | undefined {
const value = source[field];
if (value === undefined) return null;
if (value === null) return null;
return typeof value === 'string' ? value : undefined;
}
function projectSafeProject(value: unknown): 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 projected: Record<string, unknown> = { app_id: appId, title, summary };
for (const field of [
'cover_url',
'category',
'age_band',
'difficulty',
'status',
'updated_at',
'runtime_url',
'creator_name',
'buddy_name',
'buddy_sprite_url',
'buddy_pose_url',
'version_name',
'testing_ask',
'update_note',
'remix_note',
'how_to',
'learning_note',
'visual_alt',
]) {
const fieldValue = value[field];
if (fieldValue === null || typeof fieldValue === 'string') projected[field] = fieldValue;
}
if (typeof value.playable === 'boolean') projected.playable = value.playable;
return projected;
}
function projectSafeVersion(value: unknown): Record<string, unknown> | null {
if (!isRecord(value)) return null;
const id = readOptionalString(value.id);
const versionName = readOptionalString(value.version_name);
const reviewStatus = readOptionalString(value.review_status);
const changeLog = typeof value.change_log === 'string' ? value.change_log : null;
const createdAt = readOptionalString(value.created_at);
if (!id || !versionName || !reviewStatus || changeLog === null || !createdAt) return null;
const buildJobId = readNullableStringField(value, 'build_job_id');
const buildStatus = readNullableStringField(value, 'build_status');
const buildErrorCode = readNullableStringField(value, 'build_error_code');
const releaseId = readNullableStringField(value, 'release_id');
if (
buildJobId === undefined
|| buildStatus === undefined
|| buildErrorCode === undefined
|| releaseId === undefined
) return null;
const projected: Record<string, unknown> = {
id,
version_name: versionName,
review_status: reviewStatus,
change_log: changeLog,
build_job_id: buildJobId,
build_status: buildStatus,
build_error_code: buildErrorCode,
release_id: releaseId,
created_at: createdAt,
};
const rejectionReason = readNullableStringField(value, 'rejection_reason');
if (rejectionReason !== undefined) projected.rejection_reason = rejectionReason;
return projected;
}
function projectSafeStatusPayload(value: unknown): Record<string, unknown> | null {
if (!isRecord(value) || !Array.isArray(value.versions)) return null;
const project = projectSafeProject(value.project);
if (!project) return null;
const versions = value.versions.map(projectSafeVersion);
if (versions.some((version) => version === null)) return null;
const latestVersion = value.latest_version === null || value.latest_version === undefined
? null
: projectSafeVersion(value.latest_version);
if (value.latest_version !== null && value.latest_version !== undefined && !latestVersion) {
return null;
}
return { project, latest_version: latestVersion, versions };
}
function projectSafeUploadPayload(value: unknown): Record<string, unknown> | null {
if (!isRecord(value)) return null;
const versionId = readOptionalString(value.version_id);
const reviewStatus = readOptionalString(value.review_status);
if (!versionId || !reviewStatus) return null;
const projected: Record<string, unknown> = {
version_id: versionId,
review_status: reviewStatus,
};
for (const field of ['build_job_id', 'build_status']) {
const fieldValue = readNullableStringField(value, field);
if (fieldValue === undefined) return null;
projected[field] = fieldValue;
}
return projected;
}
async function handleListProjects(res: ServerResponse, url: URL): Promise<void> {
const upstreamUrl = createWorksUrl('/api/projects');
appendOptionalSearchParam(upstreamUrl, url.searchParams, 'q');
@@ -418,7 +589,12 @@ async function handleGetMyProjectStatus(
res: ServerResponse,
appId: string,
): Promise<void> {
const accessToken = readRequiredHeader(req, 'x-niancode-access-token');
const accessToken = readOptionalHeader(req, 'x-niancode-access-token')
?? await getValidWorksSquareAccessToken();
if (!accessToken) {
sendPublishSourceFailure(res, 401, 'AUTH_REQUIRED', '登录状态已失效,请重新登录。');
return;
}
const response = await proxyAwareFetch(
createWorksUrl(`/api/projects/mine/${encodeURIComponent(appId)}/status`).toString(),
{
@@ -430,11 +606,26 @@ async function handleGetMyProjectStatus(
);
if (!response.ok) {
await sendUpstreamError(res, response, `Works Square project status failed (${response.status})`);
await sendPublishSourceUpstreamError(
res,
response,
'PROJECT_STATUS_UNAVAILABLE',
'暂时无法获取作品处理状态,请稍后重试。',
);
return;
}
sendJson(res, response.status, { success: true, status: await readResponsePayload(response) });
const statusPayload = projectSafeStatusPayload(await readResponsePayload(response));
if (!statusPayload) {
sendPublishSourceFailure(
res,
502,
'PROJECT_STATUS_UNAVAILABLE',
'暂时无法获取作品处理状态,请稍后重试。',
);
return;
}
sendJson(res, response.status, { success: true, status: statusPayload });
}
async function createArchiveFormData(
@@ -507,6 +698,242 @@ async function handleUploadProjectVersion(
sendJson(res, response.status, { success: true, upload: await readResponsePayload(response) });
}
const SOURCE_PUBLISH_CHANGE_LOG = '通过 Makelore 一键提交';
const RETRYABLE_SOURCE_UPLOAD_STATUSES = new Set([408, 502, 503, 504]);
const VERSION_FILE_MAX_BYTES = 64 * 1024;
function createFallbackVersionName(now = new Date()): string {
return `v${now.toISOString().replace(/\D/g, '').slice(0, 14)}`;
}
async function readAutomaticVersionName(projectPath: string): Promise<string> {
let handle: Awaited<ReturnType<typeof open>> | null = null;
try {
const versionPath = join(projectPath, 'VERSION.md');
const scanned = await lstat(versionPath);
if (!scanned.isFile() || scanned.isSymbolicLink() || scanned.size > VERSION_FILE_MAX_BYTES) {
return createFallbackVersionName();
}
handle = await open(versionPath, 'r');
const before = await handle.stat();
if (
!before.isFile()
|| before.size !== scanned.size
|| before.dev !== scanned.dev
|| before.ino !== scanned.ino
|| before.mtimeMs !== scanned.mtimeMs
|| before.ctimeMs !== scanned.ctimeMs
) return createFallbackVersionName();
const versionDocument = await handle.readFile('utf8');
const after = await handle.stat();
if (
after.size !== before.size
|| after.dev !== before.dev
|| after.ino !== before.ino
|| after.mtimeMs !== before.mtimeMs
|| after.ctimeMs !== before.ctimeMs
) return createFallbackVersionName();
const currentVersion = versionDocument.match(/^Current:\s*(.+)$/m)?.[1]?.trim();
if (currentVersion && currentVersion.length <= 80) return currentVersion;
} catch {
// Imported projects do not have to contain VERSION.md.
} finally {
await handle?.close().catch(() => undefined);
}
return createFallbackVersionName();
}
function createSourceUploadForm(
archiveBytes: Buffer,
archiveName: string,
versionName: string,
): FormData {
const archiveBlob = new Blob([new Uint8Array(archiveBytes)], { type: 'application/zip' });
const form = new FormData();
form.set('version_name', versionName);
form.set('change_log', SOURCE_PUBLISH_CHANGE_LOG);
form.set('archive', archiveBlob, archiveName);
return form;
}
async function uploadSourceProjectVersion(input: {
accessToken: string;
appId: string;
archiveBytes: Buffer;
archiveName: string;
versionName: string;
idempotencyKey: string;
}): Promise<Response> {
let lastError: unknown;
for (let attempt = 0; attempt < 2; attempt += 1) {
try {
const response = await proxyAwareFetch(
createWorksUrl(`/api/projects/${encodeURIComponent(input.appId)}/versions/upload`).toString(),
{
method: 'POST',
headers: {
Authorization: `Bearer ${input.accessToken}`,
'Idempotency-Key': input.idempotencyKey,
},
body: createSourceUploadForm(
input.archiveBytes,
input.archiveName,
input.versionName,
),
},
);
if (attempt === 0 && RETRYABLE_SOURCE_UPLOAD_STATUSES.has(response.status)) {
await response.body?.cancel().catch(() => undefined);
continue;
}
return response;
} catch (error) {
lastError = error;
if (attempt === 1) throw error;
}
}
throw lastError instanceof Error ? lastError : new Error('Source upload retry exhausted');
}
function readProjectMetadata(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
const source = value as Record<string, unknown>;
try {
const metadata: Record<string, unknown> = {
app_id: readRequiredString(source.app_id, 'project.app_id'),
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 fieldValue = readOptionalString(source[field]);
if (fieldValue) metadata[field] = fieldValue;
}
return metadata;
} catch {
return null;
}
}
async function handlePublishProjectSource(
req: IncomingMessage,
res: ServerResponse,
ctx: HostApiContext,
): Promise<void> {
const body = await parseJsonBody<PublishProjectSourceInput>(req);
const accessToken = await getValidWorksSquareAccessToken();
if (!accessToken) {
sendPublishSourceFailure(res, 401, 'AUTH_REQUIRED', '登录状态已失效,请重新登录。');
return;
}
const projectId = readOptionalString(body.projectId);
const projectMetadata = readProjectMetadata(body.project);
if (!projectId || !projectMetadata) {
sendPublishSourceFailure(
res,
400,
'PROJECT_METADATA_INVALID',
'作品信息不完整,请返回项目后重试。',
);
return;
}
const appId = projectMetadata.app_id as string;
const localProject = (await ctx.opencodeProjectStore.listProjects())
.find((candidate) => candidate.id === projectId);
if (!localProject) {
sendPublishSourceFailure(res, 404, 'PROJECT_NOT_FOUND', '本地项目不存在,请重新选择项目。');
return;
}
const temporaryDirectory = await mkdtemp(join(tmpdir(), 'makelore-publish-'));
const archivePath = join(temporaryDirectory, 'project.zip');
try {
const packageSummary = await createStaticProjectPackage({
projectPath: localProject.path,
archivePath,
});
const archiveBytes = await readFile(archivePath);
const versionName = await readAutomaticVersionName(localProject.path);
const idempotencyKey = `makelore-${randomUUID()}`;
const createResponse = await proxyAwareFetch(createWorksUrl('/api/projects').toString(), {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(projectMetadata),
});
if (!createResponse.ok && createResponse.status !== 409) {
await sendPublishSourceUpstreamError(
res,
createResponse,
'PROJECT_CREATE_REJECTED',
'平台没有接受作品信息,请修改后重试。',
);
return;
}
await createResponse.body?.cancel().catch(() => undefined);
if (createResponse.status === 409) {
const ownershipResponse = await proxyAwareFetch(
createWorksUrl(`/api/projects/mine/${encodeURIComponent(appId)}/status`).toString(),
{
method: 'GET',
headers: { Authorization: `Bearer ${accessToken}` },
},
);
if (!ownershipResponse.ok) {
await sendPublishSourceUpstreamError(
res,
ownershipResponse,
'PROJECT_OWNERSHIP_UNCONFIRMED',
'这个作品 ID 已被占用,请更换后重试。',
);
return;
}
await ownershipResponse.body?.cancel().catch(() => undefined);
}
const uploadResponse = await uploadSourceProjectVersion({
accessToken,
appId,
archiveBytes,
archiveName: packageSummary.archiveName,
versionName,
idempotencyKey,
});
if (!uploadResponse.ok) {
await sendPublishSourceUpstreamError(
res,
uploadResponse,
'SOURCE_PACKAGE_REJECTED',
'项目没有通过平台检查,请修复后重试。',
);
return;
}
const uploadPayload = projectSafeUploadPayload(await readResponsePayload(uploadResponse));
if (!uploadPayload) {
sendPublishSourceFailure(
res,
502,
'WORKS_SQUARE_UNAVAILABLE',
'发布服务返回结果异常,请稍后重试。',
);
return;
}
const { archivePath: _archivePath, ...rendererPackageSummary } = packageSummary;
sendJson(res, uploadResponse.status, {
success: true,
package: rendererPackageSummary,
upload: uploadPayload,
});
} finally {
await rm(temporaryDirectory, { recursive: true, force: true }).catch(() => undefined);
}
}
function createSpeechTranscriptionFormData(body: SpeechTranscriptionInput): FormData {
const audioBase64 = readRequiredString(body.audioBase64, 'audioBase64');
const fileName = readOptionalString(body.fileName) ?? 'voice.wav';
@@ -646,6 +1073,11 @@ export async function handleWorksRoutes(
return true;
}
if (url.pathname === '/api/works/projects/publish-source' && req.method === 'POST') {
await handlePublishProjectSource(req, res, ctx);
return true;
}
if (url.pathname === '/api/works/projects/mine' && req.method === 'GET') {
await handleListMyProjects(req, res, url);
return true;
@@ -678,10 +1110,29 @@ export async function handleWorksRoutes(
sendJson(res, 404, { success: false, error: `No route for ${req.method} ${url.pathname}` });
return true;
} catch (error) {
sendJson(res, 400, {
success: false,
error: error instanceof Error ? error.message : String(error),
});
const isProjectSourcePublish = url.pathname === '/api/works/projects/publish-source';
const isProjectStatus = /^\/api\/works\/projects\/mine\/[^/]+\/status$/.test(url.pathname);
if (isProjectSourcePublish) {
const isPackageError = error instanceof ProjectPackageError;
sendPublishSourceFailure(
res,
isPackageError ? 400 : 503,
isPackageError ? error.code : 'WORKS_SQUARE_UNAVAILABLE',
isPackageError ? error.message : '发布服务暂时不可用,请稍后重试。',
);
} else if (isProjectStatus) {
sendPublishSourceFailure(
res,
503,
'PROJECT_STATUS_UNAVAILABLE',
'暂时无法获取作品处理状态,请稍后重试。',
);
} else {
sendJson(res, 400, {
success: false,
error: error instanceof Error ? error.message : String(error),
});
}
return true;
}
}