需求:将登录续期、一键提交审核和精确 Release 真机预览合为可推进主分支的完整客户端链路。 实现:提交成功后保存安全版本映射,映射失败不误报上传失败;同步新会话测试契约和集成态项目文档。
1153 lines
39 KiB
TypeScript
1153 lines
39 KiB
TypeScript
import type { IncomingMessage, ServerResponse } from 'http';
|
|
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';
|
|
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';
|
|
import { logger } from '../../utils/logger';
|
|
|
|
type CreateProjectInput = {
|
|
accessToken?: unknown;
|
|
project?: unknown;
|
|
};
|
|
|
|
type UploadProjectVersionInput = {
|
|
accessToken?: unknown;
|
|
projectId?: unknown;
|
|
versionName?: unknown;
|
|
changeLog?: unknown;
|
|
zipFilePath?: unknown;
|
|
};
|
|
|
|
type PublishProjectSourceInput = {
|
|
projectId?: unknown;
|
|
project?: unknown;
|
|
};
|
|
|
|
type DownloadAssetInput = {
|
|
projectId?: unknown;
|
|
};
|
|
|
|
type SpeechTranscriptionInput = {
|
|
accessToken?: unknown;
|
|
audioBase64?: unknown;
|
|
fileName?: unknown;
|
|
mimeType?: unknown;
|
|
language?: unknown;
|
|
prompt?: unknown;
|
|
model?: unknown;
|
|
};
|
|
|
|
type ImageGenerationInput = Record<string, unknown>;
|
|
type AgentProfileUpdateInput = Record<string, unknown>;
|
|
|
|
function readRequiredString(value: unknown, field: string): string {
|
|
if (typeof value !== 'string' || !value.trim()) {
|
|
throw new Error(`Missing ${field}`);
|
|
}
|
|
return value.trim();
|
|
}
|
|
|
|
function readOptionalString(value: unknown): string | undefined {
|
|
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
|
}
|
|
|
|
function resolveProjectFilePath(projectPath: string, value: string): string {
|
|
return isAbsolute(value) || /^[A-Za-z]:[\\/]/.test(value) ? value : resolve(projectPath, value);
|
|
}
|
|
|
|
function readRequiredHeader(req: IncomingMessage, name: string): string {
|
|
const value = req.headers[name.toLowerCase()];
|
|
const firstValue = Array.isArray(value) ? value[0] : value;
|
|
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)) {
|
|
throw new Error('Works Square API base URL must start with http:// or https://');
|
|
}
|
|
return apiBase;
|
|
}
|
|
|
|
function createWorksUrl(pathname: string): URL {
|
|
return new URL(`${normalizeWorksBase()}${pathname}`);
|
|
}
|
|
|
|
async function readResponsePayload(response: Response): Promise<unknown> {
|
|
const text = await response.text();
|
|
if (!text.trim()) return null;
|
|
try {
|
|
return JSON.parse(text) as unknown;
|
|
} catch {
|
|
return text;
|
|
}
|
|
}
|
|
|
|
function getErrorMessage(payload: unknown, fallback: string): string {
|
|
if (payload && typeof payload === 'object') {
|
|
const record = payload as Record<string, unknown>;
|
|
for (const field of ['msg', 'message', 'error_description', 'error', 'detail']) {
|
|
const value = record[field];
|
|
if (typeof value === 'string' && value.trim()) {
|
|
return value;
|
|
}
|
|
}
|
|
}
|
|
if (typeof payload === 'string' && payload.trim()) {
|
|
return payload;
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
async function sendUpstreamError(
|
|
res: ServerResponse,
|
|
response: Response,
|
|
fallback: string,
|
|
): Promise<void> {
|
|
const payload = await readResponsePayload(response);
|
|
sendJson(res, response.status >= 400 && response.status < 500 ? response.status : 502, {
|
|
success: false,
|
|
status: response.status,
|
|
error: getErrorMessage(payload, fallback),
|
|
});
|
|
}
|
|
|
|
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()) {
|
|
target.searchParams.set(name, value.trim());
|
|
}
|
|
}
|
|
|
|
function unwrapPayload(payload: unknown, field: string): unknown {
|
|
if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
|
|
const record = payload as Record<string, unknown>;
|
|
if (field in record) return record[field];
|
|
}
|
|
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');
|
|
appendOptionalSearchParam(upstreamUrl, url.searchParams, 'category');
|
|
appendOptionalSearchParam(upstreamUrl, url.searchParams, 'limit');
|
|
|
|
const response = await proxyAwareFetch(upstreamUrl.toString(), { method: 'GET' });
|
|
if (!response.ok) {
|
|
await sendUpstreamError(res, response, `Works Square list failed (${response.status})`);
|
|
return;
|
|
}
|
|
|
|
sendJson(res, 200, { success: true, page: await readResponsePayload(response) });
|
|
}
|
|
|
|
async function handleGetProject(res: ServerResponse, appId: string): Promise<void> {
|
|
const upstreamUrl = createWorksUrl(`/api/projects/${encodeURIComponent(appId)}`);
|
|
const response = await proxyAwareFetch(upstreamUrl.toString(), { method: 'GET' });
|
|
if (!response.ok) {
|
|
await sendUpstreamError(res, response, `Works Square detail failed (${response.status})`);
|
|
return;
|
|
}
|
|
|
|
sendJson(res, 200, { success: true, project: await readResponsePayload(response) });
|
|
}
|
|
|
|
async function handleListAssets(res: ServerResponse, url: URL): Promise<void> {
|
|
const upstreamUrl = createWorksUrl('/api/assets');
|
|
appendOptionalSearchParam(upstreamUrl, url.searchParams, 'q');
|
|
appendOptionalSearchParam(upstreamUrl, url.searchParams, 'category');
|
|
appendOptionalSearchParam(upstreamUrl, url.searchParams, 'tag');
|
|
appendOptionalSearchParam(upstreamUrl, url.searchParams, 'source');
|
|
appendOptionalSearchParam(upstreamUrl, url.searchParams, 'limit');
|
|
|
|
const response = await proxyAwareFetch(upstreamUrl.toString(), { method: 'GET' });
|
|
if (!response.ok) {
|
|
await sendUpstreamError(res, response, `Works Square asset list failed (${response.status})`);
|
|
return;
|
|
}
|
|
|
|
sendJson(res, 200, { success: true, assets: await readResponsePayload(response) });
|
|
}
|
|
|
|
async function handleGetAsset(res: ServerResponse, slug: string): Promise<void> {
|
|
const upstreamUrl = createWorksUrl(`/api/assets/${encodeURIComponent(slug)}`);
|
|
const response = await proxyAwareFetch(upstreamUrl.toString(), { method: 'GET' });
|
|
if (!response.ok) {
|
|
await sendUpstreamError(res, response, `Works Square asset detail failed (${response.status})`);
|
|
return;
|
|
}
|
|
|
|
sendJson(res, 200, { success: true, asset: await readResponsePayload(response) });
|
|
}
|
|
|
|
function assetZipFileName(slug: string): string {
|
|
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(slug)) {
|
|
throw new Error('Invalid asset slug');
|
|
}
|
|
return `${slug}.zip`;
|
|
}
|
|
|
|
function isRedirectStatus(status: number): boolean {
|
|
return status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
|
|
}
|
|
|
|
async function readArchiveBytesFromDownloadUrl(downloadUrl: string): Promise<Buffer> {
|
|
let currentUrl = downloadUrl;
|
|
for (let redirects = 0; redirects <= 5; redirects += 1) {
|
|
const response = await proxyAwareFetch(currentUrl, { method: 'GET', redirect: 'manual' });
|
|
if (isRedirectStatus(response.status)) {
|
|
const location = response.headers.get('location');
|
|
if (!location) {
|
|
throw new Error(`Works Square asset download redirect missing Location (${response.status})`);
|
|
}
|
|
currentUrl = new URL(location, currentUrl).toString();
|
|
continue;
|
|
}
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Works Square asset download failed (${response.status})`);
|
|
}
|
|
|
|
return Buffer.from(await response.arrayBuffer());
|
|
}
|
|
|
|
throw new Error('Works Square asset download redirected too many times');
|
|
}
|
|
|
|
async function handleDownloadAssetToProject(
|
|
req: IncomingMessage,
|
|
res: ServerResponse,
|
|
slug: string,
|
|
ctx: HostApiContext,
|
|
): Promise<void> {
|
|
const body = await parseJsonBody<DownloadAssetInput>(req);
|
|
const projectId = readRequiredString(body.projectId, 'projectId');
|
|
const fileName = assetZipFileName(slug);
|
|
const projects = await ctx.opencodeProjectStore.listProjects();
|
|
const project = projects.find((candidate) => candidate.id === projectId);
|
|
if (!project) {
|
|
throw new Error('Project not found');
|
|
}
|
|
|
|
const projectStat = await stat(project.path);
|
|
if (!projectStat.isDirectory()) {
|
|
throw new Error('Project path must point to a directory');
|
|
}
|
|
|
|
const archiveBytes = await readArchiveBytesFromDownloadUrl(
|
|
createWorksUrl(`/api/assets/${encodeURIComponent(slug)}/download`).toString(),
|
|
);
|
|
const relativePath = `assets/resource-square/${fileName}`;
|
|
const targetDir = join(project.path, 'assets', 'resource-square');
|
|
await mkdir(targetDir, { recursive: true });
|
|
const filePath = join(targetDir, fileName);
|
|
await writeFile(filePath, archiveBytes);
|
|
|
|
sendJson(res, 200, {
|
|
success: true,
|
|
download: {
|
|
slug,
|
|
filePath,
|
|
relativePath,
|
|
bytesWritten: archiveBytes.byteLength,
|
|
},
|
|
});
|
|
}
|
|
|
|
async function handleCreateProject(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
const body = await parseJsonBody<CreateProjectInput>(req);
|
|
const accessToken = readRequiredString(body.accessToken, 'accessToken');
|
|
if (!body.project || typeof body.project !== 'object' || Array.isArray(body.project)) {
|
|
throw new Error('Missing project');
|
|
}
|
|
|
|
const response = await proxyAwareFetch(createWorksUrl('/api/projects').toString(), {
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify(body.project),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
await sendUpstreamError(res, response, `Works Square project create failed (${response.status})`);
|
|
return;
|
|
}
|
|
|
|
sendJson(res, response.status, { success: true, project: await readResponsePayload(response) });
|
|
}
|
|
|
|
async function handleListMyProjects(
|
|
req: IncomingMessage,
|
|
res: ServerResponse,
|
|
url: URL,
|
|
): Promise<void> {
|
|
const accessToken = readRequiredHeader(req, 'x-niancode-access-token');
|
|
const upstreamUrl = createWorksUrl('/api/projects/mine');
|
|
appendOptionalSearchParam(upstreamUrl, url.searchParams, 'limit');
|
|
|
|
const response = await proxyAwareFetch(upstreamUrl.toString(), {
|
|
method: 'GET',
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
},
|
|
});
|
|
|
|
if (!response.ok) {
|
|
await sendUpstreamError(res, response, `Works Square my projects failed (${response.status})`);
|
|
return;
|
|
}
|
|
|
|
sendJson(res, response.status, { success: true, page: await readResponsePayload(response) });
|
|
}
|
|
|
|
async function handleGetBillingTokenUsage(
|
|
req: IncomingMessage,
|
|
res: ServerResponse,
|
|
): Promise<void> {
|
|
const accessToken = readRequiredHeader(req, 'x-niancode-access-token');
|
|
const response = await proxyAwareFetch(createWorksUrl('/api/billing/token-usage').toString(), {
|
|
method: 'GET',
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
},
|
|
});
|
|
|
|
if (!response.ok) {
|
|
await sendUpstreamError(res, response, `Works Square token usage failed (${response.status})`);
|
|
return;
|
|
}
|
|
|
|
sendJson(res, response.status, { success: true, usage: await readResponsePayload(response) });
|
|
}
|
|
|
|
async function handleAgentProfile(
|
|
req: IncomingMessage,
|
|
res: ServerResponse,
|
|
): Promise<void> {
|
|
const accessToken = readRequiredHeader(req, 'x-niancode-access-token');
|
|
const body = req.method === 'PUT'
|
|
? await parseJsonBody<AgentProfileUpdateInput>(req)
|
|
: undefined;
|
|
const response = await proxyAwareFetch(createWorksUrl('/api/user/agent-profile').toString(), {
|
|
method: req.method,
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
...(body ? { 'Content-Type': 'application/json' } : {}),
|
|
},
|
|
...(body ? { body: JSON.stringify(body) } : {}),
|
|
});
|
|
const payload = await readResponsePayload(response);
|
|
|
|
if (!response.ok) {
|
|
const detail = payload && typeof payload === 'object' && !Array.isArray(payload)
|
|
? (payload as Record<string, unknown>).detail
|
|
: undefined;
|
|
// Keep the local Host API response successful so the renderer can inspect
|
|
// the upstream status and conflict detail instead of losing it in the
|
|
// generic Host API error parser.
|
|
sendJson(res, 200, {
|
|
success: false,
|
|
status: response.status,
|
|
error: getErrorMessage(payload, `Agent Profile request failed (${response.status})`),
|
|
...(detail === undefined ? {} : { detail }),
|
|
});
|
|
return;
|
|
}
|
|
|
|
sendJson(res, response.status, { success: true, profile: payload });
|
|
}
|
|
|
|
async function handleSubmitImageGeneration(
|
|
req: IncomingMessage,
|
|
res: ServerResponse,
|
|
): Promise<void> {
|
|
const accessToken = readRequiredHeader(req, 'x-niancode-access-token');
|
|
const body = await parseJsonBody<ImageGenerationInput>(req);
|
|
const response = await proxyAwareFetch(createWorksUrl('/api/ai-gateway/images/generations').toString(), {
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify(body),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
await sendUpstreamError(res, response, `Works Square image generation submit failed (${response.status})`);
|
|
return;
|
|
}
|
|
|
|
const payload = await readResponsePayload(response);
|
|
sendJson(res, response.status, {
|
|
success: true,
|
|
job: unwrapPayload(payload, 'job'),
|
|
});
|
|
}
|
|
|
|
async function handleGetImageGenerationTask(
|
|
req: IncomingMessage,
|
|
res: ServerResponse,
|
|
taskId: string,
|
|
): Promise<void> {
|
|
const accessToken = readRequiredHeader(req, 'x-niancode-access-token');
|
|
const response = await proxyAwareFetch(
|
|
createWorksUrl(`/api/ai-gateway/images/tasks/${encodeURIComponent(taskId)}`).toString(),
|
|
{
|
|
method: 'GET',
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
},
|
|
},
|
|
);
|
|
|
|
if (!response.ok) {
|
|
await sendUpstreamError(res, response, `Works Square image generation task failed (${response.status})`);
|
|
return;
|
|
}
|
|
|
|
const payload = await readResponsePayload(response);
|
|
sendJson(res, response.status, {
|
|
success: true,
|
|
job: unwrapPayload(payload, 'job'),
|
|
});
|
|
}
|
|
|
|
async function handleGetMyProjectStatus(
|
|
req: IncomingMessage,
|
|
res: ServerResponse,
|
|
appId: string,
|
|
): Promise<void> {
|
|
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(),
|
|
{
|
|
method: 'GET',
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
},
|
|
},
|
|
);
|
|
|
|
if (!response.ok) {
|
|
await sendPublishSourceUpstreamError(
|
|
res,
|
|
response,
|
|
'PROJECT_STATUS_UNAVAILABLE',
|
|
'暂时无法获取作品处理状态,请稍后重试。',
|
|
);
|
|
return;
|
|
}
|
|
|
|
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(
|
|
body: UploadProjectVersionInput,
|
|
ctx: HostApiContext,
|
|
appId: string,
|
|
): Promise<FormData> {
|
|
const versionName = readRequiredString(body.versionName, 'versionName');
|
|
const changeLog = readRequiredString(body.changeLog, 'changeLog');
|
|
const zipFilePath = readRequiredString(body.zipFilePath, 'zipFilePath');
|
|
const projectId = readRequiredString(body.projectId, 'projectId');
|
|
const project = (await ctx.opencodeProjectStore.listProjects()).find((item) => item.id === projectId);
|
|
if (!project) throw new Error('Project not found');
|
|
const publish = await readWorksPublishFile(project.path);
|
|
if (publish.status !== 'ready') throw new Error('BLOCKED: works-publish.json is missing or invalid');
|
|
if (publish.publish.app_id !== appId) throw new Error('BLOCKED: upload app id does not match project publish data');
|
|
const resolvedZipPath = resolveProjectFilePath(project.path, zipFilePath);
|
|
const publishZipPath = resolveProjectFilePath(project.path, publish.publish.zip_file_path);
|
|
if (normalize(publishZipPath).toLowerCase() !== normalize(resolvedZipPath).toLowerCase()) {
|
|
throw new Error('BLOCKED: upload zip path does not match project publish data');
|
|
}
|
|
const deployCheck = await readWorksDeployCheck(project.path, publish.publish);
|
|
if (deployCheck.status !== 'pass' && deployCheck.status !== 'warning') {
|
|
throw new Error(`BLOCKED: ${deployCheck.error || 'deployment checks did not pass'}`);
|
|
}
|
|
if (extname(resolvedZipPath).toLowerCase() !== '.zip') {
|
|
throw new Error('zipFilePath must point to a .zip file');
|
|
}
|
|
|
|
const archiveStat = await stat(resolvedZipPath);
|
|
if (!archiveStat.isFile()) {
|
|
throw new Error('zipFilePath must point to a file');
|
|
}
|
|
|
|
const archiveBytes = await readFile(resolvedZipPath);
|
|
const archiveBlob = new Blob([new Uint8Array(archiveBytes)], { type: 'application/zip' });
|
|
const form = new FormData();
|
|
form.set('version_name', versionName);
|
|
form.set('change_log', changeLog);
|
|
form.set('archive', archiveBlob, basename(resolvedZipPath));
|
|
return form;
|
|
}
|
|
|
|
async function handleUploadProjectVersion(
|
|
req: IncomingMessage,
|
|
res: ServerResponse,
|
|
appId: string,
|
|
ctx: HostApiContext,
|
|
): Promise<void> {
|
|
const body = await parseJsonBody<UploadProjectVersionInput>(req);
|
|
const accessToken = readRequiredString(body.accessToken, 'accessToken');
|
|
const form = await createArchiveFormData(body, ctx, appId);
|
|
|
|
const response = await proxyAwareFetch(
|
|
createWorksUrl(`/api/projects/${encodeURIComponent(appId)}/versions/upload`).toString(),
|
|
{
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
},
|
|
body: form,
|
|
},
|
|
);
|
|
|
|
if (!response.ok) {
|
|
await sendUpstreamError(res, response, `Works Square upload failed (${response.status})`);
|
|
return;
|
|
}
|
|
|
|
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;
|
|
}
|
|
if (ctx.worksCloudDeployment) {
|
|
try {
|
|
await ctx.worksCloudDeployment.recordSubmitted(projectId, {
|
|
appId,
|
|
versionId: uploadPayload.version_id,
|
|
versionName,
|
|
reviewStatus: uploadPayload.review_status,
|
|
zipSha256: packageSummary.sha256,
|
|
});
|
|
} catch {
|
|
logger.warn('[works] One-click submission succeeded, but local preview mapping could not be saved');
|
|
}
|
|
}
|
|
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';
|
|
const mimeType = readOptionalString(body.mimeType) ?? 'audio/wav';
|
|
const audioBytes = Buffer.from(audioBase64, 'base64');
|
|
if (audioBytes.length === 0) {
|
|
throw new Error('audioBase64 must contain audio data');
|
|
}
|
|
|
|
const form = new FormData();
|
|
const audioBlob = new Blob([new Uint8Array(audioBytes)], { type: mimeType });
|
|
form.set('audio', audioBlob, fileName);
|
|
|
|
const language = readOptionalString(body.language);
|
|
const prompt = readOptionalString(body.prompt);
|
|
const model = readOptionalString(body.model);
|
|
if (language) form.set('language', language);
|
|
if (prompt) form.set('prompt', prompt);
|
|
if (model) form.set('model', model);
|
|
return form;
|
|
}
|
|
|
|
async function handleSpeechTranscription(
|
|
req: IncomingMessage,
|
|
res: ServerResponse,
|
|
): Promise<void> {
|
|
const body = await parseJsonBody<SpeechTranscriptionInput>(req);
|
|
const accessToken = readRequiredString(body.accessToken, 'accessToken');
|
|
const form = createSpeechTranscriptionFormData(body);
|
|
|
|
const response = await proxyAwareFetch(createWorksUrl('/api/speech/transcriptions').toString(), {
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
},
|
|
body: form,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
await sendUpstreamError(res, response, `Works Square speech transcription failed (${response.status})`);
|
|
return;
|
|
}
|
|
|
|
sendJson(res, response.status, { success: true, transcription: await readResponsePayload(response) });
|
|
}
|
|
|
|
async function handleListProjectVersions(
|
|
req: IncomingMessage,
|
|
res: ServerResponse,
|
|
appId: string,
|
|
): Promise<void> {
|
|
const accessToken = readRequiredHeader(req, 'x-niancode-access-token');
|
|
const response = await proxyAwareFetch(
|
|
createWorksUrl(`/api/projects/${encodeURIComponent(appId)}/versions`).toString(),
|
|
{
|
|
method: 'GET',
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
},
|
|
},
|
|
);
|
|
|
|
if (!response.ok) {
|
|
await sendUpstreamError(res, response, `Works Square versions failed (${response.status})`);
|
|
return;
|
|
}
|
|
|
|
sendJson(res, response.status, { success: true, versions: await readResponsePayload(response) });
|
|
}
|
|
|
|
export async function handleWorksRoutes(
|
|
req: IncomingMessage,
|
|
res: ServerResponse,
|
|
url: URL,
|
|
ctx: HostApiContext,
|
|
): Promise<boolean> {
|
|
if (!url.pathname.startsWith('/api/works')) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
if (url.pathname === '/api/works/user/agent-profile' && (req.method === 'GET' || req.method === 'PUT')) {
|
|
await handleAgentProfile(req, res);
|
|
return true;
|
|
}
|
|
|
|
if (url.pathname === '/api/works/speech/transcriptions' && req.method === 'POST') {
|
|
await handleSpeechTranscription(req, res);
|
|
return true;
|
|
}
|
|
|
|
if (url.pathname === '/api/works/billing/token-usage' && req.method === 'GET') {
|
|
await handleGetBillingTokenUsage(req, res);
|
|
return true;
|
|
}
|
|
|
|
if (url.pathname === '/api/works/ai-gateway/images/generations' && req.method === 'POST') {
|
|
await handleSubmitImageGeneration(req, res);
|
|
return true;
|
|
}
|
|
|
|
const imageTaskPrefix = '/api/works/ai-gateway/images/tasks/';
|
|
if (url.pathname.startsWith(imageTaskPrefix) && req.method === 'GET') {
|
|
const taskId = decodeURIComponent(url.pathname.slice(imageTaskPrefix.length));
|
|
if (!taskId) {
|
|
sendJson(res, 400, { success: false, error: 'Missing task_id' });
|
|
return true;
|
|
}
|
|
await handleGetImageGenerationTask(req, res, taskId);
|
|
return true;
|
|
}
|
|
|
|
if (url.pathname === '/api/works/assets' && req.method === 'GET') {
|
|
await handleListAssets(res, url);
|
|
return true;
|
|
}
|
|
|
|
const assetDownloadMatch = url.pathname.match(/^\/api\/works\/assets\/([^/]+)\/download$/);
|
|
if (assetDownloadMatch && req.method === 'POST') {
|
|
await handleDownloadAssetToProject(req, res, decodeURIComponent(assetDownloadMatch[1]), ctx);
|
|
return true;
|
|
}
|
|
|
|
const assetDetailMatch = url.pathname.match(/^\/api\/works\/assets\/([^/]+)$/);
|
|
if (assetDetailMatch && req.method === 'GET') {
|
|
await handleGetAsset(res, decodeURIComponent(assetDetailMatch[1]));
|
|
return true;
|
|
}
|
|
|
|
if (url.pathname === '/api/works/projects' && req.method === 'GET') {
|
|
await handleListProjects(res, url);
|
|
return true;
|
|
}
|
|
|
|
if (url.pathname === '/api/works/projects' && req.method === 'POST') {
|
|
await handleCreateProject(req, res);
|
|
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;
|
|
}
|
|
|
|
const myProjectStatusMatch = url.pathname.match(/^\/api\/works\/projects\/mine\/([^/]+)\/status$/);
|
|
if (myProjectStatusMatch && req.method === 'GET') {
|
|
await handleGetMyProjectStatus(req, res, decodeURIComponent(myProjectStatusMatch[1]));
|
|
return true;
|
|
}
|
|
|
|
const detailMatch = url.pathname.match(/^\/api\/works\/projects\/([^/]+)$/);
|
|
if (detailMatch && req.method === 'GET') {
|
|
await handleGetProject(res, decodeURIComponent(detailMatch[1]));
|
|
return true;
|
|
}
|
|
|
|
const versionsMatch = url.pathname.match(/^\/api\/works\/projects\/([^/]+)\/versions$/);
|
|
if (versionsMatch && req.method === 'GET') {
|
|
await handleListProjectVersions(req, res, decodeURIComponent(versionsMatch[1]));
|
|
return true;
|
|
}
|
|
|
|
const uploadMatch = url.pathname.match(/^\/api\/works\/projects\/([^/]+)\/versions\/upload$/);
|
|
if (uploadMatch && req.method === 'POST') {
|
|
await handleUploadProjectVersion(req, res, decodeURIComponent(uploadMatch[1]), ctx);
|
|
return true;
|
|
}
|
|
|
|
sendJson(res, 404, { success: false, error: `No route for ${req.method} ${url.pathname}` });
|
|
return true;
|
|
} catch (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;
|
|
}
|
|
}
|