From 7e00b65159cf2cd8e687412cfc111b007813436d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E9=B9=8F?= Date: Thu, 18 Jun 2026 12:53:00 +0800 Subject: [PATCH] Reuse npm cache for run previews --- .../FrontProjectRunPreviewServiceImpl.java | 1133 +++++++++++++++++ ...FrontProjectRunPreviewServiceImplTest.java | 623 +++++++++ 2 files changed, 1756 insertions(+) create mode 100644 RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/service/front/FrontProjectRunPreviewServiceImpl.java create mode 100644 RuoYi-Vue/ruoyi-generator/src/test/java/com/ruoyi/generator/service/front/FrontProjectRunPreviewServiceImplTest.java diff --git a/RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/service/front/FrontProjectRunPreviewServiceImpl.java b/RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/service/front/FrontProjectRunPreviewServiceImpl.java new file mode 100644 index 0000000..b398460 --- /dev/null +++ b/RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/service/front/FrontProjectRunPreviewServiceImpl.java @@ -0,0 +1,1133 @@ +package com.ruoyi.generator.service.front; + +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.generator.domain.front.dto.ProjectRunPreviewStatus; +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONObject; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.Charset; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; +import java.io.ByteArrayInputStream; + +@Service +public class FrontProjectRunPreviewServiceImpl implements IFrontProjectRunPreviewService +{ + private static final int MAX_LOG_LENGTH = 12000; + private static final Pattern PUBLIC_CLASS_PATTERN = Pattern.compile("\\bpublic\\s+(?:abstract\\s+|final\\s+)?class\\s+([A-Za-z_$][A-Za-z0-9_$]*)\\b"); + + @Autowired + private IFrontProjectService frontProjectService; + @Autowired + private IFrontProjectPreviewService frontProjectPreviewService; + @Autowired + private RunPreviewPortAllocator portAllocator; + @Autowired + private RunPreviewDatabaseInitializer databaseInitializer; + @Autowired + private RunPreviewProcessRunner processRunner; + @Autowired + private RunPreviewProcessKiller processKiller; + + @Value("${easycode.preview.workspace-root:preview-workspaces}") + private String workspaceRoot; + @Value("${easycode.preview.mysql-host:127.0.0.1}") + private String mysqlHost; + @Value("${easycode.preview.mysql-port:3306}") + private int mysqlPort; + @Value("${easycode.preview.mysql-username:root}") + private String mysqlUsername; + @Value("${easycode.preview.mysql-password:123456}") + private String mysqlPassword; + + private final Map sessions = new ConcurrentHashMap(); + + @Override + public ProjectRunPreviewStatus start(Long userId, Long projectId) + { + frontProjectService.getProject(userId, projectId); + String key = sessionKey(userId, projectId); + RunPreviewSession existing = sessions.get(key); + if (existing != null) + { + destroyProcesses(existing); + } + + RunPreviewSession session = new RunPreviewSession(); + session.projectId = projectId; + session.status = ProjectRunPreviewStatus.PREPARING; + session.message = "Preparing preview workspace"; + session.startedAt = now(); + session.updatedAt = session.startedAt; + sessions.put(key, session); + + try + { + byte[] sourceZip = frontProjectPreviewService.downloadAll(userId, projectId); + File workspace = prepareWorkspace(projectId, sourceZip); + session.workspacePath = workspace.getAbsolutePath(); + + int backendPort = portAllocator.allocate(); + int frontendPort = portAllocator.allocate(); + String databaseName = "preview_" + projectId + "_" + System.currentTimeMillis(); + String backendUrl = "http://127.0.0.1:" + backendPort; + String frontendUrl = "http://127.0.0.1:" + frontendPort; + File backendDirectory = resolveBackendDirectory(workspace); + String databaseUrl = databaseInitializer.initialize(resolveSqlDirectory(workspace, backendDirectory), + databaseName, mysqlHost, mysqlPort, mysqlUsername, mysqlPassword); + applyBackendPreviewCompatibilityFixes(backendDirectory); + File frontendDirectory = resolveFrontendDirectory(workspace); + File adminFrontendDirectory = resolveAdminFrontendDirectory(workspace, frontendDirectory); + File repositoryRoot = resolveRepositoryRoot(); + File previewBuildDirectory = preparePreviewBuildDirectory(workspace); + File backendReactorPom = writeBackendReactorPom(previewBuildDirectory, repositoryRoot, backendDirectory); + String backendModulePath = mavenPath(previewBuildDirectory, backendDirectory); + File localRepository = new File(workspace.getParentFile(), ".m2/repository").getCanonicalFile(); + File sharedNpmCache = new File(workspace.getParentFile(), ".npm-cache").getCanonicalFile(); + Integer adminFrontendPort = adminFrontendDirectory == null ? null : portAllocator.allocate(); + String adminFrontendUrl = adminFrontendPort == null ? null : "http://127.0.0.1:" + adminFrontendPort; + applyFrontendPreviewConfig(frontendDirectory); + if (adminFrontendDirectory != null) + { + applyFrontendPreviewConfig(adminFrontendDirectory); + } + + session.backendPort = backendPort; + session.frontendPort = frontendPort; + session.adminFrontendPort = adminFrontendPort; + session.backendUrl = backendUrl; + session.frontendUrl = frontendUrl; + session.adminFrontendUrl = adminFrontendUrl; + session.databaseName = databaseName; + session.status = ProjectRunPreviewStatus.STARTING; + session.message = "正在启动生成的后端和前端服务"; + session.updatedAt = now(); + + Map backendEnv = new HashMap(); + backendEnv.put("SERVER_PORT", String.valueOf(backendPort)); + backendEnv.put("DB_URL", databaseUrl); + backendEnv.put("DB_USERNAME", mysqlUsername); + backendEnv.put("DB_PASSWORD", mysqlPassword); + putJavaPreviewEnvironment(backendEnv, new File(previewBuildDirectory, "java-tmp/backend")); + + Map frontendEnv = new HashMap(); + frontendEnv.put("PORT", String.valueOf(frontendPort)); + frontendEnv.put("VUE_APP_API_BASE_URL", backendUrl); + frontendEnv.put("VITE_APP_API_BASE_URL", backendUrl); + frontendEnv.put("VITE_APP_BASE_API", "/api"); + putNpmPreviewEnvironment(frontendEnv, sharedNpmCache); + + Map adminFrontendEnv = null; + if (adminFrontendPort != null) + { + adminFrontendEnv = new HashMap(); + adminFrontendEnv.put("PORT", String.valueOf(adminFrontendPort)); + adminFrontendEnv.put("VUE_APP_API_BASE_URL", backendUrl); + adminFrontendEnv.put("VITE_APP_API_BASE_URL", backendUrl); + adminFrontendEnv.put("VITE_APP_BASE_API", "/api"); + putNpmPreviewEnvironment(adminFrontendEnv, sharedNpmCache); + } + + session.backendProcess = processRunner.startBackend(previewBuildDirectory, new File(repositoryRoot, "pom.xml"), + backendReactorPom, backendModulePath, localRepository, backendEnv); + session.frontendProcess = processRunner.startFrontend(frontendDirectory, frontendEnv); + if (adminFrontendDirectory != null) + { + session.adminFrontendProcess = processRunner.startFrontend(adminFrontendDirectory, adminFrontendEnv); + } + startLogReader(session, session.backendProcess, "backend"); + startLogReader(session, session.frontendProcess, "frontend"); + startLogReader(session, session.adminFrontendProcess, "adminFrontend"); + + session.status = ProjectRunPreviewStatus.STARTING; + session.message = "预览进程已启动,正在等待前端服务就绪"; + session.updatedAt = now(); + } + catch (Exception e) + { + session.status = ProjectRunPreviewStatus.FAILED; + session.message = e.getMessage(); + session.updatedAt = now(); + appendLog(session, "preview failed: " + e.getMessage()); + destroyProcesses(session); + } + return toStatus(session); + } + + private File resolveBackendDirectory(File workspace) + { + if (new File(workspace, "pom.xml").isFile()) + { + return workspace; + } + List candidates = findDirectoriesContaining(workspace, "pom.xml"); + File preferred = pickPreferredDirectory(candidates, "-backend", "backend"); + if (preferred == null) + { + throw new ServiceException("Generated project does not contain a runnable backend directory with pom.xml"); + } + return preferred; + } + + private File resolveFrontendDirectory(File workspace) + { + File legacyVueDirectory = new File(workspace, "vue"); + if (new File(legacyVueDirectory, "package.json").isFile()) + { + return legacyVueDirectory; + } + List candidates = findDirectoriesContaining(workspace, "package.json"); + File preferred = pickPreferredDirectory(candidates, "client", "-web", "frontend", "portal", "vue"); + if (preferred == null) + { + throw new ServiceException("Generated project does not contain a runnable frontend directory with package.json"); + } + return preferred; + } + + private File resolveAdminFrontendDirectory(File workspace, File frontendDirectory) throws IOException + { + List candidates = findDirectoriesContaining(workspace, "package.json"); + List adminCandidates = new ArrayList(); + String frontendPath = frontendDirectory == null ? null : frontendDirectory.getCanonicalPath(); + for (File candidate : candidates) + { + if (frontendPath != null && frontendPath.equals(candidate.getCanonicalPath())) + { + continue; + } + adminCandidates.add(candidate); + } + return pickPreferredDirectory(adminCandidates, "manage", "-admin", "admin"); + } + + private void applyFrontendPreviewConfig(File frontendDirectory) throws IOException + { + if (isViteFrontend(frontendDirectory)) + { + applyVitePreviewConfig(frontendDirectory); + return; + } + + applyVueCliPreviewConfig(frontendDirectory); + } + + private void applyVueCliPreviewConfig(File frontendDirectory) throws IOException + { + File vueConfig = new File(frontendDirectory, "vue.config.js"); + File generatedConfig = new File(frontendDirectory, "vue.config.generated.js"); + boolean hasGeneratedConfig = vueConfig.isFile(); + if (hasGeneratedConfig) + { + copyGeneratedPreviewConfig(vueConfig, generatedConfig, "vue"); + } + + String requireGeneratedConfig = hasGeneratedConfig + ? "const generated = require(\"./vue.config.generated.js\")\n" + : "const generated = {}\n"; + String previewConfig = requireGeneratedConfig + + "const generatedDevServer = generated.devServer || {}\n" + + "const generatedProxy = generatedDevServer.proxy || {}\n" + + "const generatedApiProxy = generatedProxy[\"/api\"] || {}\n" + + "const port = process.env.PORT || generatedDevServer.port || 8081\n" + + "const apiBaseUrl = process.env.VUE_APP_API_BASE_URL || generatedApiProxy.target || \"http://localhost:8080\"\n" + + "\n" + + "module.exports = Object.assign({}, generated, {\n" + + " devServer: Object.assign({}, generatedDevServer, {\n" + + " port: port,\n" + + " proxy: Object.assign({}, generatedProxy, {\n" + + " \"/api\": Object.assign({}, generatedApiProxy, {\n" + + " target: apiBaseUrl,\n" + + " changeOrigin: true,\n" + + " pathRewrite: Object.assign({}, generatedApiProxy.pathRewrite || {}, {\n" + + " \"^/api\": \"\"\n" + + " })\n" + + " })\n" + + " })\n" + + " })\n" + + "})\n"; + writeUtf8(vueConfig, previewConfig); + } + + private void applyVitePreviewConfig(File frontendDirectory) throws IOException + { + File viteConfig = new File(frontendDirectory, "vite.config.js"); + File generatedConfig = new File(frontendDirectory, "vite.config.generated.js"); + boolean hasGeneratedConfig = viteConfig.isFile(); + if (hasGeneratedConfig) + { + copyGeneratedPreviewConfig(viteConfig, generatedConfig, "vite"); + } + + String generatedConfigImport = hasGeneratedConfig + ? "import generated from \"./vite.config.generated.js\"\n" + : "const generated = {}\n"; + String previewConfig = "import { defineConfig, mergeConfig } from \"vite\"\n" + + "import vue from \"@vitejs/plugin-vue\"\n" + + generatedConfigImport + + "\n" + + "const port = Number(process.env.PORT || 8081)\n" + + "const apiBaseUrl = process.env.VITE_APP_API_BASE_URL || process.env.VUE_APP_API_BASE_URL || \"http://localhost:8080\"\n" + + "const previewConfig = {\n" + + " plugins: [vue()],\n" + + " server: {\n" + + " host: \"0.0.0.0\",\n" + + " port: port,\n" + + " proxy: {\n" + + " \"/api\": {\n" + + " target: apiBaseUrl,\n" + + " changeOrigin: true,\n" + + " rewrite: path => path.replace(/^\\/api/, \"\")\n" + + " }\n" + + " }\n" + + " }\n" + + "}\n" + + "\n" + + "export default defineConfig(configEnv => mergeConfig(\n" + + " typeof generated === \"function\" ? generated(configEnv) : generated,\n" + + " previewConfig\n" + + "))\n"; + writeUtf8(viteConfig, previewConfig); + } + + private void putJavaPreviewEnvironment(Map environment, File tempDirectory) throws IOException + { + putJavaHome(environment); + if (!tempDirectory.mkdirs() && !tempDirectory.isDirectory()) + { + throw new IOException("Unable to create preview Java temp directory"); + } + String tempOption = "-Djava.io.tmpdir=" + tempDirectory.getAbsolutePath(); + putJvmOption(environment, "MAVEN_OPTS", tempOption); + putJvmOption(environment, "JAVA_TOOL_OPTIONS", tempOption); + } + + private void putJavaHome(Map environment) + { + String javaHome = resolveJavaHome(); + if (hasText(javaHome)) + { + environment.put("JAVA_HOME", javaHome); + } + } + + private String resolveJavaHome() + { + String configuredJavaHome = System.getenv("JAVA_HOME"); + if (hasText(configuredJavaHome)) + { + return configuredJavaHome; + } + + String javaHome = System.getProperty("java.home"); + if (!hasText(javaHome)) + { + return null; + } + File javaHomeFile = new File(javaHome); + if ("jre".equalsIgnoreCase(javaHomeFile.getName()) && javaHomeFile.getParentFile() != null) + { + return javaHomeFile.getParentFile().getAbsolutePath(); + } + return javaHomeFile.getAbsolutePath(); + } + + private void putJvmOption(Map environment, String key, String option) + { + String current = environment.get(key); + if (!hasText(current)) + { + current = System.getenv(key); + } + environment.put(key, hasText(current) ? current + " " + option : option); + } + + private void putNpmPreviewEnvironment(Map environment, File cacheDirectory) throws IOException + { + if (!cacheDirectory.mkdirs() && !cacheDirectory.isDirectory()) + { + throw new IOException("Unable to create preview npm cache directory"); + } + String cachePath = cacheDirectory.getAbsolutePath(); + environment.put("NPM_CONFIG_CACHE", cachePath); + environment.put("npm_config_cache", cachePath); + environment.put("NPM_CONFIG_UPDATE_NOTIFIER", "false"); + environment.put("NO_UPDATE_NOTIFIER", "1"); + environment.put("NPM_CONFIG_AUDIT", "false"); + environment.put("NPM_CONFIG_FUND", "false"); + environment.put("NPM_CONFIG_OFFLINE", "false"); + environment.put("npm_config_offline", "false"); + environment.put("NPM_CONFIG_PREFER_OFFLINE", "true"); + environment.put("npm_config_prefer_offline", "true"); + environment.put("NPM_CONFIG_PREFER_ONLINE", "false"); + environment.put("npm_config_prefer_online", "false"); + environment.put("NPM_CONFIG_CACHE_MIN", "0"); + environment.put("npm_config_cache_min", "0"); + environment.put("NPM_CONFIG_PROXY", ""); + environment.put("npm_config_proxy", ""); + environment.put("NPM_CONFIG_HTTPS_PROXY", ""); + environment.put("npm_config_https_proxy", ""); + environment.put("HTTP_PROXY", ""); + environment.put("http_proxy", ""); + environment.put("HTTPS_PROXY", ""); + environment.put("https_proxy", ""); + environment.put("ALL_PROXY", ""); + environment.put("all_proxy", ""); + environment.put("NO_PROXY", "*"); + environment.put("no_proxy", "*"); + } + + private boolean hasText(String value) + { + return value != null && value.trim().length() > 0; + } + + private void copyGeneratedPreviewConfig(File sourceConfig, File generatedConfig, String configType) throws IOException + { + try + { + Files.copy(sourceConfig.toPath(), generatedConfig.toPath(), StandardCopyOption.REPLACE_EXISTING); + } + catch (IOException e) + { + throw new IOException("Unable to prepare generated " + configType + " preview config", e); + } + } + + private boolean isViteFrontend(File frontendDirectory) throws IOException + { + File packageJsonFile = new File(frontendDirectory, "package.json"); + if (!packageJsonFile.isFile()) + { + return false; + } + + String content = new String(Files.readAllBytes(packageJsonFile.toPath()), Charset.forName("UTF-8")); + JSONObject packageJson = JSON.parseObject(content); + return hasDependency(packageJson.getJSONObject("dependencies"), "vite") + || hasDependency(packageJson.getJSONObject("devDependencies"), "vite") + || hasViteScript(packageJson.getJSONObject("scripts")); + } + + private boolean hasDependency(JSONObject dependencies, String dependencyName) + { + return dependencies != null && dependencies.containsKey(dependencyName); + } + + private boolean hasViteScript(JSONObject scripts) + { + if (scripts == null) + { + return false; + } + for (String key : scripts.keySet()) + { + String command = scripts.getString(key); + if (command != null && command.contains("vite")) + { + return true; + } + } + return false; + } + + private void writeUtf8(File file, String content) throws IOException + { + FileOutputStream output = new FileOutputStream(file); + try + { + output.write(content.getBytes(Charset.forName("UTF-8"))); + } + finally + { + output.close(); + } + } + + private File resolveRepositoryRoot() throws IOException + { + File current = new File("").getCanonicalFile(); + while (current != null) + { + if (new File(current, "pom.xml").isFile() + && new File(current, "ruoyi-common").isDirectory() + && new File(current, "ruoyi-framework").isDirectory()) + { + return current; + } + current = current.getParentFile(); + } + throw new ServiceException("Unable to locate RuoYi repository root for preview backend"); + } + + private File preparePreviewBuildDirectory(File workspace) throws IOException + { + File previewBuildDirectory = new File(workspace, ".easycode-preview").getCanonicalFile(); + ensureChildPath(workspace, previewBuildDirectory); + if (!previewBuildDirectory.mkdirs() && !previewBuildDirectory.isDirectory()) + { + throw new IOException("Unable to create preview build directory"); + } + return previewBuildDirectory; + } + + private File writeBackendReactorPom(File previewBuildDirectory, File repositoryRoot, File backendDirectory) throws IOException + { + File reactorPom = new File(previewBuildDirectory, "pom.xml").getCanonicalFile(); + String content = "\n" + + "\n" + + " 4.0.0\n" + + " com.easycode.preview\n" + + " easycode-preview-reactor\n" + + " 1.0.0\n" + + " pom\n" + + " \n" + + " " + xmlEscape(mavenPath(previewBuildDirectory, new File(repositoryRoot, "ruoyi-common"))) + "\n" + + " " + xmlEscape(mavenPath(previewBuildDirectory, new File(repositoryRoot, "ruoyi-system"))) + "\n" + + " " + xmlEscape(mavenPath(previewBuildDirectory, new File(repositoryRoot, "ruoyi-framework"))) + "\n" + + " " + xmlEscape(mavenPath(previewBuildDirectory, backendDirectory)) + "\n" + + " \n" + + "\n"; + FileOutputStream output = new FileOutputStream(reactorPom); + try + { + output.write(content.getBytes(Charset.forName("UTF-8"))); + } + finally + { + output.close(); + } + return reactorPom; + } + + private String mavenPath(File fromDirectory, File target) throws IOException + { + File from = fromDirectory.getCanonicalFile(); + File to = target.getCanonicalFile(); + Path fromPath = from.toPath(); + Path toPath = to.toPath(); + Path fromRoot = fromPath.getRoot(); + Path toRoot = toPath.getRoot(); + String path; + if (fromRoot != null && fromRoot.equals(toRoot)) + { + path = fromPath.relativize(toPath).toString(); + } + else + { + path = to.getAbsolutePath(); + } + return path.replace(File.separatorChar, '/'); + } + + private String xmlEscape(String value) + { + return value.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """); + } + + private List findDirectoriesContaining(File root, String fileName) + { + List candidates = new ArrayList(); + collectDirectoriesContaining(root, fileName, candidates); + Collections.sort(candidates, new Comparator() + { + @Override + public int compare(File left, File right) + { + return left.getAbsolutePath().compareTo(right.getAbsolutePath()); + } + }); + return candidates; + } + + private void collectDirectoriesContaining(File directory, String fileName, List candidates) + { + if (directory == null || !directory.isDirectory()) + { + return; + } + if ("node_modules".equals(directory.getName())) + { + return; + } + if (new File(directory, fileName).isFile()) + { + candidates.add(directory); + } + File[] children = directory.listFiles(); + if (children == null) + { + return; + } + for (File child : children) + { + if (child.isDirectory()) + { + collectDirectoriesContaining(child, fileName, candidates); + } + } + } + + private File pickPreferredDirectory(List candidates, String... nameMarkers) + { + if (candidates == null || candidates.isEmpty()) + { + return null; + } + for (String marker : nameMarkers) + { + for (File candidate : candidates) + { + String name = candidate.getName().toLowerCase(); + if (name.contains(marker.toLowerCase())) + { + return candidate; + } + } + } + return candidates.get(0); + } + + private File resolveSqlDirectory(File workspace, File backendDirectory) + { + File backendSqlDirectory = new File(backendDirectory, "sql"); + if (containsSqlFile(backendSqlDirectory)) + { + return backendSqlDirectory; + } + + File rootSqlDirectory = new File(workspace, "sql"); + if (containsSqlFile(rootSqlDirectory)) + { + return rootSqlDirectory; + } + + return backendSqlDirectory.isDirectory() ? backendSqlDirectory : rootSqlDirectory; + } + + private boolean containsSqlFile(File directory) + { + if (directory == null || !directory.isDirectory()) + { + return false; + } + File[] files = directory.listFiles(); + if (files == null) + { + return false; + } + for (File file : files) + { + if (file.isFile() && file.getName().toLowerCase().endsWith(".sql")) + { + return true; + } + } + return false; + } + + private void applyBackendPreviewCompatibilityFixes(File backendDirectory) throws IOException + { + File sourceRoot = new File(backendDirectory, "src/main/java"); + if (!sourceRoot.isDirectory()) + { + return; + } + List javaFiles = new ArrayList(); + collectJavaFiles(sourceRoot, javaFiles); + for (File javaFile : javaFiles) + { + alignJavaFileNameWithPublicClass(backendDirectory, javaFile); + } + } + + private void collectJavaFiles(File directory, List javaFiles) + { + File[] children = directory.listFiles(); + if (children == null) + { + return; + } + for (File child : children) + { + if (child.isDirectory()) + { + collectJavaFiles(child, javaFiles); + } + else if (child.isFile() && child.getName().endsWith(".java")) + { + javaFiles.add(child); + } + } + } + + private void alignJavaFileNameWithPublicClass(File backendDirectory, File javaFile) throws IOException + { + String content = new String(Files.readAllBytes(javaFile.toPath()), Charset.forName("UTF-8")); + Matcher matcher = PUBLIC_CLASS_PATTERN.matcher(content); + if (!matcher.find()) + { + return; + } + String expectedFileName = matcher.group(1) + ".java"; + if (expectedFileName.equals(javaFile.getName())) + { + return; + } + File target = new File(javaFile.getParentFile(), expectedFileName).getCanonicalFile(); + ensureChildPath(backendDirectory, target); + if (target.exists()) + { + throw new IOException("Generated backend contains duplicate Java public class file: " + target.getAbsolutePath()); + } + Files.move(javaFile.toPath(), target.toPath()); + } + + @Override + public ProjectRunPreviewStatus status(Long userId, Long projectId) + { + frontProjectService.getProject(userId, projectId); + RunPreviewSession session = sessions.get(sessionKey(userId, projectId)); + if (session == null) + { + return notStarted(projectId); + } + refreshProcessStatus(session); + return toStatus(session); + } + + @Override + public ProjectRunPreviewStatus stop(Long userId, Long projectId) + { + frontProjectService.getProject(userId, projectId); + RunPreviewSession session = sessions.get(sessionKey(userId, projectId)); + if (session == null) + { + return notStarted(projectId); + } + destroyProcesses(session); + session.status = ProjectRunPreviewStatus.STOPPED; + session.message = "Preview stopped"; + session.updatedAt = now(); + return toStatus(session); + } + + private File prepareWorkspace(Long projectId, byte[] sourceZip) throws IOException + { + File root = new File(workspaceRoot).getCanonicalFile(); + if (!root.mkdirs() && !root.isDirectory()) + { + throw new IOException("Unable to create preview workspace root"); + } + File workspace = nextWorkspace(root, projectId); + ensureChildPath(root, workspace); + if (!workspace.mkdirs() && !workspace.isDirectory()) + { + throw new IOException("Unable to create preview workspace"); + } + unzip(sourceZip, workspace); + return workspace; + } + + private File nextWorkspace(File root, Long projectId) throws IOException + { + String baseName = "project-" + projectId + "-" + System.currentTimeMillis(); + File workspace = new File(root, baseName).getCanonicalFile(); + int index = 1; + while (workspace.exists()) + { + workspace = new File(root, baseName + "-" + index).getCanonicalFile(); + index++; + } + return workspace; + } + + private void unzip(byte[] sourceZip, File workspace) throws IOException + { + ZipInputStream zip = new ZipInputStream(new ByteArrayInputStream(sourceZip)); + try + { + ZipEntry entry; + byte[] buffer = new byte[4096]; + while ((entry = zip.getNextEntry()) != null) + { + String entryName = normalizeEntryName(entry.getName()); + if (entryName == null) + { + zip.closeEntry(); + continue; + } + File target = new File(workspace, entryName).getCanonicalFile(); + ensureChildPath(workspace, target); + if (entry.isDirectory()) + { + if (!target.mkdirs() && !target.isDirectory()) + { + throw new IOException("Unable to create directory " + target.getAbsolutePath()); + } + } + else + { + File parent = target.getParentFile(); + if (parent != null && !parent.mkdirs() && !parent.isDirectory()) + { + throw new IOException("Unable to create directory " + parent.getAbsolutePath()); + } + FileOutputStream output = new FileOutputStream(target); + try + { + int length; + while ((length = zip.read(buffer)) > -1) + { + output.write(buffer, 0, length); + } + } + finally + { + output.close(); + } + } + zip.closeEntry(); + } + } + finally + { + zip.close(); + } + } + + private String normalizeEntryName(String entryName) + { + if (entryName == null) + { + return null; + } + String normalized = entryName.replace('\\', '/').trim(); + while (normalized.startsWith("/")) + { + normalized = normalized.substring(1); + } + if (normalized.length() == 0) + { + return null; + } + if (normalized.startsWith("../") || normalized.contains("/../")) + { + throw new ServiceException("Unsafe generated project path"); + } + return normalized; + } + + private void deleteRecursively(File file) throws IOException + { + if (file == null || !file.exists()) + { + return; + } + if (file.isDirectory()) + { + File[] children = file.listFiles(); + if (children != null) + { + for (File child : children) + { + deleteRecursively(child); + } + } + } + if (!file.delete()) + { + throw new IOException("Unable to clean preview workspace: " + file.getAbsolutePath()); + } + } + + private void ensureChildPath(File root, File child) throws IOException + { + String rootPath = root.getCanonicalPath(); + String childPath = child.getCanonicalPath(); + if (!childPath.equals(rootPath) && !childPath.startsWith(rootPath + File.separator)) + { + throw new ServiceException("Preview workspace path is outside the configured root"); + } + } + + private void refreshProcessStatus(RunPreviewSession session) + { + if (ProjectRunPreviewStatus.STOPPED.equals(session.status) || ProjectRunPreviewStatus.FAILED.equals(session.status)) + { + return; + } + if (session.backendProcess != null && !session.backendProcess.isAlive()) + { + session.status = ProjectRunPreviewStatus.FAILED; + session.message = "生成的后端进程已退出"; + session.updatedAt = now(); + return; + } + if (session.frontendProcess != null && !session.frontendProcess.isAlive()) + { + session.status = ProjectRunPreviewStatus.FAILED; + session.message = "生成的前台前端进程已退出"; + session.updatedAt = now(); + return; + } + if (session.adminFrontendProcess != null && !session.adminFrontendProcess.isAlive()) + { + session.status = ProjectRunPreviewStatus.FAILED; + session.message = "生成的后台前端进程已退出"; + session.updatedAt = now(); + return; + } + if (areFrontendUrlsReady(session)) + { + if (!ProjectRunPreviewStatus.RUNNING.equals(session.status)) + { + session.status = ProjectRunPreviewStatus.RUNNING; + session.message = "运行预览已就绪"; + session.updatedAt = now(); + } + } + else if (!ProjectRunPreviewStatus.STARTING.equals(session.status)) + { + session.status = ProjectRunPreviewStatus.STARTING; + session.message = "正在等待前端预览服务就绪"; + session.updatedAt = now(); + } + } + + private boolean areFrontendUrlsReady(RunPreviewSession session) + { + if (!isUrlReady(session.frontendUrl)) + { + return false; + } + return session.adminFrontendUrl == null || isUrlReady(session.adminFrontendUrl); + } + + private boolean isUrlReady(String url) + { + if (url == null || url.trim().length() == 0) + { + return false; + } + HttpURLConnection connection = null; + try + { + connection = (HttpURLConnection) new URL(url).openConnection(); + connection.setConnectTimeout(1000); + connection.setReadTimeout(1000); + connection.setRequestMethod("GET"); + int statusCode = connection.getResponseCode(); + return statusCode >= 200 && statusCode < 400; + } + catch (IOException ignored) + { + return false; + } + finally + { + if (connection != null) + { + connection.disconnect(); + } + } + } + + private void destroyProcesses(RunPreviewSession session) + { + destroyProcess(session.adminFrontendProcess, session.adminFrontendPort); + destroyProcess(session.frontendProcess, session.frontendPort); + destroyProcess(session.backendProcess, session.backendPort); + } + + private void destroyProcess(Process process, Integer port) + { + processKiller.stop(process, port); + } + + private void startLogReader(final RunPreviewSession session, final Process process, final String name) + { + if (process == null) + { + return; + } + Thread thread = new Thread(new Runnable() + { + @Override + public void run() + { + BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream(), Charset.forName("UTF-8"))); + try + { + String line; + while ((line = reader.readLine()) != null) + { + appendProcessLog(session, name, line); + } + } + catch (IOException ignored) + { + appendProcessLog(session, name, "log stream closed"); + } + } + }); + thread.setDaemon(true); + thread.start(); + } + + private void appendLog(RunPreviewSession session, String line) + { + synchronized (session.logs) + { + session.logs.append(line).append('\n'); + if (session.logs.length() > MAX_LOG_LENGTH) + { + session.logs.delete(0, session.logs.length() - MAX_LOG_LENGTH); + } + } + } + + private void appendProcessLog(RunPreviewSession session, String name, String line) + { + String formatted = "[" + name + "] " + line; + appendLog(session, formatted); + appendLog(logBuilder(session, name), line); + } + + private StringBuilder logBuilder(RunPreviewSession session, String name) + { + if ("backend".equals(name)) + { + return session.backendLogs; + } + if ("adminFrontend".equals(name)) + { + return session.adminFrontendLogs; + } + return session.frontendLogs; + } + + private void appendLog(StringBuilder logs, String line) + { + synchronized (logs) + { + logs.append(line).append('\n'); + if (logs.length() > MAX_LOG_LENGTH) + { + logs.delete(0, logs.length() - MAX_LOG_LENGTH); + } + } + } + + private ProjectRunPreviewStatus notStarted(Long projectId) + { + ProjectRunPreviewStatus status = new ProjectRunPreviewStatus(); + status.setProjectId(projectId); + status.setStatus(ProjectRunPreviewStatus.NOT_STARTED); + status.setMessage("Preview has not been started"); + status.setUpdatedAt(now()); + status.setLogs(""); + status.setBackendLogs(""); + status.setFrontendLogs(""); + status.setAdminFrontendLogs(""); + return status; + } + + private ProjectRunPreviewStatus toStatus(RunPreviewSession session) + { + ProjectRunPreviewStatus status = new ProjectRunPreviewStatus(); + status.setProjectId(session.projectId); + status.setStatus(session.status); + status.setMessage(session.message); + status.setBackendUrl(session.backendUrl); + status.setFrontendUrl(session.frontendUrl); + status.setAdminFrontendUrl(session.adminFrontendUrl); + status.setBackendPort(session.backendPort); + status.setFrontendPort(session.frontendPort); + status.setAdminFrontendPort(session.adminFrontendPort); + status.setWorkspacePath(session.workspacePath); + status.setDatabaseName(session.databaseName); + status.setStartedAt(session.startedAt); + status.setUpdatedAt(session.updatedAt); + synchronized (session.logs) + { + status.setLogs(session.logs.toString()); + } + synchronized (session.backendLogs) + { + status.setBackendLogs(session.backendLogs.toString()); + } + synchronized (session.frontendLogs) + { + status.setFrontendLogs(session.frontendLogs.toString()); + } + synchronized (session.adminFrontendLogs) + { + status.setAdminFrontendLogs(session.adminFrontendLogs.toString()); + } + return status; + } + + private String sessionKey(Long userId, Long projectId) + { + return userId + ":" + projectId; + } + + private String now() + { + return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date()); + } + + private static class RunPreviewSession + { + private Long projectId; + private String status; + private String message; + private String backendUrl; + private String frontendUrl; + private String adminFrontendUrl; + private Integer backendPort; + private Integer frontendPort; + private Integer adminFrontendPort; + private String workspacePath; + private String databaseName; + private String startedAt; + private String updatedAt; + private Process backendProcess; + private Process frontendProcess; + private Process adminFrontendProcess; + private final StringBuilder logs = new StringBuilder(); + private final StringBuilder backendLogs = new StringBuilder(); + private final StringBuilder frontendLogs = new StringBuilder(); + private final StringBuilder adminFrontendLogs = new StringBuilder(); + } +} diff --git a/RuoYi-Vue/ruoyi-generator/src/test/java/com/ruoyi/generator/service/front/FrontProjectRunPreviewServiceImplTest.java b/RuoYi-Vue/ruoyi-generator/src/test/java/com/ruoyi/generator/service/front/FrontProjectRunPreviewServiceImplTest.java new file mode 100644 index 0000000..7358f14 --- /dev/null +++ b/RuoYi-Vue/ruoyi-generator/src/test/java/com/ruoyi/generator/service/front/FrontProjectRunPreviewServiceImplTest.java @@ -0,0 +1,623 @@ +package com.ruoyi.generator.service.front; + +import com.ruoyi.generator.domain.front.FrontProject; +import com.ruoyi.generator.domain.front.dto.ProjectRunPreviewStatus; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.lang.reflect.Field; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.times; + +public class FrontProjectRunPreviewServiceImplTest +{ + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + private FrontProjectRunPreviewServiceImpl runPreviewService; + + @Mock + private IFrontProjectService frontProjectService; + @Mock + private IFrontProjectPreviewService frontProjectPreviewService; + @Mock + private RunPreviewPortAllocator portAllocator; + @Mock + private RunPreviewDatabaseInitializer databaseInitializer; + @Mock + private RunPreviewProcessRunner processRunner; + + private RecordingProcessKiller processKiller; + private FakeProcess backendProcess; + private FakeProcess frontendProcess; + private FakeProcess adminFrontendProcess; + + @Before + public void setUp() throws Exception + { + MockitoAnnotations.initMocks(this); + runPreviewService = new FrontProjectRunPreviewServiceImpl(); + processKiller = new RecordingProcessKiller(); + backendProcess = new FakeProcess(); + frontendProcess = new FakeProcess(); + adminFrontendProcess = new FakeProcess(); + + setField("frontProjectService", frontProjectService); + setField("frontProjectPreviewService", frontProjectPreviewService); + setField("portAllocator", portAllocator); + setField("databaseInitializer", databaseInitializer); + setField("processRunner", processRunner); + setField("processKiller", processKiller); + setField("workspaceRoot", temporaryFolder.newFolder("preview-workspaces").getAbsolutePath()); + setField("mysqlHost", "127.0.0.1"); + setField("mysqlPort", 3306); + setField("mysqlUsername", "root"); + setField("mysqlPassword", "123456"); + } + + @Test + public void startCreatesWorkspaceInitializesDatabaseAndStartsProcesses() throws Exception + { + when(frontProjectService.getProject(10L, 20L)).thenReturn(project()); + when(frontProjectPreviewService.downloadAll(10L, 20L)).thenReturn(zipWithGeneratedProject()); + when(portAllocator.allocate()).thenReturn(18080, 18081); + when(databaseInitializer.initialize(any(File.class), anyString(), eq("127.0.0.1"), eq(3306), eq("root"), eq("123456"))) + .thenReturn("jdbc:mysql://127.0.0.1:3306/preview_20_test"); + whenBackendStarts(); + when(processRunner.startFrontend(any(File.class), any(Map.class))).thenReturn(frontendProcess); + + ProjectRunPreviewStatus status = runPreviewService.start(10L, 20L); + + assertEquals("STARTING", status.getStatus()); + assertEquals("http://127.0.0.1:18080", status.getBackendUrl()); + assertEquals("http://127.0.0.1:18081", status.getFrontendUrl()); + assertTrue(new File(status.getWorkspacePath(), "pom.xml").isFile()); + assertTrue(new File(status.getWorkspacePath(), "vue/package.json").isFile()); + + ArgumentCaptor sqlDirectoryCaptor = ArgumentCaptor.forClass(File.class); + ArgumentCaptor databaseNameCaptor = ArgumentCaptor.forClass(String.class); + verify(databaseInitializer).initialize(sqlDirectoryCaptor.capture(), databaseNameCaptor.capture(), eq("127.0.0.1"), eq(3306), eq("root"), eq("123456")); + assertEquals("sql", sqlDirectoryCaptor.getValue().getName()); + assertTrue(databaseNameCaptor.getValue().startsWith("preview_20_")); + + ArgumentCaptor backendEnvCaptor = ArgumentCaptor.forClass(Map.class); + verify(processRunner).startBackend(any(File.class), any(File.class), any(File.class), anyString(), any(File.class), backendEnvCaptor.capture()); + assertEquals("18080", backendEnvCaptor.getValue().get("SERVER_PORT")); + assertEquals("jdbc:mysql://127.0.0.1:3306/preview_20_test", backendEnvCaptor.getValue().get("DB_URL")); + assertEquals("root", backendEnvCaptor.getValue().get("DB_USERNAME")); + assertEquals("123456", backendEnvCaptor.getValue().get("DB_PASSWORD")); + assertTrue(((String) backendEnvCaptor.getValue().get("JAVA_HOME")).length() > 0); + assertTrue(((String) backendEnvCaptor.getValue().get("MAVEN_OPTS")).contains("java.io.tmpdir")); + assertTrue(((String) backendEnvCaptor.getValue().get("MAVEN_OPTS")).contains("java-tmp")); + assertTrue(((String) backendEnvCaptor.getValue().get("JAVA_TOOL_OPTIONS")).contains("java.io.tmpdir")); + + ArgumentCaptor frontendEnvCaptor = ArgumentCaptor.forClass(Map.class); + verify(processRunner).startFrontend(any(File.class), frontendEnvCaptor.capture()); + assertEquals("18081", frontendEnvCaptor.getValue().get("PORT")); + assertEquals("http://127.0.0.1:18080", frontendEnvCaptor.getValue().get("VUE_APP_API_BASE_URL")); + String frontendCache = (String) frontendEnvCaptor.getValue().get("NPM_CONFIG_CACHE"); + assertEquals(".npm-cache", new File(frontendCache).getName()); + assertEquals(new File(status.getWorkspacePath()).getParentFile().getCanonicalFile(), + new File(frontendCache).getParentFile().getCanonicalFile()); + assertEquals(frontendCache, frontendEnvCaptor.getValue().get("npm_config_cache")); + assertEquals("false", frontendEnvCaptor.getValue().get("NPM_CONFIG_UPDATE_NOTIFIER")); + assertEquals("1", frontendEnvCaptor.getValue().get("NO_UPDATE_NOTIFIER")); + assertEquals("false", frontendEnvCaptor.getValue().get("NPM_CONFIG_OFFLINE")); + assertEquals("true", frontendEnvCaptor.getValue().get("NPM_CONFIG_PREFER_OFFLINE")); + assertEquals("false", frontendEnvCaptor.getValue().get("NPM_CONFIG_PREFER_ONLINE")); + assertEquals("", frontendEnvCaptor.getValue().get("NPM_CONFIG_PROXY")); + assertEquals("", frontendEnvCaptor.getValue().get("NPM_CONFIG_HTTPS_PROXY")); + assertEquals("*", frontendEnvCaptor.getValue().get("NO_PROXY")); + } + + @Test + public void startRenamesGeneratedJavaFileToMatchPublicApplicationClass() throws Exception + { + when(frontProjectService.getProject(10L, 20L)).thenReturn(project()); + when(frontProjectPreviewService.downloadAll(10L, 20L)).thenReturn(zipWithMismatchedApplicationFile()); + when(portAllocator.allocate()).thenReturn(18080, 18081); + when(databaseInitializer.initialize(any(File.class), anyString(), anyString(), anyInt(), anyString(), anyString())) + .thenReturn("jdbc:mysql://127.0.0.1:3306/preview_20_test"); + whenBackendStarts(); + when(processRunner.startFrontend(any(File.class), any(Map.class))).thenReturn(frontendProcess); + + ProjectRunPreviewStatus status = runPreviewService.start(10L, 20L); + + File javaDirectory = new File(status.getWorkspacePath(), "demo-backend/src/main/java/com/example/demo"); + assertTrue(new File(javaDirectory, "DemoApplication.java").isFile()); + assertTrue(!new File(javaDirectory, "Application.java").exists()); + } + + @Test + public void startSupportsCombinedDownloadPackageLayout() throws Exception + { + when(frontProjectService.getProject(10L, 20L)).thenReturn(project()); + when(frontProjectPreviewService.downloadAll(10L, 20L)).thenReturn(zipWithCombinedGeneratedProject()); + when(portAllocator.allocate()).thenReturn(18080, 18081, 18082); + when(databaseInitializer.initialize(any(File.class), anyString(), eq("127.0.0.1"), eq(3306), eq("root"), eq("123456"))) + .thenReturn("jdbc:mysql://127.0.0.1:3306/preview_20_test"); + whenBackendStarts(); + when(processRunner.startFrontend(any(File.class), any(Map.class))).thenReturn(frontendProcess, adminFrontendProcess); + + ProjectRunPreviewStatus status = runPreviewService.start(10L, 20L); + + assertEquals("STARTING", status.getStatus()); + assertEquals("http://127.0.0.1:18081", status.getFrontendUrl()); + assertEquals("http://127.0.0.1:18082", status.getAdminFrontendUrl()); + assertTrue(new File(status.getWorkspacePath(), "demo-backend/pom.xml").isFile()); + assertTrue(new File(status.getWorkspacePath(), "demo-web/package.json").isFile()); + + ArgumentCaptor previewBuildDirectoryCaptor = ArgumentCaptor.forClass(File.class); + ArgumentCaptor rootPomCaptor = ArgumentCaptor.forClass(File.class); + ArgumentCaptor reactorPomCaptor = ArgumentCaptor.forClass(File.class); + ArgumentCaptor backendModulePathCaptor = ArgumentCaptor.forClass(String.class); + ArgumentCaptor localRepositoryCaptor = ArgumentCaptor.forClass(File.class); + verify(processRunner).startBackend(previewBuildDirectoryCaptor.capture(), rootPomCaptor.capture(), reactorPomCaptor.capture(), backendModulePathCaptor.capture(), localRepositoryCaptor.capture(), any(Map.class)); + assertEquals(".easycode-preview", previewBuildDirectoryCaptor.getValue().getName()); + assertEquals("pom.xml", rootPomCaptor.getValue().getName()); + assertEquals("pom.xml", reactorPomCaptor.getValue().getName()); + assertEquals("../demo-backend", backendModulePathCaptor.getValue()); + assertEquals("repository", localRepositoryCaptor.getValue().getName()); + + String reactorPom = new String(Files.readAllBytes(reactorPomCaptor.getValue().toPath()), StandardCharsets.UTF_8); + assertTrue(reactorPom.contains("ruoyi-common")); + assertTrue(reactorPom.contains("ruoyi-system")); + assertTrue(reactorPom.contains("ruoyi-framework")); + assertTrue(reactorPom.contains("../demo-backend")); + + ArgumentCaptor frontendDirectoryCaptor = ArgumentCaptor.forClass(File.class); + ArgumentCaptor frontendEnvCaptor = ArgumentCaptor.forClass(Map.class); + verify(processRunner, times(2)).startFrontend(frontendDirectoryCaptor.capture(), frontendEnvCaptor.capture()); + assertEquals("demo-web", frontendDirectoryCaptor.getAllValues().get(0).getName()); + assertEquals("demo-admin", frontendDirectoryCaptor.getAllValues().get(1).getName()); + String frontendCache = (String) frontendEnvCaptor.getAllValues().get(0).get("NPM_CONFIG_CACHE"); + String adminFrontendCache = (String) frontendEnvCaptor.getAllValues().get(1).get("NPM_CONFIG_CACHE"); + assertEquals(frontendCache, adminFrontendCache); + assertEquals(".npm-cache", new File(frontendCache).getName()); + } + + @Test + public void startInitializesDatabaseFromBackendSqlWhenCombinedPackageHasRootSql() throws Exception + { + when(frontProjectService.getProject(10L, 20L)).thenReturn(project()); + when(frontProjectPreviewService.downloadAll(10L, 20L)).thenReturn(zipWithCombinedGeneratedProjectAndBackendSql()); + when(portAllocator.allocate()).thenReturn(18080, 18081); + when(databaseInitializer.initialize(any(File.class), anyString(), eq("127.0.0.1"), eq(3306), eq("root"), eq("123456"))) + .thenReturn("jdbc:mysql://127.0.0.1:3306/preview_20_test"); + whenBackendStarts(); + when(processRunner.startFrontend(any(File.class), any(Map.class))).thenReturn(frontendProcess); + + ProjectRunPreviewStatus status = runPreviewService.start(10L, 20L); + + ArgumentCaptor sqlDirectoryCaptor = ArgumentCaptor.forClass(File.class); + verify(databaseInitializer).initialize(sqlDirectoryCaptor.capture(), anyString(), eq("127.0.0.1"), eq(3306), eq("root"), eq("123456")); + assertEquals("STARTING", status.getStatus()); + assertEquals("sql", sqlDirectoryCaptor.getValue().getName()); + assertEquals("demo-backend", sqlDirectoryCaptor.getValue().getParentFile().getName()); + } + + @Test + public void startWrapsGeneratedVueConfigForPreviewPorts() throws Exception + { + when(frontProjectService.getProject(10L, 20L)).thenReturn(project()); + when(frontProjectPreviewService.downloadAll(10L, 20L)).thenReturn(zipWithCombinedGeneratedProject()); + when(portAllocator.allocate()).thenReturn(18080, 18081); + when(databaseInitializer.initialize(any(File.class), anyString(), eq("127.0.0.1"), eq(3306), eq("root"), eq("123456"))) + .thenReturn("jdbc:mysql://127.0.0.1:3306/preview_20_test"); + whenBackendStarts(); + when(processRunner.startFrontend(any(File.class), any(Map.class))).thenReturn(frontendProcess); + + ProjectRunPreviewStatus status = runPreviewService.start(10L, 20L); + + File frontendDirectory = new File(status.getWorkspacePath(), "demo-web"); + assertTrue(new File(frontendDirectory, "vue.config.generated.js").isFile()); + String previewConfig = new String(Files.readAllBytes(new File(frontendDirectory, "vue.config.js").toPath()), StandardCharsets.UTF_8); + assertTrue(previewConfig.contains("process.env.PORT")); + assertTrue(previewConfig.contains("process.env.VUE_APP_API_BASE_URL")); + assertTrue(previewConfig.contains("vue.config.generated.js")); + } + + @Test + public void startWritesViteConfigForGeneratedViteFrontend() throws Exception + { + when(frontProjectService.getProject(10L, 20L)).thenReturn(project()); + when(frontProjectPreviewService.downloadAll(10L, 20L)).thenReturn(zipWithViteGeneratedProject()); + when(portAllocator.allocate()).thenReturn(18080, 18081); + when(databaseInitializer.initialize(any(File.class), anyString(), eq("127.0.0.1"), eq(3306), eq("root"), eq("123456"))) + .thenReturn("jdbc:mysql://127.0.0.1:3306/preview_20_test"); + whenBackendStarts(); + when(processRunner.startFrontend(any(File.class), any(Map.class))).thenReturn(frontendProcess); + + ProjectRunPreviewStatus status = runPreviewService.start(10L, 20L); + + File frontendDirectory = new File(status.getWorkspacePath(), "demo-portal"); + String viteConfig = new String(Files.readAllBytes(new File(frontendDirectory, "vite.config.js").toPath()), StandardCharsets.UTF_8); + assertTrue(viteConfig.contains("process.env.PORT")); + assertTrue(viteConfig.contains("process.env.VUE_APP_API_BASE_URL")); + assertTrue(viteConfig.contains("process.env.VITE_APP_API_BASE_URL")); + + ArgumentCaptor frontendEnvCaptor = ArgumentCaptor.forClass(Map.class); + verify(processRunner).startFrontend(any(File.class), frontendEnvCaptor.capture()); + assertEquals("/api", frontendEnvCaptor.getValue().get("VITE_APP_BASE_API")); + } + + @Test + public void startUsesFreshWorkspaceWhenPreviousProjectWorkspaceExists() throws Exception + { + File legacyWorkspace = new File(temporaryFolder.getRoot(), "preview-workspaces/project-20"); + assertTrue(legacyWorkspace.mkdirs()); + File legacyFile = new File(legacyWorkspace, "server_code"); + assertTrue(legacyFile.createNewFile()); + + when(frontProjectService.getProject(10L, 20L)).thenReturn(project()); + when(frontProjectPreviewService.downloadAll(10L, 20L)).thenReturn(zipWithGeneratedProject()); + when(portAllocator.allocate()).thenReturn(18080, 18081); + when(databaseInitializer.initialize(any(File.class), anyString(), anyString(), anyInt(), anyString(), anyString())) + .thenReturn("jdbc:mysql://127.0.0.1:3306/preview_20_test"); + whenBackendStarts(); + when(processRunner.startFrontend(any(File.class), any(Map.class))).thenReturn(frontendProcess); + + ProjectRunPreviewStatus status = runPreviewService.start(10L, 20L); + + assertEquals("STARTING", status.getStatus()); + assertTrue("legacy workspace should be left alone when a new preview starts", legacyFile.isFile()); + assertTrue("fresh workspace should include the project id and a unique suffix", + new File(status.getWorkspacePath()).getName().startsWith("project-20-")); + } + + @Test + public void statusMarksPreviewRunningAfterFrontendServerIsReady() throws Exception + { + HttpServer frontendServer = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + frontendServer.createContext("/", new HttpHandler() + { + @Override + public void handle(HttpExchange exchange) throws IOException + { + byte[] body = "ready".getBytes("UTF-8"); + exchange.sendResponseHeaders(200, body.length); + OutputStream response = exchange.getResponseBody(); + response.write(body); + response.close(); + } + }); + frontendServer.start(); + + try + { + when(frontProjectService.getProject(10L, 20L)).thenReturn(project()); + when(frontProjectPreviewService.downloadAll(10L, 20L)).thenReturn(zipWithGeneratedProject()); + when(portAllocator.allocate()).thenReturn(18080, frontendServer.getAddress().getPort()); + when(databaseInitializer.initialize(any(File.class), anyString(), anyString(), anyInt(), anyString(), anyString())) + .thenReturn("jdbc:mysql://127.0.0.1:3306/preview_20_test"); + whenBackendStarts(); + when(processRunner.startFrontend(any(File.class), any(Map.class))).thenReturn(frontendProcess); + + ProjectRunPreviewStatus startStatus = runPreviewService.start(10L, 20L); + ProjectRunPreviewStatus readyStatus = runPreviewService.status(10L, 20L); + + assertEquals("STARTING", startStatus.getStatus()); + assertEquals("RUNNING", readyStatus.getStatus()); + } + finally + { + frontendServer.stop(0); + } + } + + @Test + public void statusWaitsForBothFrontendsWhenAdminFrontendExists() throws Exception + { + HttpServer frontendServer = readyServer(); + HttpServer adminFrontendServer = readyServer(); + frontendServer.start(); + + try + { + when(frontProjectService.getProject(10L, 20L)).thenReturn(project()); + when(frontProjectPreviewService.downloadAll(10L, 20L)).thenReturn(zipWithCombinedGeneratedProject()); + when(portAllocator.allocate()).thenReturn(18080, frontendServer.getAddress().getPort(), adminFrontendServer.getAddress().getPort()); + when(databaseInitializer.initialize(any(File.class), anyString(), anyString(), anyInt(), anyString(), anyString())) + .thenReturn("jdbc:mysql://127.0.0.1:3306/preview_20_test"); + whenBackendStarts(); + when(processRunner.startFrontend(any(File.class), any(Map.class))).thenReturn(frontendProcess, adminFrontendProcess); + + runPreviewService.start(10L, 20L); + ProjectRunPreviewStatus waitingStatus = runPreviewService.status(10L, 20L); + adminFrontendServer.start(); + ProjectRunPreviewStatus readyStatus = runPreviewService.status(10L, 20L); + + assertEquals("STARTING", waitingStatus.getStatus()); + assertEquals("RUNNING", readyStatus.getStatus()); + } + finally + { + frontendServer.stop(0); + adminFrontendServer.stop(0); + } + } + + @Test + public void statusUsesChineseMessageWhenGeneratedFrontendProcessExits() throws Exception + { + when(frontProjectService.getProject(10L, 20L)).thenReturn(project()); + when(frontProjectPreviewService.downloadAll(10L, 20L)).thenReturn(zipWithGeneratedProject()); + when(portAllocator.allocate()).thenReturn(18080, 18081); + when(databaseInitializer.initialize(any(File.class), anyString(), anyString(), anyInt(), anyString(), anyString())) + .thenReturn("jdbc:mysql://127.0.0.1:3306/preview_20_test"); + whenBackendStarts(); + when(processRunner.startFrontend(any(File.class), any(Map.class))).thenReturn(frontendProcess); + + runPreviewService.start(10L, 20L); + frontendProcess.destroy(); + ProjectRunPreviewStatus status = runPreviewService.status(10L, 20L); + + assertEquals("FAILED", status.getStatus()); + assertEquals("生成的前台前端进程已退出", status.getMessage()); + } + + @Test + public void stopDestroysRunningPreviewProcesses() throws Exception + { + when(frontProjectService.getProject(10L, 20L)).thenReturn(project()); + when(frontProjectPreviewService.downloadAll(10L, 20L)).thenReturn(zipWithGeneratedProject()); + when(portAllocator.allocate()).thenReturn(18080, 18081); + when(databaseInitializer.initialize(any(File.class), anyString(), anyString(), anyInt(), anyString(), anyString())) + .thenReturn("jdbc:mysql://127.0.0.1:3306/preview_20_test"); + whenBackendStarts(); + when(processRunner.startFrontend(any(File.class), any(Map.class))).thenReturn(frontendProcess); + + runPreviewService.start(10L, 20L); + ProjectRunPreviewStatus status = runPreviewService.stop(10L, 20L); + + assertEquals("STOPPED", status.getStatus()); + assertTrue(backendProcess.destroyed); + assertTrue(frontendProcess.destroyed); + assertEquals(Integer.valueOf(18081), processKiller.stoppedPorts.get(0)); + assertEquals(Integer.valueOf(18080), processKiller.stoppedPorts.get(1)); + } + + @Test + public void stopDestroysAdminFrontendAndAllListeningPorts() throws Exception + { + when(frontProjectService.getProject(10L, 20L)).thenReturn(project()); + when(frontProjectPreviewService.downloadAll(10L, 20L)).thenReturn(zipWithCombinedGeneratedProject()); + when(portAllocator.allocate()).thenReturn(18080, 18081, 18082); + when(databaseInitializer.initialize(any(File.class), anyString(), anyString(), anyInt(), anyString(), anyString())) + .thenReturn("jdbc:mysql://127.0.0.1:3306/preview_20_test"); + whenBackendStarts(); + when(processRunner.startFrontend(any(File.class), any(Map.class))).thenReturn(frontendProcess, adminFrontendProcess); + + runPreviewService.start(10L, 20L); + ProjectRunPreviewStatus status = runPreviewService.stop(10L, 20L); + + assertEquals("STOPPED", status.getStatus()); + assertTrue(backendProcess.destroyed); + assertTrue(frontendProcess.destroyed); + assertTrue(adminFrontendProcess.destroyed); + assertEquals(Integer.valueOf(18082), processKiller.stoppedPorts.get(0)); + assertEquals(Integer.valueOf(18081), processKiller.stoppedPorts.get(1)); + assertEquals(Integer.valueOf(18080), processKiller.stoppedPorts.get(2)); + } + + @Test + public void statusReturnsNotStartedWhenNoSessionExists() + { + when(frontProjectService.getProject(10L, 20L)).thenReturn(project()); + + ProjectRunPreviewStatus status = runPreviewService.status(10L, 20L); + + assertEquals("NOT_STARTED", status.getStatus()); + assertEquals(Long.valueOf(20L), status.getProjectId()); + } + + private FrontProject project() + { + FrontProject project = new FrontProject(); + project.setProjectId(20L); + project.setProjectName("Demo"); + project.setProjectFileName("demo"); + return project; + } + + private void whenBackendStarts() throws IOException + { + when(processRunner.startBackend(any(File.class), any(File.class), any(File.class), anyString(), any(File.class), any(Map.class))) + .thenReturn(backendProcess); + } + + private HttpServer readyServer() throws IOException + { + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", new HttpHandler() + { + @Override + public void handle(HttpExchange exchange) throws IOException + { + byte[] body = "ready".getBytes("UTF-8"); + exchange.sendResponseHeaders(200, body.length); + OutputStream response = exchange.getResponseBody(); + response.write(body); + response.close(); + } + }); + return server; + } + + private byte[] zipWithGeneratedProject() throws IOException + { + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + ZipOutputStream zip = new ZipOutputStream(outputStream); + addEntry(zip, "pom.xml", ""); + addEntry(zip, "vue/package.json", "{\"scripts\":{\"dev\":\"vue-cli-service serve\"}}"); + addEntry(zip, "sql/demo.sql", "create table demo (id bigint);"); + zip.close(); + return outputStream.toByteArray(); + } + + private byte[] zipWithMismatchedApplicationFile() throws IOException + { + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + ZipOutputStream zip = new ZipOutputStream(outputStream); + addEntry(zip, "demo-backend/pom.xml", ""); + addEntry(zip, "demo-backend/src/main/java/com/example/demo/Application.java", + "package com.example.demo;\n\npublic class DemoApplication {}\n"); + addEntry(zip, "demo-web/package.json", "{\"scripts\":{\"dev\":\"vue-cli-service serve\"}}"); + addEntry(zip, "sql/demo.sql", "create table demo (id bigint);"); + zip.close(); + return outputStream.toByteArray(); + } + + private byte[] zipWithCombinedGeneratedProject() throws IOException + { + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + ZipOutputStream zip = new ZipOutputStream(outputStream); + addEntry(zip, "demo-backend/pom.xml", ""); + addEntry(zip, "demo-web/package.json", "{\"scripts\":{\"dev\":\"vue-cli-service serve\"}}"); + addEntry(zip, "demo-web/vue.config.js", "module.exports = { devServer: { port: 8082, proxy: { \"/api\": { target: \"http://localhost:8080\" } } } }"); + addEntry(zip, "demo-admin/package.json", "{\"scripts\":{\"dev\":\"vue-cli-service serve\"}}"); + addEntry(zip, "sql/demo.sql", "create table demo (id bigint);"); + zip.close(); + return outputStream.toByteArray(); + } + + private byte[] zipWithCombinedGeneratedProjectAndBackendSql() throws IOException + { + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + ZipOutputStream zip = new ZipOutputStream(outputStream); + addEntry(zip, "demo-backend/pom.xml", ""); + addEntry(zip, "demo-backend/sql/demo.sql", "create table demo_backend (id bigint);"); + addEntry(zip, "demo-web/package.json", "{\"scripts\":{\"dev\":\"vue-cli-service serve\"}}"); + addEntry(zip, "sql/demo.sql", "create table demo_root (id bigint);"); + zip.close(); + return outputStream.toByteArray(); + } + + private byte[] zipWithViteGeneratedProject() throws IOException + { + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + ZipOutputStream zip = new ZipOutputStream(outputStream); + addEntry(zip, "demo-backend/pom.xml", ""); + addEntry(zip, "demo-portal/package.json", "{\"scripts\":{\"dev\":\"vite --host 0.0.0.0\"},\"dependencies\":{\"@vitejs/plugin-vue\":\"5.0.5\",\"vite\":\"5.3.1\",\"vue\":\"3.4.29\"}}"); + addEntry(zip, "sql/demo.sql", "create table demo (id bigint);"); + zip.close(); + return outputStream.toByteArray(); + } + + private void addEntry(ZipOutputStream zip, String name, String content) throws IOException + { + zip.putNextEntry(new ZipEntry(name)); + zip.write(content.getBytes("UTF-8")); + zip.closeEntry(); + } + + private void setField(String name, Object value) throws Exception + { + Field field = FrontProjectRunPreviewServiceImpl.class.getDeclaredField(name); + field.setAccessible(true); + field.set(runPreviewService, value); + } + + private static class FakeProcess extends Process + { + private boolean alive = true; + private boolean destroyed = false; + + @Override + public OutputStream getOutputStream() + { + return new ByteArrayOutputStream(); + } + + @Override + public InputStream getInputStream() + { + return new ByteArrayInputStream(new byte[0]); + } + + @Override + public InputStream getErrorStream() + { + return new ByteArrayInputStream(new byte[0]); + } + + @Override + public int waitFor() + { + alive = false; + return 0; + } + + @Override + public int exitValue() + { + if (alive) + { + throw new IllegalThreadStateException(); + } + return 0; + } + + @Override + public void destroy() + { + alive = false; + destroyed = true; + } + + @Override + public boolean isAlive() + { + return alive; + } + } + + private static class RecordingProcessKiller extends RunPreviewProcessKiller + { + private final List stoppedPorts = new ArrayList(); + + @Override + public void stop(Process process, Integer port) + { + if (process != null) + { + process.destroy(); + } + if (port != null) + { + stoppedPorts.add(port); + } + } + } +}