收敛客户端静态发布链路

This commit is contained in:
2026-08-10 17:04:29 +08:00
parent 2e737dee31
commit 4df047708b
37 changed files with 604 additions and 3071 deletions

View File

@@ -5,7 +5,7 @@ import { hasRendererCapability } from '../renderer-capability';
import { WORKS_SQUARE_CONFIG } from '../works-config';
import { getValidWorksSquareAccessToken } from '../../services/works-square-session';
import { proxyAwareFetch } from '../../utils/proxy-fetch';
import type { WorksCloudDeploymentRecord } from '../../../shared/works-cloud-deployment';
import type { WorksSubmissionBindingRecord } from '../../../shared/works-submission-binding';
import type { DevicePreviewSnapshot } from '../../../shared/device-preview';
type RemoteProjectSnapshot = {
@@ -66,7 +66,7 @@ function projectFromPayload(payload: unknown): RemoteProjectSnapshot | null {
return {
appId: readString(project.app_id),
playable: typeof project.playable === 'boolean' ? project.playable : null,
runtimeUrl: readString(project.runtime_url) ?? readString(project.play_url),
runtimeUrl: readString(project.play_url) ?? readString(project.runtime_url),
runtimeVersionName: readString(project.version_name),
latestVersionId: readString(latestVersion?.id) ?? readString(latestVersion?.version_id),
latestVersionName: readString(latestVersion?.version_name),
@@ -214,7 +214,7 @@ async function createOwnedReleasePreview(
function localSnapshot(
projectId: string,
deployment: WorksCloudDeploymentRecord | null,
deployment: WorksSubmissionBindingRecord | null,
): Omit<DevicePreviewSnapshot, 'state' | 'message'> {
return {
projectId,
@@ -226,19 +226,13 @@ function localSnapshot(
};
}
function buildingMessage(status: WorksCloudDeploymentRecord['status']): string {
if (status === 'waiting_for_login') return '正在等待登录状态恢复,恢复后会继续提交预览版本。';
if (status === 'uploading') return '预览包正在上传到服务端。';
return '正在等待新的预览包与安全检查完成。';
}
function normalizedReviewStatus(value: string | null): string | null {
return value?.trim().toLowerCase() || null;
}
async function resolveDevicePreview(
projectId: string,
deployment: WorksCloudDeploymentRecord | null,
deployment: WorksSubmissionBindingRecord | null,
): Promise<DevicePreviewSnapshot> {
if (!deployment) {
return {
@@ -258,19 +252,11 @@ async function resolveDevicePreview(
const base = localSnapshot(projectId, deployment);
if (deployment.status === 'failed') {
if (deployment.status === 'legacy_retired') {
return {
...base,
state: 'unavailable',
message: deployment.error || '预览版本生成失败,请重新生成。',
};
}
if (deployment.status !== 'submitted') {
return {
...base,
state: 'building',
message: buildingMessage(deployment.status),
message: deployment.message || '旧版自动部署任务已停用,请重新提交审核。',
};
}
@@ -438,8 +424,8 @@ export async function handleDevicePreviewRoutes(
return true;
}
const deployment = ctx.worksCloudDeployment
? await ctx.worksCloudDeployment.get(projectId)
const deployment = ctx.worksSubmissionBinding
? await ctx.worksSubmissionBinding.get(projectId)
: null;
const preview = await resolveDevicePreview(projectId, deployment);
sendJson(res, 200, { success: true, preview });

View File

@@ -14,8 +14,6 @@ import {
type RevertOpencodeSessionMessageInput,
type SendOpencodeSessionMessageInput,
} from '../../opencode/client';
import { readWorksPublishFile } from '../../opencode/works-publish-file';
import { readWorksDeployCheck } from '../../opencode/works-square-deploy-check';
import {
buildOpencodeRuntimeConfigSummaryFromNianCodeProviders,
type OpencodeRuntimeConfigSummary,
@@ -1128,67 +1126,6 @@ export async function handleOpencodeRoutes(
return true;
}
const worksPublishMatch = url.pathname.match(/^\/api\/opencode\/projects\/([^/]+)\/works-publish$/);
if (worksPublishMatch && req.method === 'GET') {
try {
const projectId = decodeURIComponent(worksPublishMatch[1] ?? '');
if (!projectId) throw new Error('Missing project id');
const project = await findProjectById(ctx, projectId);
if (!project) throw new Error('Project not found');
sendJson(res, 200, await readWorksPublishFile(project.path));
} catch (error) {
sendJson(res, 500, {
status: 'invalid',
error: error instanceof Error ? error.message : String(error),
});
}
return true;
}
const worksDeployCheckMatch = url.pathname.match(/^\/api\/opencode\/projects\/([^/]+)\/works-deploy-check$/);
if (worksDeployCheckMatch && req.method === 'GET') {
try {
const projectId = decodeURIComponent(worksDeployCheckMatch[1] ?? '');
if (!projectId) throw new Error('Missing project id');
const project = await findProjectById(ctx, projectId);
if (!project) throw new Error('Project not found');
const publish = await readWorksPublishFile(project.path);
sendJson(
res,
200,
await readWorksDeployCheck(project.path, publish.status === 'ready' ? publish.publish : null),
);
} catch (error) {
sendJson(res, 500, {
status: 'invalid',
filePath: '',
error: error instanceof Error ? error.message : String(error),
});
}
return true;
}
const worksCloudDeployMatch = url.pathname.match(/^\/api\/opencode\/projects\/([^/]+)\/works-cloud-deploy$/);
if (worksCloudDeployMatch && (req.method === 'GET' || req.method === 'POST')) {
try {
const projectId = decodeURIComponent(worksCloudDeployMatch[1] ?? '');
if (!projectId) throw new Error('Missing project id');
if (!ctx.worksCloudDeployment) throw new Error('Cloud deployment coordinator is unavailable');
if (req.method === 'POST') {
const deployment = await ctx.worksCloudDeployment.arm(projectId);
sendJson(res, 202, { success: true, deployment });
} else {
sendJson(res, 200, { success: true, deployment: await ctx.worksCloudDeployment.get(projectId) });
}
} catch (error) {
sendJson(res, 500, {
success: false,
error: error instanceof Error ? error.message : String(error),
});
}
return true;
}
if (url.pathname === '/api/opencode/projects/remove' && req.method === 'POST') {
try {
const body = await parseJsonBody<{ projectId?: string }>(req);

View File

@@ -2,13 +2,11 @@ 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 { join } 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,
@@ -21,14 +19,6 @@ type CreateProjectInput = {
project?: unknown;
};
type UploadProjectVersionInput = {
accessToken?: unknown;
projectId?: unknown;
versionName?: unknown;
changeLog?: unknown;
zipFilePath?: unknown;
};
type PublishProjectSourceInput = {
projectId?: unknown;
project?: unknown;
@@ -62,10 +52,6 @@ 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;
@@ -209,6 +195,7 @@ function projectSafeProject(value: unknown): Record<string, unknown> | null {
'difficulty',
'status',
'updated_at',
'play_url',
'runtime_url',
'creator_name',
'buddy_name',
@@ -629,76 +616,6 @@ async function handleGetMyProjectStatus(
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;
@@ -924,9 +841,9 @@ async function handlePublishProjectSource(
);
return;
}
if (ctx.worksCloudDeployment) {
if (ctx.worksSubmissionBinding) {
try {
await ctx.worksCloudDeployment.recordSubmitted(projectId, {
await ctx.worksSubmissionBinding.recordSubmitted(projectId, {
appId,
versionId: uploadPayload.version_id,
versionName,
@@ -1115,12 +1032,6 @@ export async function handleWorksRoutes(
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) {