合并客户端一键发布链路
需求:将 Main-owned 打包提交、状态轮询和安全错误投影并入登录集成候选。 实现:合并已验证的发布功能提交,冲突保留登录续期与发布安全边界。 # Conflicts: # README.md
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
585
electron/services/project-packager.ts
Normal file
585
electron/services/project-packager.ts
Normal file
@@ -0,0 +1,585 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { Stats } from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import {
|
||||
lstat,
|
||||
mkdir,
|
||||
open,
|
||||
readdir,
|
||||
realpath,
|
||||
writeFile,
|
||||
} from 'node:fs/promises';
|
||||
import { dirname, isAbsolute, join, relative, sep } from 'node:path';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const AdmZip = require('adm-zip') as typeof import('adm-zip');
|
||||
|
||||
const GENERATED_MANIFEST = (
|
||||
'schema_version: 1\n'
|
||||
+ 'kind: web\n'
|
||||
+ 'runtime: static\n'
|
||||
+ 'build:\n'
|
||||
+ ' preset: vite\n'
|
||||
+ ' package_manager: npm\n'
|
||||
+ ' entry: index.html\n'
|
||||
);
|
||||
const FIXED_ZIP_TIME = new Date(1980, 0, 1, 0, 0, 0, 0);
|
||||
const FILE_MODE = 0o100644;
|
||||
const DEFAULT_LIMITS: ProjectPackageLimits = {
|
||||
maxFiles: 2_000,
|
||||
maxSourceBytes: 200 * 1024 * 1024,
|
||||
maxArchiveBytes: 50 * 1024 * 1024,
|
||||
};
|
||||
const REQUIRED_FILE_LIMITS = {
|
||||
'package.json': 2 * 1024 * 1024,
|
||||
'package-lock.json': 50 * 1024 * 1024,
|
||||
'index.html': 10 * 1024 * 1024,
|
||||
} as const;
|
||||
const EXCLUDED_DIRECTORY_NAMES = new Set([
|
||||
'.cache',
|
||||
'.git',
|
||||
'.gnupg',
|
||||
'.idea',
|
||||
'.kube',
|
||||
'.next',
|
||||
'.niancode',
|
||||
'.opencode',
|
||||
'.project-docs',
|
||||
'.turbo',
|
||||
'.vite',
|
||||
'.vscode',
|
||||
'.aws',
|
||||
'.docker',
|
||||
'.ssh',
|
||||
'build',
|
||||
'coverage',
|
||||
'deploy',
|
||||
'dist',
|
||||
'knowledge',
|
||||
'node_modules',
|
||||
'secrets',
|
||||
]);
|
||||
const EXCLUDED_FILE_NAMES = new Set([
|
||||
'.dockerignore',
|
||||
'.ds_store',
|
||||
'.git-credentials',
|
||||
'.netrc',
|
||||
'.npmrc',
|
||||
'art_guide.md',
|
||||
'asset_plan.md',
|
||||
'credentials.json',
|
||||
'debug.log',
|
||||
'dockerfile',
|
||||
'docker-compose.yaml',
|
||||
'docker-compose.yml',
|
||||
'findings.md',
|
||||
'gdd.md',
|
||||
'nginx.conf',
|
||||
'niancode.yml',
|
||||
'_netrc',
|
||||
'id_dsa',
|
||||
'id_ecdsa',
|
||||
'id_ed25519',
|
||||
'id_rsa',
|
||||
'product_overview.md',
|
||||
'progress.md',
|
||||
'release.md',
|
||||
'task_plan.md',
|
||||
'tasks.md',
|
||||
'thumbs.db',
|
||||
'version.md',
|
||||
'works-cloud-deploy.json',
|
||||
'works-deploy-check.json',
|
||||
'works-publish.json',
|
||||
'部署报告.md',
|
||||
]);
|
||||
const EXCLUDED_FILE_SUFFIXES = [
|
||||
'.crt',
|
||||
'.jks',
|
||||
'.key',
|
||||
'.keystore',
|
||||
'.log',
|
||||
'.p12',
|
||||
'.pem',
|
||||
'.pfx',
|
||||
'.p8',
|
||||
'.ppk',
|
||||
'.tfstate',
|
||||
'.tfvars',
|
||||
] as const;
|
||||
|
||||
export type ProjectPackageLimits = {
|
||||
maxFiles: number;
|
||||
maxSourceBytes: number;
|
||||
maxArchiveBytes: number;
|
||||
};
|
||||
|
||||
export type StaticProjectPackageSummary = {
|
||||
archivePath: string;
|
||||
archiveName: string;
|
||||
sha256: string;
|
||||
fileCount: number;
|
||||
sourceBytes: number;
|
||||
archiveBytes: number;
|
||||
excludedCount: number;
|
||||
excludedPaths: string[];
|
||||
manifest: {
|
||||
schema_version: 1;
|
||||
kind: 'web';
|
||||
runtime: 'static';
|
||||
build: {
|
||||
preset: 'vite';
|
||||
package_manager: 'npm';
|
||||
entry: 'index.html';
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export class ProjectPackageError extends Error {
|
||||
readonly code: string;
|
||||
|
||||
constructor(code: string, message: string) {
|
||||
super(message);
|
||||
this.name = 'ProjectPackageError';
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
type PackageFile = {
|
||||
absolutePath: string;
|
||||
archivePath: string;
|
||||
size: number;
|
||||
device: number;
|
||||
inode: number;
|
||||
modifiedAtMs: number;
|
||||
changedAtMs: number;
|
||||
directoryChain: DirectoryIdentity[];
|
||||
};
|
||||
|
||||
type DirectoryIdentity = {
|
||||
path: string;
|
||||
realPath: string;
|
||||
device: number;
|
||||
inode: number;
|
||||
};
|
||||
|
||||
type PackageScan = {
|
||||
files: PackageFile[];
|
||||
sourceBytes: number;
|
||||
excludedCount: number;
|
||||
excludedPaths: string[];
|
||||
};
|
||||
|
||||
export async function createStaticProjectPackage(input: {
|
||||
projectPath: string;
|
||||
archivePath: string;
|
||||
limits?: ProjectPackageLimits;
|
||||
}): Promise<StaticProjectPackageSummary> {
|
||||
const limits = input.limits ?? DEFAULT_LIMITS;
|
||||
const projectPath = await resolveProjectDirectory(input.projectPath);
|
||||
await validateViteProject(projectPath, limits);
|
||||
const scan = await scanProject(projectPath, limits);
|
||||
const manifestBytes = Buffer.from(GENERATED_MANIFEST, 'utf8');
|
||||
const sourceBytes = scan.sourceBytes + manifestBytes.length;
|
||||
if (sourceBytes > limits.maxSourceBytes) {
|
||||
throw new ProjectPackageError(
|
||||
'PROJECT_TOO_LARGE',
|
||||
`项目文件总大小超过 ${formatBytes(limits.maxSourceBytes)} 限制`,
|
||||
);
|
||||
}
|
||||
if (scan.files.length + 1 > limits.maxFiles) {
|
||||
throw new ProjectPackageError(
|
||||
'PROJECT_TOO_MANY_FILES',
|
||||
`项目文件数超过 ${limits.maxFiles} 个限制`,
|
||||
);
|
||||
}
|
||||
|
||||
const zip = new AdmZip();
|
||||
const filesByArchivePath = new Map(scan.files.map((file) => [file.archivePath, file]));
|
||||
const archivePaths = ['niancode.yml', ...filesByArchivePath.keys()]
|
||||
.sort((left, right) => left.localeCompare(right, 'en'));
|
||||
for (const archivePath of archivePaths) {
|
||||
const data = archivePath === 'niancode.yml'
|
||||
? manifestBytes
|
||||
: await readStableFile(projectPath, filesByArchivePath.get(archivePath)!);
|
||||
zip.addFile(archivePath, data, '', FILE_MODE);
|
||||
const entry = zip.getEntry(archivePath);
|
||||
if (entry) entry.header.time = FIXED_ZIP_TIME;
|
||||
}
|
||||
|
||||
const archiveBytes = zip.toBuffer();
|
||||
if (archiveBytes.length > limits.maxArchiveBytes) {
|
||||
throw new ProjectPackageError(
|
||||
'ARCHIVE_TOO_LARGE',
|
||||
`压缩包超过 ${formatBytes(limits.maxArchiveBytes)} 限制`,
|
||||
);
|
||||
}
|
||||
await mkdir(dirname(input.archivePath), { recursive: true });
|
||||
await writeFile(input.archivePath, archiveBytes);
|
||||
|
||||
return {
|
||||
archivePath: input.archivePath,
|
||||
archiveName: 'project.zip',
|
||||
sha256: createHash('sha256').update(archiveBytes).digest('hex'),
|
||||
fileCount: archivePaths.length,
|
||||
sourceBytes,
|
||||
archiveBytes: archiveBytes.length,
|
||||
excludedCount: scan.excludedCount,
|
||||
excludedPaths: scan.excludedPaths,
|
||||
manifest: {
|
||||
schema_version: 1,
|
||||
kind: 'web',
|
||||
runtime: 'static',
|
||||
build: {
|
||||
preset: 'vite',
|
||||
package_manager: 'npm',
|
||||
entry: 'index.html',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveProjectDirectory(projectPath: string): Promise<string> {
|
||||
try {
|
||||
const resolved = await realpath(projectPath);
|
||||
const stats = await lstat(resolved);
|
||||
if (!stats.isDirectory()) throw new Error('not a directory');
|
||||
return resolved;
|
||||
} catch {
|
||||
throw new ProjectPackageError('PROJECT_NOT_FOUND', '本地项目目录不存在或无法读取');
|
||||
}
|
||||
}
|
||||
|
||||
async function validateViteProject(
|
||||
projectPath: string,
|
||||
limits: ProjectPackageLimits,
|
||||
): Promise<void> {
|
||||
const packageJson = await readRequiredJson(projectPath, 'package.json', limits);
|
||||
const packageLock = await readRequiredJson(projectPath, 'package-lock.json', limits);
|
||||
const indexHtml = await readRequiredFile(projectPath, 'index.html', limits);
|
||||
const dependencies = isRecord(packageJson.dependencies) ? packageJson.dependencies : {};
|
||||
const devDependencies = isRecord(packageJson.devDependencies) ? packageJson.devDependencies : {};
|
||||
const viteVersion = devDependencies.vite ?? dependencies.vite;
|
||||
if (typeof viteVersion !== 'string' || !viteVersion.trim()) {
|
||||
throw new ProjectPackageError('VITE_NOT_DECLARED', 'package.json 没有声明 Vite 依赖');
|
||||
}
|
||||
if (
|
||||
packageJson.packageManager !== undefined
|
||||
&& (typeof packageJson.packageManager !== 'string' || !packageJson.packageManager.startsWith('npm@'))
|
||||
) {
|
||||
throw new ProjectPackageError(
|
||||
'PACKAGE_MANAGER_UNSUPPORTED',
|
||||
'当前仅支持带 package-lock.json 的 npm 项目',
|
||||
);
|
||||
}
|
||||
if (
|
||||
!Number.isInteger(packageLock.lockfileVersion)
|
||||
|| ![2, 3].includes(packageLock.lockfileVersion as number)
|
||||
) {
|
||||
throw new ProjectPackageError(
|
||||
'LOCKFILE_UNSUPPORTED',
|
||||
'package-lock.json 必须使用 lockfileVersion 2 或 3',
|
||||
);
|
||||
}
|
||||
if (!indexHtml.toString('utf8').trim()) {
|
||||
throw new ProjectPackageError('INDEX_HTML_EMPTY', 'index.html 不能为空');
|
||||
}
|
||||
}
|
||||
|
||||
async function readRequiredJson(
|
||||
projectPath: string,
|
||||
filename: 'package.json' | 'package-lock.json',
|
||||
limits: ProjectPackageLimits,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const content = await readRequiredFile(projectPath, filename, limits);
|
||||
try {
|
||||
const value = JSON.parse(content.toString('utf8')) as unknown;
|
||||
if (!isRecord(value)) throw new Error('not an object');
|
||||
return value;
|
||||
} catch {
|
||||
throw new ProjectPackageError(
|
||||
'PROJECT_FILE_INVALID',
|
||||
`${filename} 不是有效的 JSON 对象`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function readRequiredFile(
|
||||
projectPath: string,
|
||||
filename: keyof typeof REQUIRED_FILE_LIMITS,
|
||||
limits: ProjectPackageLimits,
|
||||
): Promise<Buffer> {
|
||||
try {
|
||||
const filePath = join(projectPath, filename);
|
||||
const stats = await lstat(filePath);
|
||||
if (!stats.isFile() || stats.isSymbolicLink()) throw new Error('not a regular file');
|
||||
const maxBytes = Math.min(limits.maxSourceBytes, REQUIRED_FILE_LIMITS[filename]);
|
||||
if (stats.size > maxBytes) {
|
||||
throw new ProjectPackageError(
|
||||
'PROJECT_TOO_LARGE',
|
||||
`${filename} 超过 ${formatBytes(maxBytes)} 限制`,
|
||||
);
|
||||
}
|
||||
const rootIdentity = await readSafeDirectoryIdentity(projectPath, projectPath);
|
||||
return await readStableFile(projectPath, {
|
||||
absolutePath: filePath,
|
||||
archivePath: filename,
|
||||
size: stats.size,
|
||||
device: stats.dev,
|
||||
inode: stats.ino,
|
||||
modifiedAtMs: stats.mtimeMs,
|
||||
changedAtMs: stats.ctimeMs,
|
||||
directoryChain: [rootIdentity],
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ProjectPackageError) throw error;
|
||||
throw new ProjectPackageError('PROJECT_FILE_MISSING', `项目根目录缺少 ${filename}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function scanProject(
|
||||
projectPath: string,
|
||||
limits: ProjectPackageLimits,
|
||||
): Promise<PackageScan> {
|
||||
const files: PackageFile[] = [];
|
||||
const excludedPaths: string[] = [];
|
||||
let excludedCount = 0;
|
||||
let sourceBytes = 0;
|
||||
|
||||
async function walk(
|
||||
directory: string,
|
||||
parentChain: DirectoryIdentity[],
|
||||
): Promise<void> {
|
||||
await assertDirectoryIdentityChain(projectPath, parentChain);
|
||||
const before = await readSafeDirectoryIdentity(projectPath, directory);
|
||||
const directoryChain = [...parentChain, before];
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
await assertDirectoryIdentityChain(projectPath, directoryChain);
|
||||
entries.sort((left, right) => left.name.localeCompare(right.name, 'en'));
|
||||
for (const entry of entries) {
|
||||
await assertDirectoryIdentityChain(projectPath, directoryChain);
|
||||
const absolutePath = join(directory, entry.name);
|
||||
const archivePath = toArchivePath(relative(projectPath, absolutePath));
|
||||
const isDirectory = entry.isDirectory();
|
||||
if (isExcludedPath(archivePath, entry.name, isDirectory)) {
|
||||
excludedCount += 1;
|
||||
if (excludedPaths.length < 100) {
|
||||
excludedPaths.push(isDirectory ? `${archivePath}/` : archivePath);
|
||||
}
|
||||
await assertDirectoryIdentityChain(projectPath, directoryChain);
|
||||
continue;
|
||||
}
|
||||
if (entry.isSymbolicLink()) {
|
||||
throw new ProjectPackageError(
|
||||
'PROJECT_SYMLINK_UNSUPPORTED',
|
||||
`项目包含不支持的符号链接:${archivePath}`,
|
||||
);
|
||||
}
|
||||
validateArchivePath(archivePath);
|
||||
if (isDirectory) {
|
||||
await walk(absolutePath, directoryChain);
|
||||
await assertDirectoryIdentityChain(projectPath, directoryChain);
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile()) {
|
||||
throw new ProjectPackageError(
|
||||
'PROJECT_FILE_UNSUPPORTED',
|
||||
`项目包含不支持的文件类型:${archivePath}`,
|
||||
);
|
||||
}
|
||||
const stats = await lstat(absolutePath);
|
||||
if (!stats.isFile() || stats.isSymbolicLink()) {
|
||||
throw new ProjectPackageError(
|
||||
'PROJECT_CHANGED_DURING_PACKAGING',
|
||||
`打包过程中项目文件发生变化:${archivePath}`,
|
||||
);
|
||||
}
|
||||
sourceBytes += stats.size;
|
||||
if (sourceBytes > limits.maxSourceBytes) {
|
||||
throw new ProjectPackageError(
|
||||
'PROJECT_TOO_LARGE',
|
||||
`项目文件总大小超过 ${formatBytes(limits.maxSourceBytes)} 限制`,
|
||||
);
|
||||
}
|
||||
files.push({
|
||||
absolutePath,
|
||||
archivePath,
|
||||
size: stats.size,
|
||||
device: stats.dev,
|
||||
inode: stats.ino,
|
||||
modifiedAtMs: stats.mtimeMs,
|
||||
changedAtMs: stats.ctimeMs,
|
||||
directoryChain,
|
||||
});
|
||||
if (files.length + 1 > limits.maxFiles) {
|
||||
throw new ProjectPackageError(
|
||||
'PROJECT_TOO_MANY_FILES',
|
||||
`项目文件数超过 ${limits.maxFiles} 个限制`,
|
||||
);
|
||||
}
|
||||
await assertDirectoryIdentityChain(projectPath, directoryChain);
|
||||
}
|
||||
await assertDirectoryIdentityChain(projectPath, directoryChain);
|
||||
}
|
||||
|
||||
await walk(projectPath, []);
|
||||
return {
|
||||
files,
|
||||
sourceBytes,
|
||||
excludedCount,
|
||||
excludedPaths: excludedPaths.sort((left, right) => left.localeCompare(right, 'en')),
|
||||
};
|
||||
}
|
||||
|
||||
async function readSafeDirectoryIdentity(
|
||||
projectPath: string,
|
||||
directory: string,
|
||||
): Promise<DirectoryIdentity> {
|
||||
try {
|
||||
const stats = await lstat(directory);
|
||||
if (!stats.isDirectory() || stats.isSymbolicLink()) {
|
||||
throw new ProjectPackageError(
|
||||
'PROJECT_SYMLINK_UNSUPPORTED',
|
||||
`项目包含不支持的目录链接:${toArchivePath(relative(projectPath, directory)) || '.'}`,
|
||||
);
|
||||
}
|
||||
const resolvedDirectory = await realpath(directory);
|
||||
const relativePath = relative(projectPath, resolvedDirectory);
|
||||
if (relativePath === '..' || relativePath.startsWith(`..${sep}`) || isAbsolute(relativePath)) {
|
||||
throw new ProjectPackageError(
|
||||
'PROJECT_PATH_UNSUPPORTED',
|
||||
'项目目录指向了项目范围之外的位置',
|
||||
);
|
||||
}
|
||||
return {
|
||||
path: directory,
|
||||
device: stats.dev,
|
||||
inode: stats.ino,
|
||||
realPath: resolvedDirectory,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof ProjectPackageError) throw error;
|
||||
throw new ProjectPackageError(
|
||||
'PROJECT_CHANGED_DURING_PACKAGING',
|
||||
`打包过程中项目目录发生变化:${toArchivePath(relative(projectPath, directory)) || '.'}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function assertDirectoryIdentityChain(
|
||||
projectPath: string,
|
||||
directoryChain: DirectoryIdentity[],
|
||||
): Promise<void> {
|
||||
for (const expected of directoryChain) {
|
||||
const current = await readSafeDirectoryIdentity(projectPath, expected.path);
|
||||
if (
|
||||
current.device !== expected.device
|
||||
|| current.inode !== expected.inode
|
||||
|| current.realPath !== expected.realPath
|
||||
) {
|
||||
throw new ProjectPackageError(
|
||||
'PROJECT_CHANGED_DURING_PACKAGING',
|
||||
`打包过程中项目目录发生变化:${toArchivePath(relative(projectPath, expected.path)) || '.'}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isExcludedPath(archivePath: string, basename: string, isDirectory: boolean): boolean {
|
||||
const lowerName = basename.toLowerCase();
|
||||
if (isDirectory) return EXCLUDED_DIRECTORY_NAMES.has(lowerName);
|
||||
if (lowerName.startsWith('.env')) return true;
|
||||
if (EXCLUDED_FILE_NAMES.has(lowerName)) return true;
|
||||
const compactName = lowerName.replace(/[^a-z0-9]/g, '');
|
||||
if (
|
||||
lowerName.endsWith('.json')
|
||||
&& (
|
||||
compactName.includes('serviceaccount')
|
||||
|| compactName.includes('firebaseadmin')
|
||||
|| compactName.includes('applicationdefaultcredentials')
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (lowerName.includes('.tfstate') || lowerName.includes('.tfvars')) return true;
|
||||
return EXCLUDED_FILE_SUFFIXES.some((suffix) => lowerName.endsWith(suffix))
|
||||
|| archivePath.toLowerCase().startsWith('works-');
|
||||
}
|
||||
|
||||
function validateArchivePath(archivePath: string): void {
|
||||
const parts = archivePath.split('/');
|
||||
if (
|
||||
!archivePath
|
||||
|| archivePath.includes('\\')
|
||||
|| parts.some((part) => !part
|
||||
|| part === '.'
|
||||
|| part === '..'
|
||||
|| part.includes(':')
|
||||
|| part.endsWith(' ')
|
||||
|| part.endsWith('.'))
|
||||
) {
|
||||
throw new ProjectPackageError(
|
||||
'PROJECT_PATH_UNSUPPORTED',
|
||||
`项目包含不支持的文件路径:${archivePath}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function readStableFile(projectPath: string, file: PackageFile): Promise<Buffer> {
|
||||
await assertDirectoryIdentityChain(projectPath, file.directoryChain);
|
||||
let handle: Awaited<ReturnType<typeof open>>;
|
||||
try {
|
||||
handle = await open(file.absolutePath, 'r');
|
||||
} catch {
|
||||
throw new ProjectPackageError(
|
||||
'PROJECT_CHANGED_DURING_PACKAGING',
|
||||
`打包过程中项目文件发生变化:${file.archivePath}`,
|
||||
);
|
||||
}
|
||||
try {
|
||||
await assertDirectoryIdentityChain(projectPath, file.directoryChain);
|
||||
assertStableFileStats(file, await handle.stat());
|
||||
await assertDirectoryIdentityChain(projectPath, file.directoryChain);
|
||||
const data = await handle.readFile();
|
||||
await assertDirectoryIdentityChain(projectPath, file.directoryChain);
|
||||
assertStableFileStats(file, await handle.stat());
|
||||
await assertDirectoryIdentityChain(projectPath, file.directoryChain);
|
||||
if (data.length !== file.size) {
|
||||
throw new ProjectPackageError(
|
||||
'PROJECT_CHANGED_DURING_PACKAGING',
|
||||
`打包过程中项目文件发生变化:${file.archivePath}`,
|
||||
);
|
||||
}
|
||||
return data;
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
function assertStableFileStats(file: PackageFile, stats: Stats): void {
|
||||
if (
|
||||
!stats.isFile()
|
||||
|| stats.size !== file.size
|
||||
|| stats.dev !== file.device
|
||||
|| stats.ino !== file.inode
|
||||
|| stats.mtimeMs !== file.modifiedAtMs
|
||||
|| stats.ctimeMs !== file.changedAtMs
|
||||
) {
|
||||
throw new ProjectPackageError(
|
||||
'PROJECT_CHANGED_DURING_PACKAGING',
|
||||
`打包过程中项目文件发生变化:${file.archivePath}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function toArchivePath(value: string): string {
|
||||
return sep === '/' ? value : value.split(sep).join('/');
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function formatBytes(value: number): string {
|
||||
return value % (1024 * 1024) === 0 ? `${value / (1024 * 1024)}MB` : `${value} 字节`;
|
||||
}
|
||||
Reference in New Issue
Block a user