856 lines
28 KiB
JavaScript
856 lines
28 KiB
JavaScript
import path from "path";
|
||
import fs from "fs";
|
||
import config from "../appConfig/index.js";
|
||
import { restartDevServer } from "../utils/build/restartDevUtils.js";
|
||
import { log } from "../utils/log/logUtils.js";
|
||
import {
|
||
ValidationError,
|
||
SystemError,
|
||
ResourceError,
|
||
FileError,
|
||
} from "../utils/error/errorHandler.js";
|
||
import { sanitizeSensitivePaths } from "../utils/common/sensitiveUtils.js";
|
||
import {
|
||
backupProjectToZip,
|
||
restoreProjectFromZip,
|
||
pruneMissingFiles,
|
||
removeEmptyDirectories,
|
||
} from "../utils/project/backupUtils.js";
|
||
import { extractSingleFileFromZip } from "../utils/common/zipUtils.js";
|
||
import {
|
||
shouldRestartForSingleFile,
|
||
shouldRestartDevServer,
|
||
} from "../utils/buildJudge/restartJudgeUtils.js";
|
||
import { resolveProjectPath } from "../utils/common/projectPathUtils.js";
|
||
|
||
/**
|
||
* 按行对比旧内容与新内容,返回合并后的内容和变更行数
|
||
* - 行内容不同视为修改
|
||
* - 旧文件多出的行视为删除
|
||
* - 新文件多出的行视为新增
|
||
* - 保持原文件换行符风格(\n 或 \r\n)
|
||
*/
|
||
function diffContentByLines(existingContent, newContentStr) {
|
||
const oldLines = existingContent.split(/\r?\n/);
|
||
const newLines = newContentStr.split(/\r?\n/);
|
||
|
||
const oldLen = oldLines.length;
|
||
const newLen = newLines.length;
|
||
const minLen = Math.min(oldLen, newLen);
|
||
|
||
let changesCount = 0;
|
||
|
||
// 1) 对齐范围内的行:不同则视为修改
|
||
for (let idx = 0; idx < minLen; idx++) {
|
||
if (oldLines[idx] !== newLines[idx]) {
|
||
oldLines[idx] = newLines[idx];
|
||
changesCount++;
|
||
}
|
||
}
|
||
|
||
// 2) old 比 new 长的部分:多出来的行视为删除,从后往前删
|
||
if (oldLen > newLen) {
|
||
for (let idx = oldLen - 1; idx >= newLen; idx--) {
|
||
oldLines.splice(idx, 1);
|
||
changesCount++;
|
||
}
|
||
}
|
||
|
||
// 3) new 比 old 长的部分:多出来的行视为新增,按顺序追加
|
||
if (newLen > oldLen) {
|
||
for (let idx = oldLen; idx < newLen; idx++) {
|
||
oldLines.push(newLines[idx]);
|
||
changesCount++;
|
||
}
|
||
}
|
||
|
||
const newline = existingContent.includes("\r\n") ? "\r\n" : "\n";
|
||
const finalContent = oldLines.join(newline);
|
||
|
||
return { finalContent, changesCount };
|
||
}
|
||
|
||
/**
|
||
* 直接使用新内容替换旧内容
|
||
* - 若内容完全一致,则 changesCount 为 0
|
||
* - 预留备用:当前未在业务中使用
|
||
*/
|
||
function replaceContentDirectly(existingContent, newContentStr) {
|
||
if (existingContent === newContentStr) {
|
||
return { finalContent: existingContent, changesCount: 0 };
|
||
}
|
||
return { finalContent: newContentStr, changesCount: -1 };
|
||
}
|
||
|
||
/**
|
||
* 部分文件更新:支持新增、删除、重命名、修改操作
|
||
* @param {string} projectId 项目ID
|
||
* @param {string} codeVersion 代码版本号
|
||
* @param {Array} files 文件操作列表
|
||
* @param {Object} req 请求对象
|
||
* @returns {Object} 更新结果
|
||
*/
|
||
async function specifiedFilesUpdate(
|
||
projectId,
|
||
codeVersion,
|
||
files,
|
||
req,
|
||
isolationContext = {}
|
||
) {
|
||
const startTime = Date.now();
|
||
if (!projectId) {
|
||
throw new ValidationError("Project ID cannot be empty", { field: "projectId" });
|
||
}
|
||
if (codeVersion === undefined || codeVersion === null) {
|
||
throw new ValidationError("codeVersion cannot be empty", {
|
||
field: "codeVersion",
|
||
});
|
||
}
|
||
const versionNum = Number(codeVersion);
|
||
if (!Number.isFinite(versionNum)) {
|
||
throw new ValidationError("codeVersion must be a number", {
|
||
field: "codeVersion",
|
||
});
|
||
}
|
||
if (!Array.isArray(files)) {
|
||
throw new ValidationError("files must be an array", { field: "files" });
|
||
}
|
||
|
||
// 验证文件操作结构
|
||
for (let i = 0; i < files.length; i++) {
|
||
const fileOp = files[i];
|
||
if (!fileOp || typeof fileOp.operation !== "string") {
|
||
throw new ValidationError(`files[${i}].operation cannot be empty`, {
|
||
field: `files[${i}].operation`,
|
||
});
|
||
}
|
||
// 使用 name 作为文件路径字段
|
||
if (!fileOp.name || typeof fileOp.name !== "string") {
|
||
throw new ValidationError(`files[${i}].name cannot be empty`, {
|
||
field: `files[${i}].name`,
|
||
});
|
||
}
|
||
|
||
const operation = fileOp.operation.toLowerCase();
|
||
if (!["create", "delete", "rename", "modify"].includes(operation)) {
|
||
throw new ValidationError(
|
||
`files[${i}].operation must be one of create, delete, rename or modify`,
|
||
{ field: `files[${i}].operation` }
|
||
);
|
||
}
|
||
|
||
// 验证特定操作所需的字段
|
||
if (operation === "rename" && !fileOp.renameFrom) {
|
||
throw new ValidationError(
|
||
`files[${i}].renameFrom cannot be empty (rename operation requires)`,
|
||
{ field: `files[${i}].renameFrom` }
|
||
);
|
||
}
|
||
|
||
if (operation === "modify") {
|
||
if (typeof fileOp.contents !== "string") {
|
||
throw new ValidationError(
|
||
`files[${i}].contents must be a string (modify operation requires)`,
|
||
{ field: `files[${i}].contents` }
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
const projectPath = resolveProjectPath(projectId, isolationContext);
|
||
if (!fs.existsSync(projectPath)) {
|
||
log(projectId, "ERROR", "Project does not exist", { projectId, projectPath });
|
||
throw new ResourceError("Project does not exist", { projectId });
|
||
}
|
||
|
||
let backupZipPath = "";
|
||
try {
|
||
// 1) 备份
|
||
const backupDir = path.join(config.UPLOAD_PROJECT_DIR, projectId);
|
||
if (!fs.existsSync(backupDir)) {
|
||
fs.mkdirSync(backupDir, { recursive: true });
|
||
}
|
||
const zipName = `${projectId}-v${versionNum}.zip`;
|
||
backupZipPath = path.join(backupDir, zipName);
|
||
log(projectId, "DEBUG", "Start backing up project", { projectId, backupZipPath });
|
||
await backupProjectToZip(projectId, projectPath, backupZipPath);
|
||
log(projectId, "INFO", "Project backed up successfully", {
|
||
projectId,
|
||
zipPath: backupZipPath,
|
||
});
|
||
|
||
// 2) 处理文件操作
|
||
try {
|
||
log(projectId, "DEBUG", "Start processing file operations", { projectId, filesCount: files.length });
|
||
for (const fileOp of files) {
|
||
const operation = fileOp.operation.toLowerCase();
|
||
// 使用 name 作为文件路径
|
||
const fileName = fileOp.name;
|
||
|
||
const normalizedPath = path.normalize(fileName).replace(/^[\/\\]+/, "");
|
||
const targetPath = path.join(projectPath, normalizedPath);
|
||
|
||
// 安全检查:确保目标路径在项目目录内
|
||
const resolvedTargetPath = path.resolve(targetPath);
|
||
const resolvedProjectPath = path.resolve(projectPath);
|
||
if (!resolvedTargetPath.startsWith(resolvedProjectPath + path.sep) &&
|
||
resolvedTargetPath !== resolvedProjectPath) {
|
||
log(projectId, "WARN", "Unsafe file path, skipping", {
|
||
filePath: normalizedPath,
|
||
resolvedPath: resolvedTargetPath,
|
||
});
|
||
continue;
|
||
}
|
||
|
||
switch (operation) {
|
||
case "create": {
|
||
// 创建新文件
|
||
await fs.promises.mkdir(path.dirname(targetPath), { recursive: true });
|
||
const contents = fileOp.contents || "";
|
||
await fs.promises.writeFile(targetPath, contents, "utf8");
|
||
log(projectId, "INFO", "File created successfully", {
|
||
filePath: normalizedPath,
|
||
});
|
||
break;
|
||
}
|
||
|
||
case "delete": {
|
||
// 删除文件
|
||
if (fs.existsSync(targetPath)) {
|
||
await fs.promises.unlink(targetPath);
|
||
log(projectId, "INFO", "File deleted successfully", {
|
||
filePath: normalizedPath,
|
||
});
|
||
} else {
|
||
log(projectId, "WARN", "File to delete does not exist", {
|
||
filePath: normalizedPath,
|
||
});
|
||
}
|
||
break;
|
||
}
|
||
|
||
case "rename": {
|
||
// 重命名文件
|
||
const renameFrom = fileOp.renameFrom;
|
||
if (!renameFrom || typeof renameFrom !== "string") {
|
||
log(projectId, "WARN", "Rename operation missing renameFrom", {
|
||
filePath: normalizedPath,
|
||
});
|
||
break;
|
||
}
|
||
|
||
const normalizedFrom = path.normalize(renameFrom).replace(/^[\/\\]+/, "");
|
||
const oldPath = path.join(projectPath, normalizedFrom);
|
||
const resolvedOldPath = path.resolve(oldPath);
|
||
|
||
// 安全检查
|
||
if (!resolvedOldPath.startsWith(resolvedProjectPath + path.sep) &&
|
||
resolvedOldPath !== resolvedProjectPath) {
|
||
log(projectId, "WARN", "Unsafe rename source path, skipping", {
|
||
renameFrom: normalizedFrom,
|
||
resolvedPath: resolvedOldPath,
|
||
});
|
||
break;
|
||
}
|
||
|
||
if (fs.existsSync(oldPath)) {
|
||
await fs.promises.mkdir(path.dirname(targetPath), { recursive: true });
|
||
await fs.promises.rename(oldPath, targetPath);
|
||
log(projectId, "INFO", "File renamed successfully", {
|
||
oldPath: normalizedFrom,
|
||
newPath: normalizedPath,
|
||
});
|
||
} else {
|
||
log(projectId, "WARN", "File to rename does not exist", {
|
||
renameFrom: normalizedFrom,
|
||
});
|
||
}
|
||
break;
|
||
}
|
||
|
||
case "modify": {
|
||
// 修改文件:前端传入完整 contents,这里根据新旧内容按行生成增删改
|
||
if (!fs.existsSync(targetPath)) {
|
||
log(projectId, "WARN", "File to modify does not exist", {
|
||
filePath: normalizedPath,
|
||
});
|
||
break;
|
||
}
|
||
|
||
// 读取现有文件内容
|
||
const existingContent = await fs.promises.readFile(
|
||
targetPath,
|
||
"utf8"
|
||
);
|
||
const newContentStr =
|
||
typeof fileOp.contents === "string" ? fileOp.contents : "";
|
||
|
||
// 使用按行 diff 的方式生成最终内容
|
||
const { finalContent, changesCount } = diffContentByLines(
|
||
existingContent,
|
||
newContentStr
|
||
);
|
||
|
||
// 1) 若内容完全一致,不覆写文件,避免触发 HMR
|
||
if (changesCount === 0) {
|
||
log(projectId, "INFO", "File content unchanged, skipping write", {
|
||
filePath: normalizedPath,
|
||
});
|
||
break;
|
||
}
|
||
|
||
// 2) 写入修改后的内容
|
||
await fs.promises.writeFile(targetPath, finalContent, "utf8");
|
||
log(projectId, "INFO", "File modified successfully", {
|
||
filePath: normalizedPath,
|
||
changesCount,
|
||
});
|
||
break;
|
||
}
|
||
|
||
default: {
|
||
log(projectId, "WARN", "Unsupported operation type", {
|
||
operation,
|
||
filePath: normalizedPath,
|
||
});
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 3) 清理空目录
|
||
log(projectId, "DEBUG", "Start cleaning empty directories", { projectId });
|
||
try {
|
||
await removeEmptyDirectories(
|
||
projectPath,
|
||
config.TRAVERSE_EXCLUDE_DIRS || []
|
||
);
|
||
} catch (e) {
|
||
log(projectId, "WARN", "Failed to clean empty directories", {
|
||
projectId,
|
||
error: e && e.message,
|
||
});
|
||
}
|
||
|
||
log(projectId, "INFO", "Specified files updated successfully", {
|
||
projectId,
|
||
filesCount: files.length,
|
||
elapsedMs: Date.now() - startTime,
|
||
});
|
||
|
||
return {
|
||
success: true,
|
||
message: "Specified files updated successfully",
|
||
projectId,
|
||
filesCount: files.length,
|
||
};
|
||
} catch (e) {
|
||
log(projectId, "ERROR", "Failed to process file operations", {
|
||
projectId,
|
||
error: e && e.message,
|
||
elapsedMs: Date.now() - startTime,
|
||
});
|
||
throw e;
|
||
}
|
||
} catch (backupErr) {
|
||
// 备份阶段失败:不执行回滚(无备份可回滚),直接抛出
|
||
if (!backupErr.isOperational) {
|
||
throw new SystemError("Failed to backup project", {
|
||
projectId,
|
||
originalError: backupErr && backupErr.message,
|
||
});
|
||
}
|
||
throw backupErr;
|
||
}
|
||
}
|
||
|
||
async function allFilesUpdate(
|
||
projectId,
|
||
codeVersion,
|
||
files,
|
||
req,
|
||
isolationContext = {}
|
||
) {
|
||
const startTime = Date.now();
|
||
if (!projectId) {
|
||
throw new ValidationError("Project ID cannot be empty", { field: "projectId" });
|
||
}
|
||
const versionNum = Number(codeVersion);
|
||
if (!Number.isFinite(versionNum)) {
|
||
throw new ValidationError("codeVersion must be a number", {
|
||
field: "codeVersion",
|
||
});
|
||
}
|
||
if (!Array.isArray(files)) {
|
||
throw new ValidationError("files must be an array", { field: "files" });
|
||
}
|
||
|
||
const projectPath = resolveProjectPath(projectId, isolationContext);
|
||
if (!fs.existsSync(projectPath)) {
|
||
log(projectId, "ERROR", "Project does not exist", { projectId, projectPath });
|
||
throw new ResourceError("Project does not exist", { projectId });
|
||
}
|
||
|
||
let backupZipPath = "";
|
||
try {
|
||
// 1) 备份
|
||
const backupDir = path.join(config.UPLOAD_PROJECT_DIR, projectId);
|
||
if (!fs.existsSync(backupDir)) {
|
||
fs.mkdirSync(backupDir, { recursive: true });
|
||
}
|
||
const zipName = `${projectId}-v${versionNum}.zip`;
|
||
backupZipPath = path.join(backupDir, zipName);
|
||
log(projectId, "DEBUG", "Start backing up project", { projectId, backupZipPath });
|
||
await backupProjectToZip(projectId, projectPath, backupZipPath);
|
||
|
||
// 2) 写入文件
|
||
try {
|
||
log(projectId, "DEBUG", "Start writing files", { projectId, filesCount: files.length });
|
||
for (const file of files) {
|
||
if (!file || typeof file.name !== "string") continue;
|
||
const targetPath = path.join(projectPath, file.name);
|
||
|
||
// 处理文件重命名:如果存在 renameFrom,需要先重命名原文件
|
||
if (file.renameFrom && typeof file.renameFrom === "string") {
|
||
const oldPath = path.join(projectPath, file.renameFrom);
|
||
if (fs.existsSync(oldPath)) {
|
||
// 确保目标目录存在(跨目录重命名时需要)
|
||
await fs.promises.mkdir(path.dirname(targetPath), { recursive: true });
|
||
await fs.promises.rename(oldPath, targetPath);
|
||
log(projectId, "INFO", "File renamed successfully", {
|
||
projectId,
|
||
oldPath: file.renameFrom,
|
||
newPath: file.name,
|
||
});
|
||
continue; // 重命名后跳过后续的写入操作
|
||
}
|
||
}
|
||
|
||
const isBinary = file.binary === true;
|
||
const isText = file.binary === false;
|
||
const sizeExceeded = !!file.sizeExceeded;
|
||
const hasContents =
|
||
typeof file.contents === "string" && file.contents.length > 0;
|
||
|
||
// 处理二进制文件:如果已经存在就不写入,不存在才需要根据hasContents写入
|
||
if (isBinary) {
|
||
// 如果文件已存在,跳过写入
|
||
if (fs.existsSync(targetPath)) {
|
||
log(projectId, "INFO", "Binary file already exists, skipping write", {
|
||
filePath: file.name,
|
||
});
|
||
continue;
|
||
}
|
||
|
||
// 文件不存在,且 hasContents 为 true,才写入
|
||
if (hasContents) {
|
||
// 二进制文件有 base64 编码的内容,需要解码为 Buffer 并写入
|
||
try {
|
||
await fs.promises.mkdir(path.dirname(targetPath), { recursive: true });
|
||
const buffer = Buffer.from(file.contents, "base64");
|
||
await fs.promises.writeFile(targetPath, buffer);
|
||
log(projectId, "INFO", "Binary file written successfully", {
|
||
filePath: file.name,
|
||
});
|
||
} catch (e) {
|
||
log(projectId, "ERROR", "Failed to write binary file", {
|
||
filePath: file.name,
|
||
error: e && e.message,
|
||
});
|
||
}
|
||
} else {
|
||
// 二进制文件没有内容(可能是大文件),且文件不存在
|
||
log(projectId, "WARN", "Binary file does not exist and has no content, skipping", {
|
||
filePath: file.name,
|
||
});
|
||
}
|
||
continue;
|
||
}
|
||
|
||
// 处理文本文件
|
||
const shouldReplace =
|
||
isText && (!sizeExceeded || (sizeExceeded && hasContents));
|
||
if (!shouldReplace) {
|
||
continue;
|
||
}
|
||
await fs.promises.mkdir(path.dirname(targetPath), { recursive: true });
|
||
await fs.promises.writeFile(targetPath, file.contents || "", "utf8");
|
||
}
|
||
} catch (e) {
|
||
log(projectId, "ERROR", "Failed to write files", {
|
||
projectId,
|
||
error: e && e.message,
|
||
elapsedMs: Date.now() - startTime,
|
||
});
|
||
throw e;
|
||
}
|
||
|
||
// 3) 清理缺失文件与空目录
|
||
try {
|
||
log(projectId, "DEBUG", "Start cleaning missing files and empty directories", { projectId });
|
||
const keepSet = new Set(
|
||
files
|
||
.filter((f) => f && typeof f.name === "string")
|
||
.map((f) => path.normalize(f.name))
|
||
);
|
||
await pruneMissingFiles(
|
||
projectPath,
|
||
keepSet,
|
||
config.TRAVERSE_EXCLUDE_DIRS || []
|
||
);
|
||
await removeEmptyDirectories(
|
||
projectPath,
|
||
config.TRAVERSE_EXCLUDE_DIRS || []
|
||
);
|
||
} catch (e) {
|
||
log(projectId, "ERROR", "Failed to clean missing files, starting rollback", {
|
||
projectId,
|
||
error: e && e.message,
|
||
elapsedMs: Date.now() - startTime,
|
||
});
|
||
throw e;
|
||
}
|
||
|
||
log(projectId, "INFO", "Files submitted successfully", {
|
||
projectId,
|
||
filesCount: files.length,
|
||
elapsedMs: Date.now() - startTime,
|
||
});
|
||
return {
|
||
success: true,
|
||
message: "Files submitted successfully",
|
||
projectId,
|
||
restarted: false,
|
||
};
|
||
|
||
} catch (backupErr) {
|
||
// 备份阶段失败:不执行回滚(无备份可回滚),直接抛出
|
||
if (!backupErr.isOperational) {
|
||
throw new SystemError("Failed to backup old version", {
|
||
projectId,
|
||
originalError: backupErr && backupErr.message,
|
||
});
|
||
}
|
||
throw backupErr;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 上传单个文件到指定路径
|
||
* @param {string} projectId 项目ID
|
||
* @param {string} codeVersion 代码版本号
|
||
* @param {Object} file 文件对象 (包含文件内容和元数据)
|
||
* @param {string} filePath 文件在项目中的相对路径
|
||
* @param {Object} req 请求对象
|
||
* @returns {Object} 上传结果
|
||
*/
|
||
async function uploadSingleFile(
|
||
projectId,
|
||
codeVersion,
|
||
file,
|
||
filePath,
|
||
req,
|
||
isolationContext = {}
|
||
) {
|
||
const startTime = Date.now();
|
||
if (!projectId) {
|
||
throw new ValidationError("Project ID cannot be empty", { field: "projectId" });
|
||
}
|
||
const versionNum = Number(codeVersion);
|
||
if (!Number.isFinite(versionNum)) {
|
||
throw new ValidationError("codeVersion must be a number", {
|
||
field: "codeVersion",
|
||
});
|
||
}
|
||
if (!file) {
|
||
throw new ValidationError("File cannot be empty", { field: "file" });
|
||
}
|
||
if (!filePath || typeof filePath !== "string") {
|
||
throw new ValidationError("File path cannot be empty", { field: "filePath" });
|
||
}
|
||
|
||
const projectPath = resolveProjectPath(projectId, isolationContext);
|
||
if (!fs.existsSync(projectPath)) {
|
||
log(projectId, "ERROR", "Project does not exist", { projectId, projectPath });
|
||
throw new ResourceError("Project does not exist", { projectId });
|
||
}
|
||
|
||
// 规范化文件路径,确保是相对路径
|
||
const normalizedPath = path.normalize(filePath).replace(/^[\/\\]+/, "");
|
||
const targetPath = path.join(projectPath, normalizedPath);
|
||
|
||
// 安全检查:确保目标路径在项目目录内
|
||
const resolvedTargetPath = path.resolve(targetPath);
|
||
const resolvedProjectPath = path.resolve(projectPath);
|
||
if (!resolvedTargetPath.startsWith(resolvedProjectPath)) {
|
||
throw new ValidationError("File path is not safe, cannot exceed project directory", {
|
||
field: "filePath",
|
||
providedPath: filePath,
|
||
resolvedPath: resolvedTargetPath,
|
||
});
|
||
}
|
||
|
||
let backupZipPath = "";
|
||
try {
|
||
// 1) 备份
|
||
const backupDir = path.join(config.UPLOAD_PROJECT_DIR, projectId);
|
||
if (!fs.existsSync(backupDir)) {
|
||
fs.mkdirSync(backupDir, { recursive: true });
|
||
}
|
||
const zipName = `${projectId}-v${versionNum}.zip`;
|
||
backupZipPath = path.join(backupDir, zipName);
|
||
log(projectId, "DEBUG", "Start backing up project", { projectId, backupZipPath });
|
||
await backupProjectToZip(projectId, projectPath, backupZipPath);
|
||
log(projectId, "INFO", `Project backed up: ${backupZipPath}`, {
|
||
projectId,
|
||
zipPath: backupZipPath,
|
||
});
|
||
|
||
// 2) 写入文件
|
||
try {
|
||
// 确保目标目录存在
|
||
log(projectId, "DEBUG", "Start writing uploaded file", { projectId, filePath: normalizedPath });
|
||
await fs.promises.mkdir(path.dirname(targetPath), { recursive: true });
|
||
|
||
// 写入文件内容,统一使用buffer(multer memoryStorage对所有文件类型都提供buffer)
|
||
if (!file.buffer) {
|
||
throw new ValidationError("File content format is incorrect, missing buffer", {
|
||
field: "file",
|
||
});
|
||
}
|
||
|
||
// 记录写入前的文件信息,用于调试
|
||
log(projectId, "INFO", "Prepare to write file", {
|
||
targetPath,
|
||
bufferLength: file.buffer.length,
|
||
expectedSize: file.size,
|
||
bufferIsBuffer: Buffer.isBuffer(file.buffer),
|
||
sizeMatch: file.buffer.length === file.size,
|
||
});
|
||
|
||
// 直接写入Buffer,Node.js会自动以二进制模式写入
|
||
await fs.promises.writeFile(targetPath, file.buffer);
|
||
|
||
log(projectId, "INFO", "File uploaded successfully", {
|
||
projectId,
|
||
filePath: normalizedPath,
|
||
targetPath: resolvedTargetPath,
|
||
fileSize: file.buffer ? file.buffer.length : 0,
|
||
elapsedMs: Date.now() - startTime,
|
||
});
|
||
|
||
// 判断是否需要重启开发服务器
|
||
// const needRestart = shouldRestartForSingleFile(path.basename(normalizedPath));
|
||
const needRestart = false;
|
||
|
||
if (needRestart) {
|
||
try {
|
||
const restartResult = await restartDevServer(req, projectId);
|
||
log(projectId, "INFO", "Restart development server successfully", {
|
||
projectId,
|
||
pid: restartResult.pid,
|
||
port: restartResult.port,
|
||
});
|
||
return {
|
||
success: true,
|
||
message: "File uploaded and restarted development server successfully",
|
||
projectId,
|
||
filePath: normalizedPath,
|
||
targetPath: resolvedTargetPath,
|
||
fileSize: file.buffer ? file.buffer.length : 0,
|
||
pid: restartResult.pid,
|
||
port: restartResult.port,
|
||
restarted: true,
|
||
};
|
||
} catch (e) {
|
||
log(projectId, "ERROR", "Failed to restart development server", {
|
||
projectId,
|
||
filePath: normalizedPath,
|
||
error: e && e.message,
|
||
});
|
||
}
|
||
} else {
|
||
log(projectId, "INFO", "File modification does not require restarting development server", {
|
||
projectId,
|
||
filePath: normalizedPath,
|
||
});
|
||
return {
|
||
success: true,
|
||
message: "File uploaded successfully, no need to restart development server",
|
||
projectId,
|
||
restarted: false,
|
||
};
|
||
}
|
||
} catch (e) {
|
||
log(projectId, "ERROR", "Failed to write file", {
|
||
projectId,
|
||
filePath: normalizedPath,
|
||
error: e && e.message,
|
||
elapsedMs: Date.now() - startTime,
|
||
});
|
||
throw e;
|
||
}
|
||
} catch (backupErr) {
|
||
// 备份阶段失败:不执行回滚(无备份可回滚),直接抛出
|
||
if (!backupErr.isOperational) {
|
||
throw new SystemError("Failed to backup project", {
|
||
projectId,
|
||
filePath: normalizedPath,
|
||
originalError: backupErr && backupErr.message,
|
||
});
|
||
}
|
||
throw backupErr;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 回滚项目到指定版本
|
||
* @param {string} projectId 项目ID
|
||
* @param {string} codeVersion 当前代码版本号
|
||
* @param {string} rollbackTo 要回滚到的版本号
|
||
* @param {Object} req 请求对象
|
||
* @returns {Object} 回滚结果
|
||
*/
|
||
async function rollbackVersion(
|
||
projectId,
|
||
codeVersion,
|
||
rollbackTo,
|
||
req,
|
||
isolationContext = {}
|
||
) {
|
||
const startTime = Date.now();
|
||
if (!projectId) {
|
||
throw new ValidationError("Project ID cannot be empty", { field: "projectId" });
|
||
}
|
||
const versionNum = Number(codeVersion);
|
||
if (!Number.isFinite(versionNum)) {
|
||
throw new ValidationError("codeVersion must be a number", {
|
||
field: "codeVersion",
|
||
});
|
||
}
|
||
const rollbackToNum = Number(rollbackTo);
|
||
if (!Number.isFinite(rollbackToNum)) {
|
||
throw new ValidationError("rollbackTo must be a number", {
|
||
field: "rollbackTo",
|
||
});
|
||
}
|
||
if (rollbackToNum < 0) {
|
||
throw new ValidationError("rollbackTo cannot be less than 0", {
|
||
field: "rollbackTo",
|
||
});
|
||
}
|
||
if (rollbackToNum >= versionNum) {
|
||
throw new ValidationError("rollbackTo must be less than current codeVersion", {
|
||
field: "rollbackTo",
|
||
});
|
||
}
|
||
|
||
const projectPath = resolveProjectPath(projectId, isolationContext);
|
||
if (!fs.existsSync(projectPath)) {
|
||
log(projectId, "ERROR", "Project does not exist", { projectId, projectPath });
|
||
throw new ResourceError("Project does not exist", { projectId });
|
||
}
|
||
|
||
// 检查要回滚到的版本的备份文件是否存在
|
||
const backupDir = path.join(config.UPLOAD_PROJECT_DIR, projectId);
|
||
const rollbackZipName = `${projectId}-v${rollbackToNum}.zip`;
|
||
const rollbackZipPath = path.join(backupDir, rollbackZipName);
|
||
|
||
if (!fs.existsSync(rollbackZipPath)) {
|
||
log(projectId, "ERROR", "Rollback version backup file does not exist", {
|
||
projectId,
|
||
rollbackTo: rollbackToNum,
|
||
zipPath: rollbackZipPath,
|
||
});
|
||
throw new ResourceError("Rollback version backup file does not exist", {
|
||
projectId,
|
||
rollbackTo: rollbackToNum,
|
||
});
|
||
}
|
||
|
||
let currentBackupZipPath = "";
|
||
try {
|
||
// 1) 备份当前版本
|
||
if (!fs.existsSync(backupDir)) {
|
||
fs.mkdirSync(backupDir, { recursive: true });
|
||
}
|
||
const currentZipName = `${projectId}-v${versionNum}.zip`;
|
||
currentBackupZipPath = path.join(backupDir, currentZipName);
|
||
|
||
// 如果当前版本的备份已存在,跳过备份(避免覆盖)
|
||
if (!fs.existsSync(currentBackupZipPath)) {
|
||
await backupProjectToZip(projectId, projectPath, currentBackupZipPath);
|
||
log(projectId, "INFO", "Current version backed up", {
|
||
projectId,
|
||
zipPath: currentBackupZipPath,
|
||
});
|
||
} else {
|
||
log(projectId, "INFO", "Current version backup already exists, skipping backup", {
|
||
projectId,
|
||
zipPath: currentBackupZipPath,
|
||
});
|
||
}
|
||
|
||
// 2) 从指定版本恢复项目
|
||
log(projectId, "DEBUG", "Start restoring project from backup", { projectId, rollbackToNum, rollbackZipPath });
|
||
await restoreProjectFromZip(projectId, projectPath, rollbackZipPath);
|
||
log(projectId, "INFO", "Project rolled back successfully", {
|
||
projectId,
|
||
newVersion: versionNum,
|
||
toVersion: rollbackToNum,
|
||
rollbackZipPath,
|
||
elapsedMs: Date.now() - startTime,
|
||
});
|
||
|
||
return {
|
||
success: true,
|
||
message: "Project rolled back successfully",
|
||
newVersion: versionNum,
|
||
rollbackTo: rollbackToNum,
|
||
};
|
||
} catch (restoreErr) {
|
||
log(projectId, "ERROR", "Failed to rollback project", {
|
||
projectId,
|
||
rollbackTo: rollbackToNum,
|
||
error: restoreErr && restoreErr.message,
|
||
elapsedMs: Date.now() - startTime,
|
||
});
|
||
|
||
// 如果恢复失败,尝试从当前版本备份恢复(如果存在)
|
||
if (currentBackupZipPath && fs.existsSync(currentBackupZipPath)) {
|
||
try {
|
||
log(projectId, "INFO", "Failed to rollback, trying to restore current version", {
|
||
projectId,
|
||
backupPath: currentBackupZipPath,
|
||
});
|
||
await restoreProjectFromZip(projectId, projectPath, currentBackupZipPath);
|
||
log(projectId, "INFO", "Current version restored", {
|
||
projectId,
|
||
});
|
||
} catch (recoveryErr) {
|
||
log(projectId, "ERROR", "Failed to restore current version", {
|
||
projectId,
|
||
error: recoveryErr && recoveryErr.message,
|
||
});
|
||
}
|
||
}
|
||
|
||
if (!restoreErr.isOperational) {
|
||
throw new SystemError("Failed to rollback project", {
|
||
projectId,
|
||
rollbackTo: rollbackToNum,
|
||
originalError: restoreErr && restoreErr.message,
|
||
});
|
||
}
|
||
throw restoreErr;
|
||
}
|
||
}
|
||
|
||
export { specifiedFilesUpdate, allFilesUpdate, uploadSingleFile, rollbackVersion };
|
||
export default {
|
||
specifiedFilesUpdate,
|
||
allFilesUpdate,
|
||
uploadSingleFile,
|
||
rollbackVersion,
|
||
};
|