cyrus 5 mesi fa
parent
commit
c20efd48f0
85 ha cambiato i file con 5814 aggiunte e 1117 eliminazioni
  1. 34 0
      .env.example
  2. 4 0
      .gitignore
  3. 15 0
      backend/Dockerfile
  4. 16 0
      backend/pom.xml
  5. 16 0
      backend/src/main/java/com/wenshu/platform/config/ClusterVersionProperties.java
  6. 18 0
      backend/src/main/java/com/wenshu/platform/config/K8sOrchestratorProperties.java
  7. 27 0
      backend/src/main/java/com/wenshu/platform/config/SparkSqlRunnerProperties.java
  8. 24 5
      backend/src/main/java/com/wenshu/platform/config/resource/ClusterDefaultConfigProvider.java
  9. 8 0
      backend/src/main/java/com/wenshu/platform/controller/taskexec/WorkflowController.java
  10. 7 0
      backend/src/main/java/com/wenshu/platform/dao/ClusterDAO.java
  11. 2 0
      backend/src/main/java/com/wenshu/platform/dao/ClusterNodeDAO.java
  12. 2 0
      backend/src/main/java/com/wenshu/platform/dao/ConfigVersionDAO.java
  13. 1 0
      backend/src/main/java/com/wenshu/platform/model/bo/ClusterCreateBO.java
  14. 1 0
      backend/src/main/java/com/wenshu/platform/model/converter/ClusterConverter.java
  15. 4 0
      backend/src/main/java/com/wenshu/platform/model/converter/WorkflowExecutionConverter.java
  16. 7 0
      backend/src/main/java/com/wenshu/platform/model/dataobject/TaskInstanceDO.java
  17. 1 0
      backend/src/main/java/com/wenshu/platform/model/req/ClusterCreateReq.java
  18. 4 0
      backend/src/main/java/com/wenshu/platform/model/resp/TaskInstanceResp.java
  19. 18 0
      backend/src/main/java/com/wenshu/platform/model/resp/TaskResultDetailResp.java
  20. 225 0
      backend/src/main/java/com/wenshu/platform/service/resource/ClusterCreateStatusReconciler.java
  21. 179 18
      backend/src/main/java/com/wenshu/platform/service/resource/ClusterService.java
  22. 43 5
      backend/src/main/java/com/wenshu/platform/service/resource/ConfigService.java
  23. 79 23
      backend/src/main/java/com/wenshu/platform/service/resource/ScalingService.java
  24. 17 1
      backend/src/main/java/com/wenshu/platform/service/resource/StockService.java
  25. 477 0
      backend/src/main/java/com/wenshu/platform/service/resource/k8s/HttpK8sOrchestratorClient.java
  26. 46 0
      backend/src/main/java/com/wenshu/platform/service/resource/k8s/K8sOrchestratorClient.java
  27. 28 0
      backend/src/main/java/com/wenshu/platform/service/resource/k8s/OrchestratorIdMapper.java
  28. 12 0
      backend/src/main/java/com/wenshu/platform/service/resource/k8s/SparkJobResult.java
  29. 11 0
      backend/src/main/java/com/wenshu/platform/service/resource/k8s/SparkJobStatus.java
  30. 7 1
      backend/src/main/java/com/wenshu/platform/service/taskexec/ExecutionService.java
  31. 3 0
      backend/src/main/java/com/wenshu/platform/service/taskexec/SchedulerService.java
  32. 376 24
      backend/src/main/java/com/wenshu/platform/service/taskexec/SparkAdapter.java
  33. 111 0
      backend/src/main/java/com/wenshu/platform/service/taskexec/SparkClusterConfigResolver.java
  34. 189 0
      backend/src/main/java/com/wenshu/platform/service/taskexec/TaskResultPersistenceService.java
  35. 9 0
      backend/src/main/java/com/wenshu/platform/service/taskexec/TaskResultSnapshot.java
  36. 280 0
      backend/src/main/java/com/wenshu/platform/service/taskexec/TaskResultStorageService.java
  37. 75 0
      backend/src/main/java/com/wenshu/platform/service/taskexec/WorkflowService.java
  38. 56 24
      backend/src/main/resources/application.yml
  39. 10 4
      backend/src/main/resources/cluster-default-config/spark.json
  40. 15 0
      backend/src/main/resources/db/schema.sql
  41. 25 2
      backend/src/main/resources/mapper/resource/ClusterDAO.xml
  42. 6 0
      backend/src/main/resources/mapper/resource/ClusterNodeDAO.xml
  43. 5 0
      backend/src/main/resources/mapper/resource/ConfigVersionDAO.xml
  44. 53 4
      backend/src/main/resources/mapper/taskexec/TaskInstanceDAO.xml
  45. 132 0
      backend/src/test/java/com/wenshu/platform/service/resource/ClusterCreateStatusReconcilerTest.java
  46. 133 3
      backend/src/test/java/com/wenshu/platform/service/resource/ClusterServiceTest.java
  47. 65 0
      backend/src/test/java/com/wenshu/platform/service/resource/ConfigServiceTest.java
  48. 25 1
      backend/src/test/java/com/wenshu/platform/service/resource/ScalingServiceTest.java
  49. 60 1
      backend/src/test/java/com/wenshu/platform/service/resource/StockServiceTest.java
  50. 28 0
      backend/src/test/java/com/wenshu/platform/service/taskexec/ExecutionServiceTest.java
  51. 248 0
      backend/src/test/java/com/wenshu/platform/service/taskexec/SparkAdapterTest.java
  52. 76 0
      backend/src/test/java/com/wenshu/platform/service/taskexec/SparkClusterConfigResolverTest.java
  53. 284 0
      docker-compose.yml
  54. 13 0
      frontend/Dockerfile
  55. 31 0
      frontend/nginx.conf
  56. 7 3
      frontend/src/api/taskExec.js
  57. 233 53
      frontend/src/views/resource-management/ClusterConsolePage.vue
  58. 158 32
      frontend/src/views/resource-management/ClusterPage.vue
  59. 67 1
      frontend/src/views/resource-management/StockPage.vue
  60. 52 27
      frontend/src/views/task-build/TaskBuildWorkflowListPage.vue
  61. 83 5
      frontend/src/views/task-exec/WorkflowExecutionPage.vue
  62. 12 0
      k8s-orchestrator/Dockerfile.api
  63. 12 0
      k8s-orchestrator/Dockerfile.controller
  64. 18 1
      k8s-orchestrator/Makefile
  65. 8 27
      k8s-orchestrator/README.md
  66. 20 0
      k8s-orchestrator/internal/api/server.go
  67. 81 4
      k8s-orchestrator/internal/controller/clusteroperation_controller.go
  68. 199 0
      k8s-orchestrator/internal/controller/clusteroperation_controller_test.go
  69. 9 4
      k8s-orchestrator/internal/controller/sparkvirtualcluster_controller.go
  70. 12 7
      k8s-orchestrator/internal/controller/starrocksvirtualcluster_controller.go
  71. 77 0
      k8s-orchestrator/internal/k8s/node.go
  72. 6 0
      k8s-orchestrator/internal/k8s/spark.go
  73. 181 0
      k8s-orchestrator/internal/k8s/spark_sql_runner.go
  74. 46 0
      k8s-orchestrator/internal/k8s/spark_sql_runner.py
  75. 130 0
      k8s-orchestrator/internal/k8s/spark_sql_runner_test.go
  76. 63 3
      k8s-orchestrator/internal/onboarding/onboarder.go
  77. 91 2
      k8s-orchestrator/internal/service/orchestrator.go
  78. 42 0
      k8s-orchestrator/internal/service/orchestrator_version_test.go
  79. 210 0
      k8s-orchestrator/internal/service/spark_result.go
  80. 59 0
      k8s-orchestrator/internal/service/spark_result_test.go
  81. 3 0
      k8s-orchestrator/internal/util/labels.go
  82. 8 0
      python-iceberg/Dockerfile
  83. 87 0
      scripts/install-k8s-control-plane.sh
  84. 37 0
      scripts/install-k8s-operators.sh
  85. 162 832
      部署文档-从零开始.md

+ 34 - 0
.env.example

@@ -0,0 +1,34 @@
+# =========================================
+# Wenshu Platform 手动配置项(仅保留人工填写)
+# =========================================
+# 说明:
+# - 代码参数(超时、轮询、RAG、服务地址等)统一走 application.yml 默认值
+# - .env 只放账号/密码/地址等人工配置
+
+# ---------- MySQL(必须修改) ----------
+MYSQL_ROOT_PASSWORD=root_pass_change_me
+MYSQL_DATABASE=wenshu_platform
+# Docker Compose 本地库:mysql:3306
+# 远程数据库:<host>:<port>
+MYSQL_URL=mysql:3306
+MYSQL_USER=wenshu
+MYSQL_PASSWORD=wenshu_db_pass
+
+# ---------- Polaris(必须修改) ----------
+POLARIS_POSTGRES_PASSWORD=polaris_pg_pass
+POLARIS_CLIENT_SECRET=s3cr3t
+
+# ---------- OSS(必须修改) ----------
+OSS_ENDPOINT=https://oss-cn-hangzhou.aliyuncs.com
+OSS_AK=
+OSS_SK=
+OSS_REGION=cn-hangzhou
+OSS_BUCKET=
+
+# ---------- 后端安全(必须修改) ----------
+STOCK_SSH_CRYPTO_KEY=change-me-stock-ssh-key-32chars
+
+# ---------- k8s-orchestrator(纳管必填) ----------
+K8S_KUBECONFIG_PATH=/root/.kube/config
+# 示例:kubeadm token create --ttl 0 --print-join-command
+KUBEADM_JOIN_COMMAND=

+ 4 - 0
.gitignore

@@ -72,3 +72,7 @@ tmp/
 .tmp/
 .deps/
 backend/tmp/
+data/
+
+# Local scratch docs
+polaris-doc.txt

+ 15 - 0
backend/Dockerfile

@@ -0,0 +1,15 @@
+# ---- 构建阶段 ----
+FROM maven:3.9-eclipse-temurin-17 AS build
+WORKDIR /app
+COPY pom.xml .
+# 先下载依赖,利用 Docker 缓存层
+RUN mvn dependency:go-offline -q
+COPY src ./src
+RUN mvn package -DskipTests -q
+
+# ---- 运行阶段 ----
+FROM eclipse-temurin:17-jre-jammy
+WORKDIR /app
+COPY --from=build /app/target/*.jar app.jar
+EXPOSE 8080
+ENTRYPOINT ["java", "-jar", "app.jar"]

+ 16 - 0
backend/pom.xml

@@ -70,6 +70,22 @@
             <artifactId>caffeine</artifactId>
         </dependency>
 
+        <dependency>
+            <groupId>software.amazon.awssdk</groupId>
+            <artifactId>s3</artifactId>
+            <version>2.25.55</version>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.parquet</groupId>
+            <artifactId>parquet-avro</artifactId>
+            <version>1.14.1</version>
+        </dependency>
+        <dependency>
+            <groupId>com.google.protobuf</groupId>
+            <artifactId>protobuf-java</artifactId>
+            <version>3.24.0</version>
+        </dependency>
+
         <!-- Milvus Java SDK: 向量存储与RAG语义检索 -->
         <dependency>
             <groupId>io.milvus</groupId>

+ 16 - 0
backend/src/main/java/com/wenshu/platform/config/ClusterVersionProperties.java

@@ -0,0 +1,16 @@
+package com.wenshu.platform.config;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+@Data
+@Component
+@ConfigurationProperties(prefix = "cluster")
+public class ClusterVersionProperties {
+
+    private Map<String, List<String>> supportedVersions = new LinkedHashMap<>();
+}

+ 18 - 0
backend/src/main/java/com/wenshu/platform/config/K8sOrchestratorProperties.java

@@ -0,0 +1,18 @@
+package com.wenshu.platform.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+@Data
+@Component
+@ConfigurationProperties(prefix = "k8s.orchestrator")
+public class K8sOrchestratorProperties {
+
+    private String baseUrl = "http://127.0.0.1:18080";
+    private int timeoutMs = 5000;
+    private int waitTimeoutMs = 300000;
+    private int pollIntervalMs = 2000;
+    private int reconcileCreateDelayMs = 5000;
+    private int reconcileBatchSize = 100;
+}

+ 27 - 0
backend/src/main/java/com/wenshu/platform/config/SparkSqlRunnerProperties.java

@@ -0,0 +1,27 @@
+package com.wenshu.platform.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+@Data
+@Component
+@ConfigurationProperties(prefix = "spark.sql-runner")
+public class SparkSqlRunnerProperties {
+
+    private String type = "Java";
+    private String mode = "cluster";
+    private String sparkVersion = "3.5.1";
+    private String pythonVersion = "3";
+    private String mainClass = "org.apache.spark.sql.hive.thriftserver.SparkSQLCLIDriver";
+    private String mainApplicationFile = "local:///opt/spark/jars/spark-sql_2.12-3.5.1.jar";
+    private String polarisCatalogAlias = "polaris";
+    private String icebergRuntimePackage = "org.apache.iceberg:iceberg-spark-runtime-3.5_2.12:1.10.0";
+    private String icebergAwsBundlePackage = "org.apache.iceberg:iceberg-aws-bundle:1.10.0";
+    private String polarisScope = "PRINCIPAL_ROLE:ALL";
+    private int defaultDriverCores = 1;
+    private String defaultDriverMemory = "2g";
+    private int defaultExecutorInstances = 2;
+    private int defaultExecutorCores = 2;
+    private String defaultExecutorMemory = "4g";
+}

+ 24 - 5
backend/src/main/java/com/wenshu/platform/config/resource/ClusterDefaultConfigProvider.java

@@ -2,9 +2,11 @@ package com.wenshu.platform.config.resource;
 
 import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.ObjectMapper;
+import com.wenshu.platform.config.ClusterVersionProperties;
 import java.io.IOException;
 import java.io.InputStream;
 import java.util.ArrayList;
+import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.LinkedHashSet;
@@ -31,6 +33,7 @@ public class ClusterDefaultConfigProvider implements InitializingBean {
     private final Map<String, List<String>> supportedVersionsByComponentType = new HashMap<>();
     private final ResourceLoader resourceLoader;
     private final ObjectMapper objectMapper;
+    private final ClusterVersionProperties clusterVersionProperties;
 
     @Override
     public void afterPropertiesSet() {
@@ -39,7 +42,13 @@ public class ClusterDefaultConfigProvider implements InitializingBean {
             String filePath = entry.getValue();
             TemplateData templateData = loadTemplate(componentType, filePath);
             defaultConfigContentByComponentType.put(componentType, templateData.defaultConfigContent());
-            supportedVersionsByComponentType.put(componentType, templateData.versions());
+
+            List<String> configuredVersions = normalizeVersions(
+                    clusterVersionProperties.getSupportedVersions().get(componentType));
+            if (configuredVersions.isEmpty()) {
+                configuredVersions = templateData.versions();
+            }
+            supportedVersionsByComponentType.put(componentType, configuredVersions);
         }
     }
 
@@ -95,12 +104,22 @@ public class ClusterDefaultConfigProvider implements InitializingBean {
         if (!versionsNode.isArray()) {
             throw new IllegalStateException("versions must be an array in cluster default config template");
         }
-        LinkedHashSet<String> uniqueVersions = new LinkedHashSet<>();
+        List<String> versions = new ArrayList<>();
         for (JsonNode versionNode : versionsNode) {
             if (!versionNode.isTextual()) {
                 continue;
             }
-            String version = versionNode.asText();
+            versions.add(versionNode.asText());
+        }
+        return normalizeVersions(versions);
+    }
+
+    private List<String> normalizeVersions(Collection<String> versions) {
+        if (versions == null || versions.isEmpty()) {
+            return List.of();
+        }
+        LinkedHashSet<String> uniqueVersions = new LinkedHashSet<>();
+        for (String version : versions) {
             if (!StringUtils.hasText(version)) {
                 continue;
             }
@@ -109,8 +128,8 @@ public class ClusterDefaultConfigProvider implements InitializingBean {
         if (uniqueVersions.isEmpty()) {
             return List.of();
         }
-        List<String> versions = new ArrayList<>(uniqueVersions);
-        return Collections.unmodifiableList(versions);
+        List<String> normalized = new ArrayList<>(uniqueVersions);
+        return Collections.unmodifiableList(normalized);
     }
 
     private record TemplateData(String defaultConfigContent, List<String> versions) {

+ 8 - 0
backend/src/main/java/com/wenshu/platform/controller/taskexec/WorkflowController.java

@@ -5,6 +5,7 @@ import com.wenshu.platform.model.req.WorkflowInstanceQueryReq;
 import com.wenshu.platform.model.req.WorkflowSubmitReq;
 import com.wenshu.platform.model.resp.PageResp;
 import com.wenshu.platform.model.resp.WorkflowInstanceListResp;
+import com.wenshu.platform.model.resp.TaskResultDetailResp;
 import com.wenshu.platform.model.resp.WorkflowStatusResp;
 import com.wenshu.platform.model.resp.WorkflowSubmitResp;
 import com.wenshu.platform.service.taskexec.WorkflowService;
@@ -45,6 +46,13 @@ public class WorkflowController {
         return workflowService.getWorkflowStatus(workflowInstanceId);
     }
 
+    @GetMapping("/instances/{workflowInstanceId}/tasks/{taskInstanceId}/result")
+    public TaskResultDetailResp getTaskResult(
+            @PathVariable Long workflowInstanceId,
+            @PathVariable Long taskInstanceId) {
+        return workflowService.getTaskResult(workflowInstanceId, taskInstanceId);
+    }
+
     @PostMapping("/instances/{workflowInstanceId}/terminate")
     @ResponseStatus(HttpStatus.NO_CONTENT)
     public void terminateWorkflow(@PathVariable Long workflowInstanceId) {

+ 7 - 0
backend/src/main/java/com/wenshu/platform/dao/ClusterDAO.java

@@ -29,4 +29,11 @@ public interface ClusterDAO {
     List<ClusterDO> find(@Param("query") ClusterQuery query);
 
     long count(@Param("query") ClusterQuery query);
+
+    List<ClusterDO> findByStatusAndComponentTypes(
+            @Param("status") String status,
+            @Param("componentTypes") List<String> componentTypes,
+            @Param("limit") Integer limit);
+
+    int deleteById(@Param("clusterId") Long clusterId);
 }

+ 2 - 0
backend/src/main/java/com/wenshu/platform/dao/ClusterNodeDAO.java

@@ -32,4 +32,6 @@ public interface ClusterNodeDAO {
     int deleteByClusterId(@Param("clusterId") Long clusterId);
 
     long countByMachineId(@Param("machineId") Long machineId);
+
+    int updateStatusByClusterId(@Param("clusterId") Long clusterId, @Param("status") String status);
 }

+ 2 - 0
backend/src/main/java/com/wenshu/platform/dao/ConfigVersionDAO.java

@@ -20,4 +20,6 @@ public interface ConfigVersionDAO {
     ConfigVersionDO findActiveByClusterId(@Param("clusterId") Long clusterId);
 
     List<ConfigVersionDO> findByClusterId(@Param("clusterId") Long clusterId);
+
+    int deleteByClusterId(@Param("clusterId") Long clusterId);
 }

+ 1 - 0
backend/src/main/java/com/wenshu/platform/model/bo/ClusterCreateBO.java

@@ -9,5 +9,6 @@ import lombok.Data;
 public class ClusterCreateBO {
 
     private ClusterDO cluster;
+    private Integer frontendNodeCount;
     private List<Long> nodeMachineIds;
 }

+ 1 - 0
backend/src/main/java/com/wenshu/platform/model/converter/ClusterConverter.java

@@ -47,6 +47,7 @@ public final class ClusterConverter {
         cluster.setVersion(normalizeText(req.getVersion()));
         ClusterCreateBO createBO = new ClusterCreateBO();
         createBO.setCluster(cluster);
+        createBO.setFrontendNodeCount(req.getFrontendNodeCount());
         createBO.setNodeMachineIds(req.getNodeMachineIds());
         return createBO;
     }

+ 4 - 0
backend/src/main/java/com/wenshu/platform/model/converter/WorkflowExecutionConverter.java

@@ -125,6 +125,10 @@ public final class WorkflowExecutionConverter {
         resp.setEndTime(taskInstance.getEndTime());
         resp.setRetryCount(taskInstance.getRetryCount());
         resp.setErrorMessage(taskInstance.getErrorMessage());
+        resp.setResultPreview(taskInstance.getResultPreview());
+        resp.setResultFormat(taskInstance.getResultFormat());
+        resp.setResultTruncated(taskInstance.getResultTruncated());
+        resp.setHasFullResult(StringUtils.hasText(taskInstance.getResultRef()));
         return resp;
     }
 

+ 7 - 0
backend/src/main/java/com/wenshu/platform/model/dataobject/TaskInstanceDO.java

@@ -16,4 +16,11 @@ public class TaskInstanceDO {
     private Long executorId;
     private Integer retryCount;
     private String errorMessage;
+    private String engineTaskId;
+    private String resultPreview;
+    private String resultFormat;
+    private String resultRef;
+    private Integer resultTruncated;
+    private Long resultSizeBytes;
+    private LocalDateTime resultUpdatedAt;
 }

+ 1 - 0
backend/src/main/java/com/wenshu/platform/model/req/ClusterCreateReq.java

@@ -11,5 +11,6 @@ public class ClusterCreateReq {
     private String description;
     private String componentType;
     private String version;
+    private Integer frontendNodeCount;
     private List<Long> nodeMachineIds;
 }

+ 4 - 0
backend/src/main/java/com/wenshu/platform/model/resp/TaskInstanceResp.java

@@ -18,4 +18,8 @@ public class TaskInstanceResp {
     private LocalDateTime endTime;
     private Integer retryCount;
     private String errorMessage;
+    private String resultPreview;
+    private String resultFormat;
+    private Integer resultTruncated;
+    private Boolean hasFullResult;
 }

+ 18 - 0
backend/src/main/java/com/wenshu/platform/model/resp/TaskResultDetailResp.java

@@ -0,0 +1,18 @@
+package com.wenshu.platform.model.resp;
+
+import java.time.LocalDateTime;
+
+import lombok.Data;
+
+@Data
+public class TaskResultDetailResp {
+
+    private Long taskInstanceId;
+    private String resultFormat;
+    private String resultPreview;
+    private String resultContent;
+    private String resultRef;
+    private Integer resultTruncated;
+    private Long resultSizeBytes;
+    private LocalDateTime resultUpdatedAt;
+}

+ 225 - 0
backend/src/main/java/com/wenshu/platform/service/resource/ClusterCreateStatusReconciler.java

@@ -0,0 +1,225 @@
+package com.wenshu.platform.service.resource;
+
+import java.util.List;
+import java.util.Locale;
+import java.util.LinkedHashSet;
+import java.util.Set;
+
+import com.wenshu.platform.config.K8sOrchestratorProperties;
+import com.wenshu.platform.dao.ClusterDAO;
+import com.wenshu.platform.dao.ClusterNodeDAO;
+import com.wenshu.platform.dao.ConfigVersionDAO;
+import com.wenshu.platform.dao.StockDAO;
+import com.wenshu.platform.model.dataobject.ClusterDO;
+import com.wenshu.platform.model.enums.ClusterStatus;
+import com.wenshu.platform.model.enums.StockMachineStatus;
+import com.wenshu.platform.service.resource.k8s.K8sOrchestratorClient;
+import com.wenshu.platform.service.resource.k8s.OrchestratorIdMapper;
+import lombok.RequiredArgsConstructor;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.StringUtils;
+
+@Component
+@RequiredArgsConstructor
+public class ClusterCreateStatusReconciler {
+
+    private static final Logger LOGGER = LoggerFactory.getLogger(ClusterCreateStatusReconciler.class);
+    private static final String COMPONENT_SPARK = "SPARK";
+    private static final String COMPONENT_STARROCKS = "STARROCKS";
+    private static final List<String> ASYNC_COMPONENT_TYPES = List.of(COMPONENT_SPARK, COMPONENT_STARROCKS);
+
+    private final ClusterDAO clusterDAO;
+    private final ClusterNodeDAO clusterNodeDAO;
+    private final ConfigVersionDAO configVersionDAO;
+    private final StockDAO stockDAO;
+    private final K8sOrchestratorClient k8sOrchestratorClient;
+    private final K8sOrchestratorProperties properties;
+
+    @Scheduled(fixedDelayString = "${k8s.orchestrator.reconcile-create-delay-ms:5000}")
+    public void reconcileCreatingClusters() {
+        int limit = Math.max(properties.getReconcileBatchSize(), 1);
+        List<ClusterDO> creatingClusters = clusterDAO.findByStatusAndComponentTypes(
+                ClusterStatus.CREATING.getCode(),
+                ASYNC_COMPONENT_TYPES,
+                limit);
+        for (ClusterDO cluster : safeList(creatingClusters)) {
+            try {
+                reconcileCluster(cluster);
+            } catch (Exception ex) {
+                LOGGER.warn("Reconcile creating cluster failed. clusterId={}, error={}",
+                        cluster.getClusterId(), ex.getMessage());
+            }
+        }
+        List<ClusterDO> stoppingClusters = clusterDAO.findByStatusAndComponentTypes(
+                ClusterStatus.STOPPING.getCode(),
+                ASYNC_COMPONENT_TYPES,
+                limit);
+        for (ClusterDO cluster : safeList(stoppingClusters)) {
+            try {
+                reconcileStoppingCluster(cluster);
+            } catch (Exception ex) {
+                LOGGER.warn("Reconcile stopping cluster failed. clusterId={}, error={}",
+                        cluster.getClusterId(), ex.getMessage());
+            }
+        }
+        List<ClusterDO> scalingClusters = clusterDAO.findByStatusAndComponentTypes(
+                ClusterStatus.SCALING.getCode(),
+                ASYNC_COMPONENT_TYPES,
+                limit);
+        for (ClusterDO cluster : safeList(scalingClusters)) {
+            try {
+                reconcileScalingCluster(cluster);
+            } catch (Exception ex) {
+                LOGGER.warn("Reconcile scaling cluster failed. clusterId={}, error={}",
+                        cluster.getClusterId(), ex.getMessage());
+            }
+        }
+    }
+
+    private void reconcileCluster(ClusterDO cluster) {
+        if (cluster == null || cluster.getClusterId() == null) {
+            return;
+        }
+        String componentType = normalizeUpperText(cluster.getComponentType());
+        String phase;
+        if (COMPONENT_SPARK.equals(componentType)) {
+            phase = k8sOrchestratorClient.getSparkClusterPhase(
+                    OrchestratorIdMapper.toSparkClusterName(cluster.getClusterId()));
+        } else if (COMPONENT_STARROCKS.equals(componentType)) {
+            phase = k8sOrchestratorClient.getStarRocksClusterPhase(
+                    OrchestratorIdMapper.toStarRocksClusterName(cluster.getClusterId()));
+        } else {
+            return;
+        }
+        if ("READY".equals(phase)) {
+            markClusterRunning(cluster.getClusterId());
+            return;
+        }
+        if ("FAILED".equals(phase)) {
+            LOGGER.warn("Cluster creation failed in k8s orchestrator, keep local status CREATING. clusterId={}",
+                    cluster.getClusterId());
+        }
+    }
+
+    @Transactional(rollbackFor = Exception.class)
+    public void markClusterRunning(Long clusterId) {
+        int updated = clusterDAO.updateStatusByExpected(
+                clusterId,
+                ClusterStatus.CREATING.getCode(),
+                ClusterStatus.RUNNING.getCode());
+        if (updated <= 0) {
+            return;
+        }
+        clusterNodeDAO.updateStatusByClusterId(clusterId, ClusterStatus.RUNNING.getCode());
+        LOGGER.info("Cluster moved from CREATING to RUNNING. clusterId={}", clusterId);
+    }
+
+    @Transactional(rollbackFor = Exception.class)
+    public void markClusterDeleted(Long clusterId) {
+        int updated = clusterDAO.updateStatusByExpected(
+                clusterId,
+                ClusterStatus.STOPPING.getCode(),
+                ClusterStatus.DELETED.getCode());
+        if (updated <= 0) {
+            return;
+        }
+        List<Long> machineIds = clusterNodeDAO.findMachineIdsByClusterId(clusterId);
+        if (machineIds != null) {
+            Set<Long> uniqueMachineIds = new LinkedHashSet<>(machineIds);
+            for (Long machineId : uniqueMachineIds) {
+                if (machineId == null) {
+                    continue;
+                }
+                stockDAO.updateStatusByIdAndStatus(
+                        machineId,
+                        StockMachineStatus.WORKING.getCode(),
+                        StockMachineStatus.IDLE.getCode());
+            }
+        }
+        configVersionDAO.deleteByClusterId(clusterId);
+        clusterNodeDAO.deleteByClusterId(clusterId);
+        int deleted = clusterDAO.deleteById(clusterId);
+        if (deleted <= 0) {
+            throw new IllegalStateException("Cluster changed, please retry");
+        }
+        LOGGER.info("Cluster released and removed from database. clusterId={}", clusterId);
+    }
+
+    private void reconcileStoppingCluster(ClusterDO cluster) {
+        if (cluster == null || cluster.getClusterId() == null) {
+            return;
+        }
+        String componentType = normalizeUpperText(cluster.getComponentType());
+        String phase;
+        if (COMPONENT_SPARK.equals(componentType)) {
+            phase = k8sOrchestratorClient.getSparkClusterPhase(
+                    OrchestratorIdMapper.toSparkClusterName(cluster.getClusterId()));
+        } else if (COMPONENT_STARROCKS.equals(componentType)) {
+            phase = k8sOrchestratorClient.getStarRocksClusterPhase(
+                    OrchestratorIdMapper.toStarRocksClusterName(cluster.getClusterId()));
+        } else {
+            return;
+        }
+        if (phase == null || "RELEASED".equals(phase)) {
+            markClusterDeleted(cluster.getClusterId());
+            return;
+        }
+        if ("FAILED".equals(phase)) {
+            LOGGER.warn("Cluster release failed in k8s orchestrator, keep local status STOPPING. clusterId={}",
+                    cluster.getClusterId());
+        }
+    }
+
+    private void reconcileScalingCluster(ClusterDO cluster) {
+        if (cluster == null || cluster.getClusterId() == null) {
+            return;
+        }
+        String componentType = normalizeUpperText(cluster.getComponentType());
+        String phase;
+        if (COMPONENT_SPARK.equals(componentType)) {
+            phase = k8sOrchestratorClient.getSparkClusterPhase(
+                    OrchestratorIdMapper.toSparkClusterName(cluster.getClusterId()));
+        } else if (COMPONENT_STARROCKS.equals(componentType)) {
+            phase = k8sOrchestratorClient.getStarRocksClusterPhase(
+                    OrchestratorIdMapper.toStarRocksClusterName(cluster.getClusterId()));
+        } else {
+            return;
+        }
+        if ("READY".equals(phase)) {
+            markScaledClusterRunning(cluster.getClusterId());
+            return;
+        }
+        if ("FAILED".equals(phase)) {
+            LOGGER.warn("Cluster scaling failed in k8s orchestrator, keep local status SCALING. clusterId={}",
+                    cluster.getClusterId());
+        }
+    }
+
+    @Transactional(rollbackFor = Exception.class)
+    public void markScaledClusterRunning(Long clusterId) {
+        int updated = clusterDAO.updateStatusByExpected(
+                clusterId,
+                ClusterStatus.SCALING.getCode(),
+                ClusterStatus.RUNNING.getCode());
+        if (updated <= 0) {
+            return;
+        }
+        clusterNodeDAO.updateStatusByClusterId(clusterId, ClusterStatus.RUNNING.getCode());
+        LOGGER.info("Cluster moved from SCALING to RUNNING. clusterId={}", clusterId);
+    }
+
+    private List<ClusterDO> safeList(List<ClusterDO> clusters) {
+        return clusters == null ? List.of() : clusters;
+    }
+
+    private String normalizeUpperText(String value) {
+        if (!StringUtils.hasText(value)) {
+            return null;
+        }
+        return value.trim().toUpperCase(Locale.ROOT);
+    }
+}

+ 179 - 18
backend/src/main/java/com/wenshu/platform/service/resource/ClusterService.java

@@ -29,31 +29,45 @@ import com.wenshu.platform.model.resp.ClusterComponentVersionsResp;
 import com.wenshu.platform.model.resp.ClusterNodeResp;
 import com.wenshu.platform.model.resp.ClusterResp;
 import com.wenshu.platform.model.resp.PageResp;
+import com.wenshu.platform.service.resource.k8s.K8sOrchestratorClient;
+import com.wenshu.platform.service.resource.k8s.OrchestratorIdMapper;
 import lombok.RequiredArgsConstructor;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
+import org.springframework.transaction.support.TransactionTemplate;
 import org.springframework.util.StringUtils;
 
 @Service
 @RequiredArgsConstructor
 public class ClusterService {
 
+    private static final Logger LOGGER = LoggerFactory.getLogger(ClusterService.class);
+
     private static final int QUERY_MAX_PAGE_SIZE = 200;
     private static final int CLUSTER_NAME_MAX_LENGTH = 64;
     private static final int NODE_NAME_MAX_LENGTH = 128;
     private static final int DESCRIPTION_MAX_LENGTH = 256;
     private static final int COMPONENT_TYPE_MAX_LENGTH = 32;
     private static final int VERSION_MAX_LENGTH = 16;
-    private static final List<String> SUPPORTED_COMPONENT_TYPE_LIST = List.of("SPARK", "ICEBERG", "STARROCKS");
+    private static final List<String> SUPPORTED_COMPONENT_TYPE_LIST = List.of("SPARK", "STARROCKS");
     private static final Set<String> SUPPORTED_COMPONENT_TYPES = Set.copyOf(SUPPORTED_COMPONENT_TYPE_LIST);
+    private static final String COMPONENT_SPARK = "SPARK";
+    private static final String COMPONENT_STARROCKS = "STARROCKS";
+    private static final int DEFAULT_STARROCKS_FRONTEND_NODE_COUNT = 1;
     private static final Map<String, Set<String>> STATE_TRANSITIONS = Map.of(
             ClusterStatus.CREATING.getCode(), Set.of(ClusterStatus.RUNNING.getCode()),
             ClusterStatus.RUNNING.getCode(), Set.of(
                     ClusterStatus.STOPPING.getCode(),
                     ClusterStatus.SCALING.getCode(),
                     ClusterStatus.UPDATING.getCode()),
-            ClusterStatus.STOPPING.getCode(), Set.of(ClusterStatus.STOPPED.getCode()),
+            ClusterStatus.STOPPING.getCode(), Set.of(
+                    ClusterStatus.STOPPED.getCode(),
+                    ClusterStatus.DELETED.getCode()),
             ClusterStatus.STOPPED.getCode(), Set.of(
+                    ClusterStatus.CREATING.getCode(),
+                    ClusterStatus.STOPPING.getCode(),
                     ClusterStatus.STARTING.getCode(),
                     ClusterStatus.DELETED.getCode()),
             ClusterStatus.STARTING.getCode(), Set.of(ClusterStatus.RUNNING.getCode()),
@@ -66,6 +80,8 @@ public class ClusterService {
     private final ConfigVersionDAO configVersionDAO;
     private final StockDAO stockDAO;
     private final ClusterDefaultConfigProvider clusterDefaultConfigProvider;
+    private final K8sOrchestratorClient k8sOrchestratorClient;
+    private final TransactionTemplate transactionTemplate;
 
     public PageResp<ClusterResp> listClusters(ClusterQuery query) {
         ResourceAuthSupport.requireOpsRole();
@@ -126,7 +142,6 @@ public class ClusterService {
                 .toList();
     }
 
-    @Transactional(rollbackFor = Exception.class)
     public ClusterResp createCluster(ClusterCreateBO createBO) {
         ResourceAuthSupport.requireOpsRole();
         if (createBO == null) {
@@ -137,13 +152,35 @@ public class ClusterService {
         List<Long> nodeMachineIds = normalizeNodeMachineIds(createBO.getNodeMachineIds());
         validateCreateCluster(cluster);
         validateNodeMachineIds(nodeMachineIds);
-        cluster.setCreatedTime(LocalDateTime.now());
-        cluster.setStatus(ClusterStatus.CREATING.getCode());
-        clusterDAO.save(cluster);
-        bindClusterNodes(cluster, nodeMachineIds);
-        updateStatus(cluster.getClusterId(), ClusterStatus.RUNNING.getCode());
-        initDefaultConfig(cluster);
-        return ClusterConverter.toResp(requireCluster(cluster.getClusterId()));
+        int frontendNodeCount = resolveStarRocksFrontendNodeCount(
+                cluster.getComponentType(),
+                createBO.getFrontendNodeCount(),
+                nodeMachineIds.size());
+        boolean asyncCreate = requiresAsyncCreate(cluster.getComponentType());
+
+        ClusterDO persistedCluster = transactionTemplate.execute(status -> {
+            cluster.setCreatedTime(LocalDateTime.now());
+            cluster.setStatus(ClusterStatus.CREATING.getCode());
+            clusterDAO.save(cluster);
+            bindClusterNodes(
+                    cluster,
+                    nodeMachineIds,
+                    frontendNodeCount,
+                    asyncCreate ? ClusterStatus.CREATING.getCode() : ClusterStatus.RUNNING.getCode());
+            initDefaultConfig(cluster);
+            return requireCluster(cluster.getClusterId());
+        });
+        if (persistedCluster == null) {
+            throw new IllegalStateException("Failed to persist cluster");
+        }
+
+        if (!asyncCreate) {
+            updateStatus(persistedCluster.getClusterId(), ClusterStatus.RUNNING.getCode());
+            return ClusterConverter.toResp(requireCluster(persistedCluster.getClusterId()));
+        }
+
+        dispatchCreateToK8s(persistedCluster, nodeMachineIds, frontendNodeCount);
+        return ClusterConverter.toResp(requireCluster(persistedCluster.getClusterId()));
     }
 
     @Transactional(rollbackFor = Exception.class)
@@ -175,9 +212,17 @@ public class ClusterService {
     @Transactional(rollbackFor = Exception.class)
     public void deleteCluster(Long clusterId) {
         ResourceAuthSupport.requireOpsRole();
+        ClusterDO cluster = requireCluster(clusterId);
+        if (requiresAsyncCreate(cluster.getComponentType())) {
+            if (!ClusterStatus.STOPPING.getCode().equals(cluster.getStatus())) {
+                updateStatus(clusterId, ClusterStatus.STOPPING.getCode());
+            }
+            dispatchReleaseToK8s(requireCluster(clusterId));
+            return;
+        }
         releaseClusterMachines(clusterId);
         updateStatus(clusterId, ClusterStatus.DELETED.getCode());
-        clusterNodeDAO.deleteByClusterId(clusterId);
+        deleteClusterPersistedData(clusterId);
     }
 
     private void releaseClusterMachines(Long clusterId) {
@@ -196,6 +241,15 @@ public class ClusterService {
         }
     }
 
+    private void deleteClusterPersistedData(Long clusterId) {
+        configVersionDAO.deleteByClusterId(clusterId);
+        clusterNodeDAO.deleteByClusterId(clusterId);
+        int deleted = clusterDAO.deleteById(clusterId);
+        if (deleted <= 0) {
+            throw new IllegalStateException("Cluster changed, please retry");
+        }
+    }
+
     private ClusterDO requireCluster(Long clusterId) {
         if (clusterId == null) {
             throw new IllegalArgumentException("clusterId cannot be null");
@@ -259,19 +313,29 @@ public class ClusterService {
         }
         for (int index = 0; index < nodes.size(); index++) {
             ClusterNodeBO node = nodes.get(index);
-            node.setNodeRole(resolveNodeRole(node.getComponentType(), index));
+            node.setNodeRole(resolveNodeRole(node.getComponentType(), node.getNodeName(), index));
         }
     }
 
-    private String resolveNodeRole(String componentType, int nodeIndex) {
+    private String resolveNodeRole(String componentType, String nodeName, int nodeIndex) {
         if (!StringUtils.hasText(componentType)) {
             return null;
         }
         String normalizedComponentType = componentType.trim().toUpperCase(Locale.ROOT);
         if ("SPARK".equals(normalizedComponentType)) {
-            return nodeIndex == 0 ? "spark_master" : "spark_worker";
+            return "spark_node";
         }
         if ("STARROCKS".equals(normalizedComponentType)) {
+            String normalizedNodeName = normalizeText(nodeName);
+            if (normalizedNodeName != null) {
+                String lowerNodeName = normalizedNodeName.toLowerCase(Locale.ROOT);
+                if (lowerNodeName.contains("-frontend-")) {
+                    return "starrocks_frontend";
+                }
+                if (lowerNodeName.contains("-backend-")) {
+                    return "starrocks_backend";
+                }
+            }
             return nodeIndex == 0 ? "starrocks_frontend" : "starrocks_backend";
         }
         if ("ICEBERG".equals(normalizedComponentType)) {
@@ -328,7 +392,7 @@ public class ClusterService {
             throw new IllegalArgumentException("componentType length exceeds " + COMPONENT_TYPE_MAX_LENGTH);
         }
         if (!SUPPORTED_COMPONENT_TYPES.contains(cluster.getComponentType())) {
-            throw new IllegalArgumentException("componentType must be SPARK, ICEBERG or STARROCKS");
+            throw new IllegalArgumentException("componentType must be SPARK or STARROCKS");
         }
         if (cluster.getDescription() != null && cluster.getDescription().length() > DESCRIPTION_MAX_LENGTH) {
             throw new IllegalArgumentException("description length exceeds " + DESCRIPTION_MAX_LENGTH);
@@ -381,7 +445,31 @@ public class ClusterService {
         }
     }
 
-    private void bindClusterNodes(ClusterDO cluster, List<Long> nodeMachineIds) {
+    private int resolveStarRocksFrontendNodeCount(String componentType, Integer frontendNodeCount, int totalNodeCount) {
+        String normalizedComponentType = normalizeUpperText(componentType);
+        if (!COMPONENT_STARROCKS.equals(normalizedComponentType)) {
+            return 0;
+        }
+        int resolvedFrontendCount = frontendNodeCount == null
+                ? DEFAULT_STARROCKS_FRONTEND_NODE_COUNT
+                : frontendNodeCount;
+        if (resolvedFrontendCount <= 0) {
+            throw new IllegalArgumentException("starrocks frontend node count must be greater than 0");
+        }
+        if (resolvedFrontendCount % 2 == 0) {
+            throw new IllegalArgumentException("starrocks frontend node count must be an odd number");
+        }
+        if (resolvedFrontendCount >= totalNodeCount) {
+            throw new IllegalArgumentException("starrocks cluster requires at least one backend node");
+        }
+        return resolvedFrontendCount;
+    }
+
+    private void bindClusterNodes(
+            ClusterDO cluster,
+            List<Long> nodeMachineIds,
+            int starRocksFrontendNodeCount,
+            String nodeStatus) {
         for (int i = 0; i < nodeMachineIds.size(); i++) {
             Long machineId = nodeMachineIds.get(i);
             StockInfoDO machine = stockDAO.findById(machineId);
@@ -401,10 +489,10 @@ public class ClusterService {
             }
 
             ClusterNodeDO node = new ClusterNodeDO();
-            node.setNodeName(buildNodeName(cluster.getClusterName(), i + 1));
+            node.setNodeName(buildClusterNodeName(cluster, i, starRocksFrontendNodeCount));
             node.setMachineId(machineId);
             node.setClusterId(cluster.getClusterId());
-            node.setStatus(ClusterStatus.RUNNING.getCode());
+            node.setStatus(nodeStatus);
             node.setComponentType(cluster.getComponentType());
             node.setCpuCores(machine.getCpuCores());
             node.setRamGb(machine.getRamGb());
@@ -426,6 +514,59 @@ public class ClusterService {
         configVersionDAO.save(initialVersion);
     }
 
+    private void dispatchCreateToK8s(ClusterDO cluster, List<Long> nodeMachineIds, int starRocksFrontendNodeCount) {
+        String componentType = normalizeUpperText(cluster.getComponentType());
+        List<String> nodeIds = nodeMachineIds.stream().map(OrchestratorIdMapper::toNodeId).toList();
+
+        if (COMPONENT_SPARK.equals(componentType)) {
+            String operationId = k8sOrchestratorClient.createSparkCluster(
+                    OrchestratorIdMapper.toSparkClusterName(cluster.getClusterId()),
+                    nodeIds);
+            LOGGER.info("Dispatched spark cluster creation. clusterId={}, operationId={}", cluster.getClusterId(), operationId);
+            return;
+        }
+        if (COMPONENT_STARROCKS.equals(componentType)) {
+            List<String> feNodeIds = nodeIds.subList(0, starRocksFrontendNodeCount);
+            List<String> beNodeIds = nodeIds.subList(starRocksFrontendNodeCount, nodeIds.size());
+            if (beNodeIds.isEmpty()) {
+                throw new IllegalArgumentException("starrocks cluster requires at least one backend node");
+            }
+            String operationId = k8sOrchestratorClient.createStarRocksCluster(
+                    OrchestratorIdMapper.toStarRocksClusterName(cluster.getClusterId()),
+                    feNodeIds,
+                    beNodeIds,
+                    cluster.getVersion());
+            LOGGER.info("Dispatched starrocks cluster creation. clusterId={}, operationId={}", cluster.getClusterId(), operationId);
+            return;
+        }
+        throw new IllegalArgumentException("Unsupported componentType for k8s dispatch: " + cluster.getComponentType());
+    }
+
+    private void dispatchReleaseToK8s(ClusterDO cluster) {
+        if (cluster == null || cluster.getClusterId() == null) {
+            throw new IllegalArgumentException("cluster does not exist");
+        }
+        String componentType = normalizeUpperText(cluster.getComponentType());
+        if (COMPONENT_SPARK.equals(componentType)) {
+            String operationId = k8sOrchestratorClient.releaseSparkCluster(
+                    OrchestratorIdMapper.toSparkClusterName(cluster.getClusterId()));
+            LOGGER.info("Dispatched spark cluster release. clusterId={}, operationId={}", cluster.getClusterId(), operationId);
+            return;
+        }
+        if (COMPONENT_STARROCKS.equals(componentType)) {
+            String operationId = k8sOrchestratorClient.releaseStarRocksCluster(
+                    OrchestratorIdMapper.toStarRocksClusterName(cluster.getClusterId()));
+            LOGGER.info("Dispatched starrocks cluster release. clusterId={}, operationId={}", cluster.getClusterId(), operationId);
+            return;
+        }
+        throw new IllegalArgumentException("Unsupported componentType for k8s release: " + cluster.getComponentType());
+    }
+
+    private boolean requiresAsyncCreate(String componentType) {
+        String normalizedType = normalizeUpperText(componentType);
+        return COMPONENT_SPARK.equals(normalizedType) || COMPONENT_STARROCKS.equals(normalizedType);
+    }
+
     private String buildDefaultConfigContent(String componentType) {
         return clusterDefaultConfigProvider.getDefaultConfigContent(componentType);
     }
@@ -460,6 +601,26 @@ public class ClusterService {
         return prefix + "-node-" + index;
     }
 
+    private String buildClusterNodeName(ClusterDO cluster, int nodeIndex, int starRocksFrontendNodeCount) {
+        String componentType = normalizeUpperText(cluster == null ? null : cluster.getComponentType());
+        String clusterName = cluster == null ? null : cluster.getClusterName();
+        if (COMPONENT_STARROCKS.equals(componentType) && starRocksFrontendNodeCount > 0) {
+            if (nodeIndex < starRocksFrontendNodeCount) {
+                return buildStarRocksNodeName(clusterName, "frontend", nodeIndex + 1);
+            }
+            return buildStarRocksNodeName(
+                    clusterName,
+                    "backend",
+                    nodeIndex - starRocksFrontendNodeCount + 1);
+        }
+        return buildNodeName(clusterName, nodeIndex + 1);
+    }
+
+    private String buildStarRocksNodeName(String clusterName, String role, int index) {
+        String prefix = StringUtils.hasText(clusterName) ? clusterName.trim() : "cluster";
+        return prefix + "-" + role + "-" + index;
+    }
+
     private String normalizeText(String value) {
         if (!StringUtils.hasText(value)) {
             return null;

+ 43 - 5
backend/src/main/java/com/wenshu/platform/service/resource/ConfigService.java

@@ -1,7 +1,9 @@
 package com.wenshu.platform.service.resource;
 
 import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
 import com.wenshu.platform.dao.ClusterDAO;
 import com.wenshu.platform.dao.ConfigVersionDAO;
 import com.wenshu.platform.model.converter.ConfigConverter;
@@ -17,12 +19,15 @@ import org.springframework.transaction.annotation.Transactional;
 import org.springframework.util.StringUtils;
 
 import java.util.List;
+import java.util.Set;
 
 @Service
 @RequiredArgsConstructor
 public class ConfigService {
 
     private static final int DESCRIPTION_MAX_LENGTH = 512;
+    private static final Set<String> BACKEND_MANAGED_SPARK_CONFIG_KEYS = Set.of(
+            "spark.sql.catalog.polaris.uri");
 
     private final ClusterDAO clusterDAO;
     private final ConfigVersionDAO configVersionDAO;
@@ -31,13 +36,15 @@ public class ConfigService {
     public ConfigVersionResp getCurrentConfig(Long clusterId) {
         ResourceAuthSupport.requireOpsRole();
         requireUpdatableCluster(clusterId);
-        return ConfigConverter.toResp(configVersionDAO.findActiveByClusterId(clusterId));
+        return sanitizeManagedKeysForResponse(ConfigConverter.toResp(configVersionDAO.findActiveByClusterId(clusterId)));
     }
 
     public List<ConfigVersionResp> listConfigHistory(Long clusterId) {
         ResourceAuthSupport.requireOpsRole();
         requireUpdatableCluster(clusterId);
-        return ConfigConverter.toRespList(configVersionDAO.findByClusterId(clusterId));
+        return ConfigConverter.toRespList(configVersionDAO.findByClusterId(clusterId)).stream()
+                .map(this::sanitizeManagedKeysForResponse)
+                .toList();
     }
 
     @Transactional(rollbackFor = Exception.class)
@@ -45,13 +52,18 @@ public class ConfigService {
         ResourceAuthSupport.requireOpsRole();
         ClusterDO cluster = requireUpdatableCluster(clusterId);
         enterUpdating(cluster);
-        ConfigVersionDO newVersion = ConfigConverter.toCommitVersion(clusterId, nextVersionNo(clusterId), req);
+        ClusterConfigCommitReq normalizedReq = new ClusterConfigCommitReq();
+        if (req != null) {
+            normalizedReq.setDescription(req.getDescription());
+            normalizedReq.setConfigContent(sanitizeManagedKeys(req.getConfigContent()));
+        }
+        ConfigVersionDO newVersion = ConfigConverter.toCommitVersion(clusterId, nextVersionNo(clusterId), normalizedReq);
         validateConfigVersion(newVersion);
 
         configVersionDAO.deactivateAll(clusterId);
         configVersionDAO.save(newVersion);
         backToRunning(clusterId);
-        return ConfigConverter.toResp(newVersion);
+        return sanitizeManagedKeysForResponse(ConfigConverter.toResp(newVersion));
     }
 
     @Transactional(rollbackFor = Exception.class)
@@ -74,12 +86,13 @@ public class ConfigService {
         }
         ConfigVersionDO rollbackVersion =
                 ConfigConverter.toRollbackVersion(clusterId, nextVersionNo(clusterId), description, targetVersion);
+        rollbackVersion.setConfigContent(sanitizeManagedKeys(rollbackVersion.getConfigContent()));
         validateConfigVersion(rollbackVersion);
 
         configVersionDAO.deactivateAll(clusterId);
         configVersionDAO.save(rollbackVersion);
         backToRunning(clusterId);
-        return ConfigConverter.toResp(rollbackVersion);
+        return sanitizeManagedKeysForResponse(ConfigConverter.toResp(rollbackVersion));
     }
 
     private ClusterDO requireUpdatableCluster(Long clusterId) {
@@ -144,4 +157,29 @@ public class ConfigService {
             throw new IllegalArgumentException("Cluster status changed, please retry");
         }
     }
+
+    private ConfigVersionResp sanitizeManagedKeysForResponse(ConfigVersionResp resp) {
+        if (resp == null) {
+            return null;
+        }
+        resp.setConfigContent(sanitizeManagedKeys(resp.getConfigContent()));
+        return resp;
+    }
+
+    private String sanitizeManagedKeys(String configContent) {
+        if (!StringUtils.hasText(configContent)) {
+            return configContent;
+        }
+        try {
+            JsonNode rootNode = objectMapper.readTree(configContent);
+            if (!rootNode.isObject()) {
+                return configContent;
+            }
+            ObjectNode objectNode = (ObjectNode) rootNode.deepCopy();
+            BACKEND_MANAGED_SPARK_CONFIG_KEYS.forEach(objectNode::remove);
+            return objectMapper.writeValueAsString(objectNode);
+        } catch (JsonProcessingException ex) {
+            return configContent;
+        }
+    }
 }

+ 79 - 23
backend/src/main/java/com/wenshu/platform/service/resource/ScalingService.java

@@ -27,6 +27,8 @@ import com.wenshu.platform.model.enums.StockMachineStatus;
 import com.wenshu.platform.model.query.ScalingEventQuery;
 import com.wenshu.platform.model.resp.PageResp;
 import com.wenshu.platform.model.resp.ScalingEventResp;
+import com.wenshu.platform.service.resource.k8s.K8sOrchestratorClient;
+import com.wenshu.platform.service.resource.k8s.OrchestratorIdMapper;
 import lombok.RequiredArgsConstructor;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -57,6 +59,7 @@ public class ScalingService {
     private final ClusterNodeDAO clusterNodeDAO;
     private final StockDAO stockDAO;
     private final TransactionTemplate transactionTemplate;
+    private final K8sOrchestratorClient k8sOrchestratorClient;
 
     public ScalingEventResp manualScaleOut(Long clusterId, ClusterScaleOutBO inputBO) {
         ResourceAuthSupport.requireOpsRole();
@@ -76,7 +79,7 @@ public class ScalingService {
             Integer nodesAfter = transactionTemplate.execute(status -> {
                 enterScaling(clusterId);
                 int addedCount = doScaleOut(cluster, currentNodes.size(), selectedMachines, requiredSpec);
-                backToRunning(clusterId);
+                dispatchScaleOutToK8s(cluster, selectedMachines);
                 return nodesBefore + addedCount;
             });
             if (nodesAfter == null) {
@@ -111,7 +114,7 @@ public class ScalingService {
             Integer nodesAfter = transactionTemplate.execute(status -> {
                 enterScaling(clusterId);
                 doScaleIn(clusterId, selectedNodes);
-                backToRunning(clusterId);
+                dispatchScaleInToK8s(cluster, selectedNodes);
                 return nodesBefore - selectedNodes.size();
             });
             if (nodesAfter == null) {
@@ -368,7 +371,7 @@ public class ScalingService {
         node.setNodeName(buildScaleOutNodeName(cluster, nodeIndex));
         node.setMachineId(machine.getMachineId());
         node.setClusterId(cluster.getClusterId());
-        node.setStatus(ClusterStatus.RUNNING.getCode());
+        node.setStatus(ClusterStatus.CREATING.getCode());
         node.setComponentType(cluster.getComponentType());
         node.setCpuCores(machine.getCpuCores());
         node.setRamGb(machine.getRamGb());
@@ -385,10 +388,10 @@ public class ScalingService {
     private String buildScaleOutNodeName(ClusterDO cluster, int index) {
         String clusterName = cluster == null ? null : cluster.getClusterName();
         String componentType = cluster == null ? null : normalizeUpperText(cluster.getComponentType());
-        String prefix = StringUtils.hasText(clusterName) ? clusterName.trim() : "cluster";
         if (COMPONENT_SPARK.equals(componentType)) {
-            return prefix + "-worker-" + index;
+            return buildNodeName(clusterName, index);
         }
+        String prefix = StringUtils.hasText(clusterName) ? clusterName.trim() : "cluster";
         if (COMPONENT_STARROCKS.equals(componentType)) {
             return prefix + "-backend-" + index;
         }
@@ -401,7 +404,7 @@ public class ScalingService {
             List<ClusterNodeDO> currentNodes) {
         List<ClusterNodeDO> scalableNodes = resolveScalableNodes(cluster, currentNodes);
         if (scalableNodes.isEmpty()) {
-            throw new IllegalArgumentException("No scalable worker/backend nodes in cluster");
+            throw new IllegalArgumentException("No scalable nodes in cluster");
         }
 
         if (ScalingSelectMode.MANUAL.getCode().equals(request.getSelectMode())) {
@@ -411,8 +414,7 @@ public class ScalingService {
             for (Long nodeId : request.getNodeIds()) {
                 ClusterNodeDO node = nodeIdToNode.get(nodeId);
                 if (node == null) {
-                    throw new IllegalArgumentException(
-                            "Only spark_worker/starrocks_backend nodes can be scaled in: " + nodeId);
+                    throw new IllegalArgumentException("Only scalable nodes can be scaled in: " + nodeId);
                 }
                 selectedNodes.add(node);
             }
@@ -424,7 +426,7 @@ public class ScalingService {
             throw new IllegalArgumentException("target nodes cannot be less than " + MIN_CLUSTER_NODE_COUNT);
         }
         if (count > scalableNodes.size()) {
-            throw new IllegalArgumentException("Not enough scalable worker/backend nodes for scale in");
+            throw new IllegalArgumentException("Not enough scalable nodes for scale in");
         }
         List<ClusterNodeDO> candidates = new ArrayList<>(scalableNodes);
         candidates.sort(
@@ -453,16 +455,14 @@ public class ScalingService {
     private void ensureScaleOutSupportedComponent(ClusterDO cluster) {
         String componentType = normalizeUpperText(cluster == null ? null : cluster.getComponentType());
         if (!COMPONENT_SPARK.equals(componentType) && !COMPONENT_STARROCKS.equals(componentType)) {
-            throw new IllegalArgumentException(
-                    "Scale out only supports spark_worker or starrocks_backend");
+            throw new IllegalArgumentException("Scale out only supports SPARK or STARROCKS");
         }
     }
 
     private void ensureScaleInSupportedComponent(ClusterDO cluster) {
         String componentType = normalizeUpperText(cluster == null ? null : cluster.getComponentType());
         if (!COMPONENT_SPARK.equals(componentType) && !COMPONENT_STARROCKS.equals(componentType)) {
-            throw new IllegalArgumentException(
-                    "Scale in only supports spark_worker or starrocks_backend");
+            throw new IllegalArgumentException("Scale in only supports SPARK or STARROCKS");
         }
     }
 
@@ -486,9 +486,31 @@ public class ScalingService {
         }
         List<ClusterNodeDO> orderedNodes = new ArrayList<>(currentNodes);
         orderedNodes.sort(Comparator.comparing(ClusterNodeDO::getNodeId, Comparator.nullsLast(Comparator.naturalOrder())));
+        if (COMPONENT_SPARK.equals(componentType)) {
+            return orderedNodes;
+        }
+        if (COMPONENT_STARROCKS.equals(componentType)) {
+            List<ClusterNodeDO> backendNodes = orderedNodes.stream()
+                    .filter(this::isStarRocksBackendNode)
+                    .toList();
+            if (!backendNodes.isEmpty()) {
+                return backendNodes;
+            }
+        }
         return orderedNodes.subList(1, orderedNodes.size());
     }
 
+    private boolean isStarRocksBackendNode(ClusterNodeDO node) {
+        if (node == null || !StringUtils.hasText(node.getNodeName())) {
+            return false;
+        }
+        String lowerNodeName = node.getNodeName().trim().toLowerCase(Locale.ROOT);
+        if (lowerNodeName.contains("-frontend-")) {
+            return false;
+        }
+        return lowerNodeName.contains("-backend-");
+    }
+
     private void releaseMachine(Long machineId) {
         if (machineId == null) {
             return;
@@ -512,16 +534,6 @@ public class ScalingService {
         }
     }
 
-    private void backToRunning(Long clusterId) {
-        int updated = clusterDAO.updateStatusByExpected(
-                clusterId,
-                ClusterStatus.SCALING.getCode(),
-                ClusterStatus.RUNNING.getCode());
-        if (updated <= 0) {
-            throw new IllegalStateException("Cluster status changed during scaling, please retry");
-        }
-    }
-
     private ScalingEventDO createManualEvent(Long clusterId, String eventType, int nodesBefore) {
         ScalingEventDO event = new ScalingEventDO();
         event.setClusterId(clusterId);
@@ -580,4 +592,48 @@ public class ScalingService {
 
     private record NodeSpec(int minCpuCores, int minRamGb, int minSsdGb) {
     }
+
+    private void dispatchScaleOutToK8s(ClusterDO cluster, List<StockInfoDO> machines) {
+        if (cluster == null || cluster.getClusterId() == null || machines == null || machines.isEmpty()) {
+            return;
+        }
+        List<String> nodeIds = machines.stream()
+                .map(StockInfoDO::getMachineId)
+                .map(OrchestratorIdMapper::toNodeId)
+                .toList();
+        String componentType = normalizeUpperText(cluster.getComponentType());
+        String operationId;
+        if (COMPONENT_SPARK.equals(componentType)) {
+            operationId = k8sOrchestratorClient.scaleOutSparkCluster(
+                    OrchestratorIdMapper.toSparkClusterName(cluster.getClusterId()), nodeIds);
+        } else if (COMPONENT_STARROCKS.equals(componentType)) {
+            operationId = k8sOrchestratorClient.scaleOutStarRocksCluster(
+                    OrchestratorIdMapper.toStarRocksClusterName(cluster.getClusterId()), nodeIds);
+        } else {
+            throw new IllegalArgumentException("Unsupported component type for scale-out: " + cluster.getComponentType());
+        }
+        LOGGER.info("Dispatched k8s scale-out. clusterId={}, operationId={}", cluster.getClusterId(), operationId);
+    }
+
+    private void dispatchScaleInToK8s(ClusterDO cluster, List<ClusterNodeDO> nodes) {
+        if (cluster == null || cluster.getClusterId() == null || nodes == null || nodes.isEmpty()) {
+            return;
+        }
+        List<String> nodeIds = nodes.stream()
+                .map(ClusterNodeDO::getMachineId)
+                .map(OrchestratorIdMapper::toNodeId)
+                .toList();
+        String componentType = normalizeUpperText(cluster.getComponentType());
+        String operationId;
+        if (COMPONENT_SPARK.equals(componentType)) {
+            operationId = k8sOrchestratorClient.scaleInSparkCluster(
+                    OrchestratorIdMapper.toSparkClusterName(cluster.getClusterId()), nodeIds);
+        } else if (COMPONENT_STARROCKS.equals(componentType)) {
+            operationId = k8sOrchestratorClient.scaleInStarRocksCluster(
+                    OrchestratorIdMapper.toStarRocksClusterName(cluster.getClusterId()), nodeIds);
+        } else {
+            throw new IllegalArgumentException("Unsupported component type for scale-in: " + cluster.getComponentType());
+        }
+        LOGGER.info("Dispatched k8s scale-in. clusterId={}, operationId={}", cluster.getClusterId(), operationId);
+    }
 }

+ 17 - 1
backend/src/main/java/com/wenshu/platform/service/resource/StockService.java

@@ -30,6 +30,8 @@ import com.wenshu.platform.model.resp.StockMachineResp;
 import com.wenshu.platform.model.resp.StockProbeResp;
 import com.wenshu.platform.service.auth.ForbiddenException;
 import com.wenshu.platform.service.auth.UnauthorizedException;
+import com.wenshu.platform.service.resource.k8s.K8sOrchestratorClient;
+import com.wenshu.platform.service.resource.k8s.OrchestratorIdMapper;
 import lombok.RequiredArgsConstructor;
 import org.springframework.security.core.Authentication;
 import org.springframework.security.core.GrantedAuthority;
@@ -88,6 +90,7 @@ public class StockService {
 
     private final StockDAO stockDAO;
     private final SshCredentialCryptoService sshCredentialCryptoService;
+    private final K8sOrchestratorClient k8sOrchestratorClient;
 
     public PageResp<StockMachineResp> listMachines(StockQuery query) {
         requireOpsRole();
@@ -103,11 +106,22 @@ public class StockService {
         requireOpsRole();
         StockInfoDO normalizedMachine = normalizeCreateMachine(inputMachine);
         validateSshInputOnCreate(normalizedMachine);
-        normalizedMachine.setSshPasswordCipher(sshCredentialCryptoService.encrypt(normalizedMachine.getSshPassword()));
+        String sshPassword = normalizedMachine.getSshPassword();
+        normalizedMachine.setSshPasswordCipher(sshCredentialCryptoService.encrypt(sshPassword));
         normalizedMachine.setSshPassword(null);
         validateMachine(normalizedMachine);
         ensureIpAddressUnique(normalizedMachine.getIpAddress(), null);
         stockDAO.save(normalizedMachine);
+        String operationId = k8sOrchestratorClient.addNode(
+                OrchestratorIdMapper.toNodeId(normalizedMachine.getMachineId()),
+                normalizedMachine.getCpuCores(),
+                normalizedMachine.getRamGb(),
+                normalizedMachine.getSsdGb(),
+                normalizedMachine.getIpAddress(),
+                normalizedMachine.getSshUsername(),
+                sshPassword,
+                SSH_PORT);
+        k8sOrchestratorClient.waitForOperationSucceeded(operationId);
         return StockConverter.toResp(normalizedMachine);
     }
 
@@ -164,6 +178,8 @@ public class StockService {
         if (StockMachineStatus.WORKING.getCode().equals(existingMachine.getStatus())) {
             throw new IllegalArgumentException("Working machine cannot be deleted");
         }
+        String operationId = k8sOrchestratorClient.deleteNode(OrchestratorIdMapper.toNodeId(machineId));
+        k8sOrchestratorClient.waitForOperationSucceeded(operationId);
 
         int deleted = stockDAO.deleteByIdAndStatus(machineId, existingMachine.getStatus());
         if (deleted <= 0) {

+ 477 - 0
backend/src/main/java/com/wenshu/platform/service/resource/k8s/HttpK8sOrchestratorClient.java

@@ -0,0 +1,477 @@
+package com.wenshu.platform.service.resource.k8s;
+
+import java.io.IOException;
+import java.net.URI;
+import java.net.URLEncoder;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.wenshu.platform.config.K8sOrchestratorProperties;
+import jakarta.annotation.PostConstruct;
+import lombok.RequiredArgsConstructor;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+
+@Component
+@RequiredArgsConstructor
+public class HttpK8sOrchestratorClient implements K8sOrchestratorClient {
+
+    private static final Logger LOGGER = LoggerFactory.getLogger(HttpK8sOrchestratorClient.class);
+
+    private final K8sOrchestratorProperties properties;
+    private final ObjectMapper objectMapper;
+
+    private HttpClient httpClient;
+    private String baseUrl;
+
+    @PostConstruct
+    public void init() {
+        this.baseUrl = trimTrailingSlash(properties.getBaseUrl());
+        if (baseUrl.isBlank()) {
+            throw new IllegalStateException("k8s.orchestrator.base-url cannot be empty");
+        }
+        this.httpClient = HttpClient.newBuilder()
+                .connectTimeout(Duration.ofMillis(Math.max(properties.getTimeoutMs(), 1000)))
+                .build();
+    }
+
+    @Override
+    public String createSparkCluster(String clusterName, List<String> nodeIds) {
+        Map<String, Object> payload = new LinkedHashMap<>();
+        payload.put("clusterId", clusterName);
+        payload.put("nodeIds", nodeIds);
+        return submitMutation("/v1/spark-clusters", payload);
+    }
+
+    @Override
+    public String addNode(
+            String nodeId,
+            Integer cpu,
+            Integer ram,
+            Integer ssd,
+            String ipAddress,
+            String sshUser,
+            String sshPassword,
+            Integer sshPort) {
+        Map<String, Object> payload = new LinkedHashMap<>();
+        payload.put("nodeId", nodeId);
+        payload.put("cpu", cpu);
+        payload.put("ram", ram);
+        payload.put("ssd", ssd);
+        payload.put("ipAddress", ipAddress);
+        payload.put("sshUser", sshUser);
+        payload.put("sshPassword", sshPassword);
+        payload.put("sshPort", sshPort);
+        return submitMutation("/v1/nodes", payload);
+    }
+
+    @Override
+    public String deleteNode(String nodeId) {
+        return submitDeleteMutation("/v1/nodes/" + urlEncode(nodeId));
+    }
+
+    @Override
+    public String scaleOutSparkCluster(String clusterName, List<String> nodeIds) {
+        return submitResize("/v1/spark-clusters/" + urlEncode(clusterName) + "/scale-out", nodeIds);
+    }
+
+    @Override
+    public String scaleInSparkCluster(String clusterName, List<String> nodeIds) {
+        return submitResize("/v1/spark-clusters/" + urlEncode(clusterName) + "/scale-in", nodeIds);
+    }
+
+    @Override
+    public String releaseSparkCluster(String clusterName) {
+        return submitMutation("/v1/spark-clusters/" + urlEncode(clusterName) + "/release", null);
+    }
+
+    @Override
+    public String createStarRocksCluster(String clusterName, List<String> feNodeIds, List<String> beNodeIds, String version) {
+        Map<String, Object> payload = new LinkedHashMap<>();
+        payload.put("clusterId", clusterName);
+        payload.put("feNodeIds", feNodeIds);
+        payload.put("beNodeIds", beNodeIds);
+        if (version != null && !version.isBlank()) {
+            payload.put("version", version);
+        }
+        return submitMutation("/v1/starrocks-clusters", payload);
+    }
+
+    @Override
+    public String scaleOutStarRocksCluster(String clusterName, List<String> nodeIds) {
+        return submitResize("/v1/starrocks-clusters/" + urlEncode(clusterName) + "/scale-out", nodeIds);
+    }
+
+    @Override
+    public String scaleInStarRocksCluster(String clusterName, List<String> nodeIds) {
+        return submitResize("/v1/starrocks-clusters/" + urlEncode(clusterName) + "/scale-in", nodeIds);
+    }
+
+    @Override
+    public String releaseStarRocksCluster(String clusterName) {
+        return submitMutation("/v1/starrocks-clusters/" + urlEncode(clusterName) + "/release", null);
+    }
+
+    @Override
+    public String getSparkClusterPhase(String clusterName) {
+        String path = "/v1/spark-clusters/" + urlEncode(clusterName);
+        return getPhase(path);
+    }
+
+    @Override
+    public String getStarRocksClusterPhase(String clusterName) {
+        String path = "/v1/starrocks-clusters/" + urlEncode(clusterName);
+        return getPhase(path);
+    }
+
+    @Override
+    public String submitSparkJob(String clusterName, Object sparkApplicationSpec) {
+        if (clusterName == null || clusterName.isBlank()) {
+            throw new IllegalArgumentException("clusterName cannot be empty");
+        }
+        if (sparkApplicationSpec == null) {
+            throw new IllegalArgumentException("sparkApplicationSpec cannot be null");
+        }
+        Map<String, Object> payload = new LinkedHashMap<>();
+        payload.put("clusterId", clusterName);
+        payload.put("sparkApplicationSpec", sparkApplicationSpec);
+        return submitMutation("/v1/spark-jobs", payload);
+    }
+
+    @Override
+    public SparkJobStatus getSparkJobStatus(String operationId) {
+        if (operationId == null || operationId.isBlank()) {
+            throw new IllegalArgumentException("operationId cannot be empty");
+        }
+        String path = "/v1/spark-jobs/operations/" + urlEncode(operationId);
+        try {
+            HttpRequest request = HttpRequest.newBuilder()
+                    .uri(URI.create(baseUrl + path))
+                    .timeout(Duration.ofMillis(Math.max(properties.getTimeoutMs(), 1000)))
+                    .GET()
+                    .build();
+            HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
+            if (response.statusCode() == 404) {
+                return null;
+            }
+            if (response.statusCode() < 200 || response.statusCode() >= 300) {
+                throw new IllegalStateException("k8s orchestrator get spark job status failed: operationId="
+                        + operationId + ", error=" + extractError(response.body()));
+            }
+            JsonNode root = objectMapper.readTree(response.body());
+            return new SparkJobStatus(
+                    normalizeText(root.path("operationId").asText(null), false),
+                    normalizeText(root.path("operationPhase").asText(null), true),
+                    normalizeText(root.path("resourceRef").asText(null), false),
+                    normalizeText(root.path("namespace").asText(null), false),
+                    normalizeText(root.path("application").asText(null), false),
+                    normalizeText(root.path("appState").asText(null), true),
+                    normalizeText(root.path("message").asText(null), false));
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            throw new IllegalStateException("k8s orchestrator get spark job status interrupted: operationId="
+                    + operationId, e);
+        } catch (IOException e) {
+            throw new IllegalStateException("k8s orchestrator get spark job status failed: operationId="
+                    + operationId + ", error=" + e.getMessage(), e);
+        }
+    }
+
+    @Override
+    public SparkJobResult getSparkJobResult(String operationId) {
+        if (operationId == null || operationId.isBlank()) {
+            throw new IllegalArgumentException("operationId cannot be empty");
+        }
+        String path = "/v1/spark-jobs/operations/" + urlEncode(operationId) + "/result";
+        try {
+            HttpRequest request = HttpRequest.newBuilder()
+                    .uri(URI.create(baseUrl + path))
+                    .timeout(Duration.ofMillis(Math.max(properties.getTimeoutMs(), 1000)))
+                    .GET()
+                    .build();
+            HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
+            if (response.statusCode() == 404) {
+                return null;
+            }
+            if (response.statusCode() < 200 || response.statusCode() >= 300) {
+                throw new IllegalStateException("k8s orchestrator get spark job result failed: operationId="
+                        + operationId + ", error=" + extractError(response.body()));
+            }
+            JsonNode root = objectMapper.readTree(response.body());
+            return new SparkJobResult(
+                    normalizeText(readText(root, "operationId", "OperationID"), false),
+                    normalizeText(readText(root, "namespace", "Namespace"), false),
+                    normalizeText(readText(root, "application", "Application"), false),
+                    normalizeText(readText(root, "sql", "SQL"), false),
+                    normalizeText(readText(root, "result", "Result"), false),
+                    normalizeText(readText(root, "resultRef", "ResultRef"), false),
+                    readBoolean(root, "truncated", "Truncated"),
+                    normalizeText(readText(root, "message", "Message"), false));
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            throw new IllegalStateException("k8s orchestrator get spark job result interrupted: operationId="
+                    + operationId, e);
+        } catch (IOException e) {
+            throw new IllegalStateException("k8s orchestrator get spark job result failed: operationId="
+                    + operationId + ", error=" + e.getMessage(), e);
+        }
+    }
+
+    @Override
+    public void waitForOperationSucceeded(String operationId) {
+        if (operationId == null || operationId.isBlank()) {
+            throw new IllegalArgumentException("operationId cannot be empty");
+        }
+        long timeoutMs = Math.max(properties.getWaitTimeoutMs(), 1000);
+        long pollIntervalMs = Math.max(properties.getPollIntervalMs(), 500);
+        long deadlineAt = System.currentTimeMillis() + timeoutMs;
+
+        String lastPhase = null;
+        String lastMessage = null;
+        while (true) {
+            OperationSnapshot snapshot = getOperation(operationId);
+            lastPhase = snapshot.phase();
+            lastMessage = snapshot.message();
+
+            if ("SUCCEEDED".equals(lastPhase)) {
+                return;
+            }
+            if ("FAILED".equals(lastPhase)) {
+                throw new IllegalStateException("k8s orchestrator operation failed: operationId="
+                        + operationId + ", step=" + nullToDash(snapshot.step())
+                        + ", message=" + nullToDash(snapshot.message()));
+            }
+
+            if (System.currentTimeMillis() >= deadlineAt) {
+                throw new IllegalStateException("k8s orchestrator operation wait timed out: operationId="
+                        + operationId + ", phase=" + nullToDash(lastPhase)
+                        + ", message=" + nullToDash(lastMessage));
+            }
+            try {
+                Thread.sleep(pollIntervalMs);
+            } catch (InterruptedException e) {
+                Thread.currentThread().interrupt();
+                throw new IllegalStateException("k8s orchestrator operation wait interrupted: operationId="
+                        + operationId, e);
+            }
+        }
+    }
+
+    private String submitMutation(String path, Map<String, Object> payload) {
+        try {
+            HttpRequest.Builder builder = HttpRequest.newBuilder()
+                    .uri(URI.create(baseUrl + path))
+                    .timeout(Duration.ofMillis(Math.max(properties.getTimeoutMs(), 1000)))
+                    .header("Idempotency-Key", UUID.randomUUID().toString());
+            if (payload == null) {
+                builder.POST(HttpRequest.BodyPublishers.noBody());
+            } else {
+                String body = objectMapper.writeValueAsString(payload);
+                builder.header("Content-Type", "application/json");
+                builder.POST(HttpRequest.BodyPublishers.ofString(body));
+            }
+            HttpRequest request = builder.build();
+            HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
+            if (response.statusCode() < 200 || response.statusCode() >= 300) {
+                throw new IllegalStateException("k8s orchestrator request failed: " + extractError(response.body()));
+            }
+            JsonNode node = objectMapper.readTree(response.body());
+            String operationId = node.path("operationId").asText(null);
+            if (operationId == null || operationId.isBlank()) {
+                throw new IllegalStateException("k8s orchestrator response missing operationId");
+            }
+            return operationId;
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            throw new IllegalStateException("k8s orchestrator request interrupted: " + e.getMessage(), e);
+        } catch (IOException e) {
+            throw new IllegalStateException("k8s orchestrator request failed: " + e.getMessage(), e);
+        }
+    }
+
+    private String submitDeleteMutation(String path) {
+        try {
+            HttpRequest request = HttpRequest.newBuilder()
+                    .uri(URI.create(baseUrl + path))
+                    .timeout(Duration.ofMillis(Math.max(properties.getTimeoutMs(), 1000)))
+                    .header("Idempotency-Key", UUID.randomUUID().toString())
+                    .DELETE()
+                    .build();
+            HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
+            if (response.statusCode() < 200 || response.statusCode() >= 300) {
+                throw new IllegalStateException("k8s orchestrator request failed: " + extractError(response.body()));
+            }
+            JsonNode node = objectMapper.readTree(response.body());
+            String operationId = node.path("operationId").asText(null);
+            if (operationId == null || operationId.isBlank()) {
+                throw new IllegalStateException("k8s orchestrator response missing operationId");
+            }
+            return operationId;
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            throw new IllegalStateException("k8s orchestrator request interrupted: " + e.getMessage(), e);
+        } catch (IOException e) {
+            throw new IllegalStateException("k8s orchestrator request failed: " + e.getMessage(), e);
+        }
+    }
+
+    private String submitResize(String path, List<String> nodeIds) {
+        Map<String, Object> payload = new LinkedHashMap<>();
+        payload.put("nodeIds", nodeIds);
+        return submitMutation(path, payload);
+    }
+
+    private OperationSnapshot getOperation(String operationId) {
+        String path = "/v1/operations/" + urlEncode(operationId);
+        try {
+            HttpRequest request = HttpRequest.newBuilder()
+                    .uri(URI.create(baseUrl + path))
+                    .timeout(Duration.ofMillis(Math.max(properties.getTimeoutMs(), 1000)))
+                    .GET()
+                    .build();
+            HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
+            if (response.statusCode() == 404) {
+                throw new IllegalStateException("k8s orchestrator operation not found: operationId=" + operationId);
+            }
+            if (response.statusCode() < 200 || response.statusCode() >= 300) {
+                throw new IllegalStateException("k8s orchestrator get operation failed: operationId="
+                        + operationId + ", error=" + extractError(response.body()));
+            }
+            JsonNode root = objectMapper.readTree(response.body());
+            return new OperationSnapshot(
+                    normalizeText(root.path("status").path("phase").asText(null), true),
+                    normalizeText(root.path("status").path("step").asText(null), false),
+                    normalizeText(root.path("status").path("message").asText(null), false));
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            throw new IllegalStateException("k8s orchestrator get operation interrupted: operationId="
+                    + operationId, e);
+        } catch (IOException e) {
+            throw new IllegalStateException("k8s orchestrator get operation failed: operationId="
+                    + operationId + ", error=" + e.getMessage(), e);
+        }
+    }
+
+    private String getPhase(String path) {
+        try {
+            HttpRequest request = HttpRequest.newBuilder()
+                    .uri(URI.create(baseUrl + path))
+                    .timeout(Duration.ofMillis(Math.max(properties.getTimeoutMs(), 1000)))
+                    .GET()
+                    .build();
+            HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
+            if (response.statusCode() == 404) {
+                return null;
+            }
+            if (response.statusCode() < 200 || response.statusCode() >= 300) {
+                throw new IllegalStateException("k8s orchestrator get phase failed: " + extractError(response.body()));
+            }
+            JsonNode root = objectMapper.readTree(response.body());
+            String phase = root.path("status").path("phase").asText(null);
+            return phase == null ? null : phase.trim().toUpperCase();
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            LOGGER.warn("Query k8s cluster phase interrupted: path={}, error={}", path, e.getMessage());
+            return null;
+        } catch (IOException e) {
+            LOGGER.warn("Query k8s cluster phase failed: path={}, error={}", path, e.getMessage());
+            return null;
+        }
+    }
+
+    private String extractError(String rawBody) {
+        try {
+            JsonNode node = objectMapper.readTree(rawBody);
+            String error = node.path("error").asText(null);
+            if (error != null && !error.isBlank()) {
+                return error;
+            }
+        } catch (Exception ignored) {
+            // Ignore parse errors and fallback to raw body
+        }
+        return rawBody;
+    }
+
+    private String trimTrailingSlash(String value) {
+        if (value == null || value.isBlank()) {
+            return "";
+        }
+        String normalized = value.trim();
+        while (normalized.endsWith("/")) {
+            normalized = normalized.substring(0, normalized.length() - 1);
+        }
+        return normalized;
+    }
+
+    private String urlEncode(String value) {
+        return URLEncoder.encode(value, StandardCharsets.UTF_8);
+    }
+
+    private String readText(JsonNode root, String... keys) {
+        if (root == null || keys == null) {
+            return null;
+        }
+        for (String key : keys) {
+            if (key == null || key.isBlank()) {
+                continue;
+            }
+            JsonNode node = root.path(key);
+            if (node.isMissingNode() || node.isNull()) {
+                continue;
+            }
+            if (node.isTextual()) {
+                return node.asText(null);
+            }
+            return node.toString();
+        }
+        return null;
+    }
+
+    private Boolean readBoolean(JsonNode root, String... keys) {
+        if (root == null || keys == null) {
+            return null;
+        }
+        for (String key : keys) {
+            if (key == null || key.isBlank()) {
+                continue;
+            }
+            JsonNode node = root.path(key);
+            if (node.isMissingNode() || node.isNull()) {
+                continue;
+            }
+            return node.asBoolean(false);
+        }
+        return null;
+    }
+
+    private String normalizeText(String value, boolean toUpperCase) {
+        if (value == null) {
+            return null;
+        }
+        String normalized = value.trim();
+        if (normalized.isEmpty()) {
+            return null;
+        }
+        return toUpperCase ? normalized.toUpperCase() : normalized;
+    }
+
+    private String nullToDash(String value) {
+        if (value == null || value.isBlank()) {
+            return "-";
+        }
+        return value;
+    }
+
+    private record OperationSnapshot(String phase, String step, String message) {
+    }
+}

+ 46 - 0
backend/src/main/java/com/wenshu/platform/service/resource/k8s/K8sOrchestratorClient.java

@@ -0,0 +1,46 @@
+package com.wenshu.platform.service.resource.k8s;
+
+import java.util.List;
+
+public interface K8sOrchestratorClient {
+
+    String addNode(
+            String nodeId,
+            Integer cpu,
+            Integer ram,
+            Integer ssd,
+            String ipAddress,
+            String sshUser,
+            String sshPassword,
+            Integer sshPort);
+
+    String deleteNode(String nodeId);
+
+    String createSparkCluster(String clusterName, List<String> nodeIds);
+
+    String scaleOutSparkCluster(String clusterName, List<String> nodeIds);
+
+    String scaleInSparkCluster(String clusterName, List<String> nodeIds);
+
+    String releaseSparkCluster(String clusterName);
+
+    String createStarRocksCluster(String clusterName, List<String> feNodeIds, List<String> beNodeIds, String version);
+
+    String scaleOutStarRocksCluster(String clusterName, List<String> nodeIds);
+
+    String scaleInStarRocksCluster(String clusterName, List<String> nodeIds);
+
+    String releaseStarRocksCluster(String clusterName);
+
+    String getSparkClusterPhase(String clusterName);
+
+    String getStarRocksClusterPhase(String clusterName);
+
+    String submitSparkJob(String clusterName, Object sparkApplicationSpec);
+
+    SparkJobStatus getSparkJobStatus(String operationId);
+
+    SparkJobResult getSparkJobResult(String operationId);
+
+    void waitForOperationSucceeded(String operationId);
+}

+ 28 - 0
backend/src/main/java/com/wenshu/platform/service/resource/k8s/OrchestratorIdMapper.java

@@ -0,0 +1,28 @@
+package com.wenshu.platform.service.resource.k8s;
+
+public final class OrchestratorIdMapper {
+
+    private OrchestratorIdMapper() {
+    }
+
+    public static String toNodeId(Long machineId) {
+        if (machineId == null || machineId <= 0) {
+            throw new IllegalArgumentException("machineId is invalid");
+        }
+        return "node-" + machineId;
+    }
+
+    public static String toSparkClusterName(Long clusterId) {
+        if (clusterId == null || clusterId <= 0) {
+            throw new IllegalArgumentException("clusterId is invalid");
+        }
+        return "spark-" + clusterId;
+    }
+
+    public static String toStarRocksClusterName(Long clusterId) {
+        if (clusterId == null || clusterId <= 0) {
+            throw new IllegalArgumentException("clusterId is invalid");
+        }
+        return "starrocks-" + clusterId;
+    }
+}

+ 12 - 0
backend/src/main/java/com/wenshu/platform/service/resource/k8s/SparkJobResult.java

@@ -0,0 +1,12 @@
+package com.wenshu.platform.service.resource.k8s;
+
+public record SparkJobResult(
+        String operationId,
+        String namespace,
+        String application,
+        String sql,
+        String result,
+        String resultRef,
+        Boolean truncated,
+        String message) {
+}

+ 11 - 0
backend/src/main/java/com/wenshu/platform/service/resource/k8s/SparkJobStatus.java

@@ -0,0 +1,11 @@
+package com.wenshu.platform.service.resource.k8s;
+
+public record SparkJobStatus(
+        String operationId,
+        String operationPhase,
+        String resourceRef,
+        String namespace,
+        String application,
+        String appState,
+        String message) {
+}

+ 7 - 1
backend/src/main/java/com/wenshu/platform/service/taskexec/ExecutionService.java

@@ -75,6 +75,7 @@ public class ExecutionService {
                 updateTask.setStartTime(LocalDateTime.now());
             }
             updateTask.setErrorMessage("");
+            updateTask.setEngineTaskId(engineTaskId);
             taskInstanceDAO.update(updateTask);
         } catch (RuntimeException ex) {
             markDispatchError(taskInstanceId, "Task dispatch failed: " + ex.getMessage());
@@ -150,7 +151,12 @@ public class ExecutionService {
         if (!StringUtils.hasText(engineType)) {
             return "";
         }
-        return engineType.trim().toUpperCase(Locale.ROOT);
+        String normalized = engineType.trim().toUpperCase(Locale.ROOT);
+        return switch (normalized) {
+            case "SPARK_SQL" -> "SPARK";
+            case "STARROCKS_SQL" -> "STARROCKS";
+            default -> normalized;
+        };
     }
 
     private String limitLength(String value, int maxLength) {

+ 3 - 0
backend/src/main/java/com/wenshu/platform/service/taskexec/SchedulerService.java

@@ -39,6 +39,7 @@ public class SchedulerService {
     private final ExecutionService executionService;
     private final WorkflowInstanceDAO workflowInstanceDAO;
     private final TaskInstanceDAO taskInstanceDAO;
+    private final TaskResultPersistenceService taskResultPersistenceService;
 
     private final Map<Long, WorkflowRuntimeContext> activeWorkflowContextMap = new ConcurrentHashMap<>();
 
@@ -172,6 +173,7 @@ public class SchedulerService {
                 if (latestState == TaskInstanceState.SUCCESS) {
                     updateTaskState(taskInstance.getTaskInstanceId(), TaskInstanceState.SUCCESS, null,
                             LocalDateTime.now(), null, "");
+                    taskResultPersistenceService.persistTaskResult(taskInstance.getTaskInstanceId());
                     taskStatusChanged = true;
                     continue;
                 }
@@ -224,6 +226,7 @@ public class SchedulerService {
 
         updateTaskState(failedTask.getTaskInstanceId(), TaskInstanceState.FAILED, null,
                 LocalDateTime.now(), currentRetryCount, "Task failed after retries");
+        taskResultPersistenceService.persistTaskResult(failedTask.getTaskInstanceId());
 
         if (context.getFailureStrategy() == WorkflowFailureStrategy.STOP) {
             markStopDownstream(context, taskByTaskId, failedTask.getTaskId());

+ 376 - 24
backend/src/main/java/com/wenshu/platform/service/taskexec/SparkAdapter.java

@@ -1,20 +1,45 @@
 package com.wenshu.platform.service.taskexec;
 
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.time.LocalDate;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
 import java.util.Locale;
 import java.util.Map;
 import java.util.UUID;
-import java.util.concurrent.ConcurrentHashMap;
 
+import com.wenshu.platform.config.PolarisProperties;
+import com.wenshu.platform.config.SparkSqlRunnerProperties;
 import com.wenshu.platform.model.dataobject.TaskDefinitionDO;
 import com.wenshu.platform.model.dataobject.TaskInstanceDO;
 import com.wenshu.platform.model.enums.TaskInstanceState;
+import com.wenshu.platform.service.resource.k8s.K8sOrchestratorClient;
+import com.wenshu.platform.service.resource.k8s.OrchestratorIdMapper;
+import com.wenshu.platform.service.resource.k8s.SparkJobStatus;
+import lombok.RequiredArgsConstructor;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
 
 @Service
+@RequiredArgsConstructor
 public class SparkAdapter extends EngineAdapter {
 
-    private static final long DEFAULT_RUN_MILLIS = 1800L;
-    private final Map<String, EngineTaskRecord> taskRecordMap = new ConcurrentHashMap<>();
+    private static final Logger LOGGER = LoggerFactory.getLogger(SparkAdapter.class);
+    private static final String SPARK_APP_STATE_COMPLETED = "COMPLETED";
+    private static final String SPARK_APP_STATE_SUCCEEDED = "SUCCEEDED";
+    private static final String SPARK_APP_STATE_FAILED = "FAILED";
+    private static final String SPARK_APP_STATE_RUNNING = "RUNNING";
+    private static final String OP_PHASE_FAILED = "FAILED";
+    private static final String RESULT_PATH_PREFIX = "s3a://";
+
+    private final SparkClusterConfigResolver sparkClusterConfigResolver;
+    private final K8sOrchestratorClient k8sOrchestratorClient;
+    private final SparkSqlRunnerProperties sparkSqlRunnerProperties;
+    private final PolarisProperties polarisProperties;
 
     @Override
     public String getEngineType() {
@@ -23,41 +48,368 @@ public class SparkAdapter extends EngineAdapter {
 
     @Override
     public String submitTask(TaskDefinitionDO taskDefinition, TaskInstanceDO taskInstance) {
-        String engineTaskId = "spark-" + UUID.randomUUID();
-        boolean shouldFail = containsFailFlag(taskDefinition.getTaskContent());
-        taskRecordMap.put(engineTaskId, new EngineTaskRecord(System.currentTimeMillis(), DEFAULT_RUN_MILLIS, shouldFail));
-        return engineTaskId;
+        if (taskDefinition == null) {
+            throw new IllegalArgumentException("taskDefinition cannot be null");
+        }
+        if (taskInstance == null || taskInstance.getExecutorId() == null || taskInstance.getExecutorId() <= 0) {
+            throw new IllegalArgumentException("task executor clusterId is invalid");
+        }
+        if (!StringUtils.hasText(taskDefinition.getTaskContent())) {
+            throw new IllegalArgumentException("spark sql task content cannot be empty");
+        }
+
+        Long clusterId = taskInstance.getExecutorId();
+        String clusterName = OrchestratorIdMapper.toSparkClusterName(clusterId);
+        Map<String, String> mergedSparkConfig = sparkClusterConfigResolver.resolveSparkConfig(clusterId);
+        Map<String, Object> sparkApplicationSpec = buildSparkApplicationSpec(
+                taskDefinition.getTaskContent(),
+                taskInstance.getTaskInstanceId(),
+                mergedSparkConfig);
+        String operationId = k8sOrchestratorClient.submitSparkJob(clusterName, sparkApplicationSpec);
+
+        LOGGER.info(
+                "Submitted spark sql task to k8s orchestrator. taskId={}, clusterId={}, operationId={}, sparkConfigKeyCount={}",
+                taskDefinition.getTaskId(),
+                clusterId,
+                operationId,
+                mergedSparkConfig.size());
+        return operationId;
     }
 
     @Override
     public TaskInstanceState queryStatus(String engineTaskId) {
-        EngineTaskRecord record = taskRecordMap.get(engineTaskId);
-        if (record == null) {
+        if (!StringUtils.hasText(engineTaskId)) {
             return TaskInstanceState.FAILED;
         }
-        if (System.currentTimeMillis() - record.startTimestamp < record.costMillis) {
+        try {
+            SparkJobStatus status = k8sOrchestratorClient.getSparkJobStatus(engineTaskId.trim());
+            if (status == null) {
+                return TaskInstanceState.SUBMITTED;
+            }
+
+            String operationPhase = normalizeUpper(status.operationPhase());
+            if (OP_PHASE_FAILED.equals(operationPhase)) {
+                return TaskInstanceState.FAILED;
+            }
+
+            String appState = normalizeUpper(status.appState());
+            if (SPARK_APP_STATE_COMPLETED.equals(appState) || SPARK_APP_STATE_SUCCEEDED.equals(appState)) {
+                return TaskInstanceState.SUCCESS;
+            }
+            if (SPARK_APP_STATE_FAILED.equals(appState)) {
+                return TaskInstanceState.FAILED;
+            }
+            if (SPARK_APP_STATE_RUNNING.equals(appState)) {
+                return TaskInstanceState.RUNNING;
+            }
+            return TaskInstanceState.SUBMITTED;
+        } catch (RuntimeException ex) {
+            LOGGER.warn("Query spark task status failed, fallback to RUNNING. engineTaskId={}, error={}",
+                    engineTaskId, ex.getMessage());
             return TaskInstanceState.RUNNING;
         }
-        taskRecordMap.remove(engineTaskId);
-        return record.shouldFail ? TaskInstanceState.FAILED : TaskInstanceState.SUCCESS;
     }
 
-    private boolean containsFailFlag(String script) {
-        if (script == null) {
-            return false;
+    private Map<String, Object> buildSparkApplicationSpec(
+            String sql,
+            Long taskInstanceId,
+            Map<String, String> mergedSparkConfig) {
+        Map<String, String> sparkConfig = new LinkedHashMap<>();
+        if (mergedSparkConfig != null && !mergedSparkConfig.isEmpty()) {
+            sparkConfig.putAll(mergedSparkConfig);
+        }
+        applyPolarisSparkDefaults(sparkConfig);
+        String resultOutputUri = buildSparkResultOutputUri(taskInstanceId);
+        applyResultOutputSparkDefaults(sparkConfig, resultOutputUri);
+
+        String runnerType = preferText(sparkSqlRunnerProperties.getType(), "Java");
+        boolean pythonRunner = "PYTHON".equalsIgnoreCase(runnerType);
+        String image = sparkConfig.remove("spark.image");
+        String imagePullPolicy = sparkConfig.remove("spark.imagePullPolicy");
+        String sparkVersion = preferText(
+                sparkConfig.remove("spark.version"),
+                sparkSqlRunnerProperties.getSparkVersion());
+
+        Map<String, Object> spec = new LinkedHashMap<>();
+        spec.put("type", pythonRunner ? "Python" : runnerType);
+        spec.put("mode", sparkSqlRunnerProperties.getMode());
+        if (StringUtils.hasText(sparkVersion)) {
+            spec.put("sparkVersion", sparkVersion);
+        }
+        spec.put("mainApplicationFile", sparkSqlRunnerProperties.getMainApplicationFile());
+        if (pythonRunner) {
+            spec.put("pythonVersion", preferText(sparkSqlRunnerProperties.getPythonVersion(), "3"));
+            ArrayList<String> arguments = new ArrayList<>();
+            arguments.add("--sql");
+            arguments.add(sql);
+            if (StringUtils.hasText(resultOutputUri)) {
+                arguments.add("--result-output");
+                arguments.add(resultOutputUri);
+            }
+            spec.put("arguments", arguments.toArray(new String[0]));
+        } else {
+            spec.put("mainClass", sparkSqlRunnerProperties.getMainClass());
+            spec.put("arguments", new String[]{"-e", sql});
+        }
+
+        if (StringUtils.hasText(image)) {
+            spec.put("image", image);
+        }
+        if (StringUtils.hasText(imagePullPolicy)) {
+            spec.put("imagePullPolicy", imagePullPolicy);
+        }
+
+        Map<String, Object> driver = new LinkedHashMap<>();
+        driver.put(
+                "cores",
+                parsePositiveInt(
+                        sparkConfig.get("spark.driver.cores"),
+                        sparkSqlRunnerProperties.getDefaultDriverCores()));
+        driver.put(
+                "memory",
+                preferText(
+                        sparkConfig.get("spark.driver.memory"),
+                        sparkSqlRunnerProperties.getDefaultDriverMemory()));
+        spec.put("driver", driver);
+
+        Map<String, Object> executor = new LinkedHashMap<>();
+        executor.put(
+                "instances",
+                parsePositiveInt(
+                        sparkConfig.get("spark.executor.instances"),
+                        sparkSqlRunnerProperties.getDefaultExecutorInstances()));
+        executor.put(
+                "cores",
+                parsePositiveInt(
+                        sparkConfig.get("spark.executor.cores"),
+                        sparkSqlRunnerProperties.getDefaultExecutorCores()));
+        executor.put(
+                "memory",
+                preferText(
+                        sparkConfig.get("spark.executor.memory"),
+                        sparkSqlRunnerProperties.getDefaultExecutorMemory()));
+        spec.put("executor", executor);
+
+        if (!sparkConfig.isEmpty()) {
+            spec.put("sparkConf", sparkConfig);
+        }
+        return spec;
+    }
+
+    private String buildSparkResultOutputUri(Long taskInstanceId) {
+        PolarisProperties.Oss oss = polarisProperties.getOss();
+        if (taskInstanceId == null || taskInstanceId <= 0 || oss == null) {
+            return null;
+        }
+        String bucket = normalizeText(oss.getBucket());
+        if (!StringUtils.hasText(bucket)) {
+            return null;
+        }
+        LocalDate now = LocalDate.now();
+        return String.format(
+                "%s%s/task-results/%04d/%02d/task-%d-%s",
+                RESULT_PATH_PREFIX,
+                bucket,
+                now.getYear(),
+                now.getMonthValue(),
+                taskInstanceId,
+                UUID.randomUUID().toString().replace("-", ""));
+    }
+
+    private void applyResultOutputSparkDefaults(Map<String, String> sparkConfig, String resultOutputUri) {
+        if (sparkConfig == null || StringUtils.hasText(resultOutputUri) == false) {
+            return;
+        }
+        PolarisProperties.Oss oss = polarisProperties.getOss();
+        if (oss == null) {
+            return;
+        }
+        String endpoint = normalizeText(oss.getEndpoint());
+        String accessKeyId = normalizeText(oss.getAccessKeyId());
+        String accessKeySecret = normalizeText(oss.getAccessKeySecret());
+        if (!StringUtils.hasText(endpoint) || !StringUtils.hasText(accessKeyId) || !StringUtils.hasText(accessKeySecret)) {
+            return;
+        }
+        EndpointInfo endpointInfo = parseEndpoint(endpoint);
+        if (!StringUtils.hasText(endpointInfo.hostPort())) {
+            return;
+        }
+
+        sparkConfig.putIfAbsent("spark.hadoop.fs.s3a.impl", "org.apache.hadoop.fs.s3a.S3AFileSystem");
+        sparkConfig.putIfAbsent("spark.hadoop.fs.s3a.endpoint", endpointInfo.hostPort());
+        sparkConfig.putIfAbsent("spark.hadoop.fs.s3a.path.style.access", "false");
+        sparkConfig.putIfAbsent("spark.hadoop.fs.s3a.connection.ssl.enabled", Boolean.toString(endpointInfo.sslEnabled()));
+        sparkConfig.putIfAbsent("spark.hadoop.fs.s3a.aws.credentials.provider",
+                "org.apache.hadoop.fs.s3a.SimpleAWSCredentialsProvider");
+        sparkConfig.putIfAbsent("spark.hadoop.fs.s3a.access.key", accessKeyId);
+        sparkConfig.putIfAbsent("spark.hadoop.fs.s3a.secret.key", accessKeySecret);
+    }
+
+    private EndpointInfo parseEndpoint(String endpoint) {
+        String normalized = normalizeText(endpoint);
+        if (!StringUtils.hasText(normalized)) {
+            return new EndpointInfo("", true);
+        }
+        if (normalized.startsWith("http://") || normalized.startsWith("https://")) {
+            try {
+                URI uri = new URI(normalized);
+                String host = normalizeText(uri.getHost());
+                if (!StringUtils.hasText(host)) {
+                    return new EndpointInfo("", "https".equalsIgnoreCase(uri.getScheme()));
+                }
+                int port = uri.getPort();
+                String hostPort = port > 0 ? host + ":" + port : host;
+                return new EndpointInfo(hostPort, "https".equalsIgnoreCase(uri.getScheme()));
+            } catch (URISyntaxException ex) {
+                return new EndpointInfo("", true);
+            }
+        }
+        return new EndpointInfo(normalized, true);
+    }
+
+    private void applyPolarisSparkDefaults(Map<String, String> sparkConfig) {
+        if (sparkConfig == null) {
+            return;
+        }
+        String catalogAlias = preferText(sparkSqlRunnerProperties.getPolarisCatalogAlias(), "polaris");
+        String catalogRootKey = "spark.sql.catalog." + catalogAlias;
+        String catalogPrefix = "spark.sql.catalog." + catalogAlias + ".";
+
+        sparkConfig.putIfAbsent(
+                "spark.sql.extensions",
+                "org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions");
+        sparkConfig.putIfAbsent("spark.sql.defaultCatalog", catalogAlias);
+        sparkConfig.putIfAbsent(catalogRootKey, "org.apache.iceberg.spark.SparkCatalog");
+        sparkConfig.putIfAbsent(catalogPrefix + "token-refresh-enabled", "true");
+        sparkConfig.putIfAbsent(catalogPrefix + "type", "rest");
+        sparkConfig.putIfAbsent(catalogPrefix + "io-impl", "org.apache.iceberg.aws.s3.S3FileIO");
+
+        String catalogUri = buildPolarisCatalogUri(polarisProperties.getHost());
+        if (StringUtils.hasText(catalogUri)) {
+            // Polaris catalog URI is backend-managed and must not be overridden by cluster config.
+            sparkConfig.put(catalogPrefix + "uri", catalogUri);
+        }
+        String warehouse = normalizeText(polarisProperties.getDefaultCatalog());
+        if (StringUtils.hasText(warehouse)) {
+            sparkConfig.putIfAbsent(catalogPrefix + "warehouse", warehouse);
+        }
+
+        String clientId = normalizeText(polarisProperties.getClientId());
+        String clientSecret = normalizeText(polarisProperties.getClientSecret());
+        if (StringUtils.hasText(clientId) && StringUtils.hasText(clientSecret)) {
+            sparkConfig.putIfAbsent(catalogPrefix + "credential", clientId + ":" + clientSecret);
+        }
+
+        String realm = normalizeText(polarisProperties.getRealm());
+        if (StringUtils.hasText(realm)) {
+            sparkConfig.putIfAbsent(catalogPrefix + "header.Polaris-Realm", realm);
+        }
+
+        String scope = normalizeText(sparkSqlRunnerProperties.getPolarisScope());
+        if (StringUtils.hasText(scope)) {
+            sparkConfig.putIfAbsent(catalogPrefix + "scope", scope);
+        }
+
+        PolarisProperties.Oss oss = polarisProperties.getOss();
+        if (oss != null) {
+            String region = normalizeText(oss.getRegion());
+            if (StringUtils.hasText(region)) {
+                sparkConfig.putIfAbsent(catalogPrefix + "s3.region", region);
+            }
+            String endpoint = normalizeText(oss.getEndpoint());
+            if (StringUtils.hasText(endpoint)) {
+                sparkConfig.putIfAbsent(catalogPrefix + "s3.endpoint", endpoint);
+            }
+            String accessKeyId = normalizeText(oss.getAccessKeyId());
+            if (StringUtils.hasText(accessKeyId)) {
+                sparkConfig.putIfAbsent(catalogPrefix + "s3.access-key-id", accessKeyId);
+            }
+            String accessKeySecret = normalizeText(oss.getAccessKeySecret());
+            if (StringUtils.hasText(accessKeySecret)) {
+                sparkConfig.putIfAbsent(catalogPrefix + "s3.secret-access-key", accessKeySecret);
+            }
+        }
+
+        String icebergRuntimePackage = normalizeText(sparkSqlRunnerProperties.getIcebergRuntimePackage());
+        if (StringUtils.hasText(icebergRuntimePackage)) {
+            String jarsPackages = normalizeText(sparkConfig.get("spark.jars.packages"));
+            if (!StringUtils.hasText(jarsPackages)) {
+                jarsPackages = icebergRuntimePackage;
+            }
+            String icebergAwsBundlePackage = normalizeText(sparkSqlRunnerProperties.getIcebergAwsBundlePackage());
+            if (StringUtils.hasText(icebergAwsBundlePackage)) {
+                jarsPackages = appendMavenCoordinate(jarsPackages, icebergAwsBundlePackage);
+            }
+            if (StringUtils.hasText(jarsPackages)) {
+                sparkConfig.put("spark.jars.packages", jarsPackages);
+            }
+            // Spark operator pod runs as non-root without HOME, so Ivy cache must point to writable path.
+            sparkConfig.putIfAbsent("spark.jars.ivy", "/tmp/.ivy2");
         }
-        return script.toUpperCase(Locale.ROOT).contains("SIMULATE_FAIL");
     }
 
-    private static class EngineTaskRecord {
-        private final long startTimestamp;
-        private final long costMillis;
-        private final boolean shouldFail;
+    private String appendMavenCoordinate(String existingCoordinates, String coordinateToAppend) {
+        LinkedHashSet<String> coordinates = new LinkedHashSet<>();
+        if (StringUtils.hasText(existingCoordinates)) {
+            String[] splits = existingCoordinates.split(",");
+            for (String split : splits) {
+                String normalized = normalizeText(split);
+                if (StringUtils.hasText(normalized)) {
+                    coordinates.add(normalized);
+                }
+            }
+        }
+        String normalizedAppend = normalizeText(coordinateToAppend);
+        if (StringUtils.hasText(normalizedAppend)) {
+            coordinates.add(normalizedAppend);
+        }
+        return String.join(",", coordinates);
+    }
+
+    private String buildPolarisCatalogUri(String host) {
+        String normalizedHost = normalizeText(host);
+        if (!StringUtils.hasText(normalizedHost)) {
+            return null;
+        }
+        if (normalizedHost.endsWith("/")) {
+            normalizedHost = normalizedHost.substring(0, normalizedHost.length() - 1);
+        }
+        return normalizedHost + "/api/catalog";
+    }
+
+    private int parsePositiveInt(String value, int defaultValue) {
+        if (!StringUtils.hasText(value)) {
+            return defaultValue;
+        }
+        try {
+            int parsed = Integer.parseInt(value.trim());
+            return parsed > 0 ? parsed : defaultValue;
+        } catch (NumberFormatException ex) {
+            return defaultValue;
+        }
+    }
 
-        private EngineTaskRecord(long startTimestamp, long costMillis, boolean shouldFail) {
-            this.startTimestamp = startTimestamp;
-            this.costMillis = costMillis;
-            this.shouldFail = shouldFail;
+    private String preferText(String value, String defaultValue) {
+        if (!StringUtils.hasText(value)) {
+            return defaultValue;
         }
+        return value.trim();
+    }
+
+    private String normalizeText(String value) {
+        if (!StringUtils.hasText(value)) {
+            return null;
+        }
+        return value.trim();
+    }
+
+    private String normalizeUpper(String value) {
+        if (!StringUtils.hasText(value)) {
+            return null;
+        }
+        return value.trim().toUpperCase(Locale.ROOT);
+    }
+
+    private record EndpointInfo(String hostPort, boolean sslEnabled) {
     }
 }

+ 111 - 0
backend/src/main/java/com/wenshu/platform/service/taskexec/SparkClusterConfigResolver.java

@@ -0,0 +1,111 @@
+package com.wenshu.platform.service.taskexec;
+
+import java.io.IOException;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Set;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.wenshu.platform.dao.ConfigVersionDAO;
+import com.wenshu.platform.model.dataobject.ConfigVersionDO;
+import lombok.RequiredArgsConstructor;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
+
+@Service
+@RequiredArgsConstructor
+public class SparkClusterConfigResolver {
+
+    private static final Logger LOGGER = LoggerFactory.getLogger(SparkClusterConfigResolver.class);
+
+    private static final Map<String, String> LEGACY_SPARK_KEY_MAPPING = Map.of(
+            "executorMemory", "spark.executor.memory",
+            "executorCores", "spark.executor.cores",
+            "executorInstances", "spark.executor.instances",
+            "driverMemory", "spark.driver.memory",
+            "driverCores", "spark.driver.cores",
+            "shufflePartitions", "spark.sql.shuffle.partitions");
+    private static final Set<String> BACKEND_MANAGED_SPARK_KEYS = Set.of(
+            "spark.sql.catalog.polaris.uri");
+
+    private final ConfigVersionDAO configVersionDAO;
+    private final ObjectMapper objectMapper;
+
+    public Map<String, String> resolveSparkConfig(Long clusterId) {
+        if (clusterId == null || clusterId <= 0) {
+            return Collections.emptyMap();
+        }
+        ConfigVersionDO activeVersion = configVersionDAO.findActiveByClusterId(clusterId);
+        if (activeVersion == null || !StringUtils.hasText(activeVersion.getConfigContent())) {
+            return Collections.emptyMap();
+        }
+
+        JsonNode rootNode = parseJsonSafely(clusterId, activeVersion.getConfigContent());
+        if (rootNode == null || !rootNode.isObject()) {
+            return Collections.emptyMap();
+        }
+
+        Map<String, String> resolved = new LinkedHashMap<>();
+        rootNode.fields().forEachRemaining(entry -> {
+            String key = normalizeKey(entry.getKey());
+            if (key == null || !key.startsWith("spark.")) {
+                return;
+            }
+            if (BACKEND_MANAGED_SPARK_KEYS.contains(key)) {
+                return;
+            }
+            String value = toSparkConfigValue(entry.getValue());
+            if (value == null) {
+                return;
+            }
+            resolved.put(key, value);
+        });
+
+        for (Map.Entry<String, String> mapping : LEGACY_SPARK_KEY_MAPPING.entrySet()) {
+            String legacyKey = mapping.getKey();
+            String sparkKey = mapping.getValue();
+            if (resolved.containsKey(sparkKey)) {
+                continue;
+            }
+            String legacyValue = toSparkConfigValue(rootNode.get(legacyKey));
+            if (legacyValue != null) {
+                resolved.put(sparkKey, legacyValue);
+            }
+        }
+        return Collections.unmodifiableMap(resolved);
+    }
+
+    private JsonNode parseJsonSafely(Long clusterId, String configContent) {
+        try {
+            return objectMapper.readTree(configContent);
+        } catch (IOException ex) {
+            LOGGER.warn("Ignore invalid active cluster config. clusterId={}, error={}", clusterId, ex.getMessage());
+            return null;
+        }
+    }
+
+    private String normalizeKey(String value) {
+        if (!StringUtils.hasText(value)) {
+            return null;
+        }
+        return value.trim();
+    }
+
+    private String toSparkConfigValue(JsonNode valueNode) {
+        if (valueNode == null || valueNode.isNull()) {
+            return null;
+        }
+        if (valueNode.isTextual()) {
+            String value = valueNode.asText().trim();
+            return value.isEmpty() ? null : value;
+        }
+        if (valueNode.isNumber() || valueNode.isBoolean()) {
+            return valueNode.asText();
+        }
+        return null;
+    }
+}

+ 189 - 0
backend/src/main/java/com/wenshu/platform/service/taskexec/TaskResultPersistenceService.java

@@ -0,0 +1,189 @@
+package com.wenshu.platform.service.taskexec;
+
+import java.nio.charset.StandardCharsets;
+import java.time.LocalDateTime;
+import java.util.Locale;
+
+import com.wenshu.platform.dao.TaskDefinitionDAO;
+import com.wenshu.platform.dao.TaskInstanceDAO;
+import com.wenshu.platform.model.dataobject.TaskDefinitionDO;
+import com.wenshu.platform.model.dataobject.TaskInstanceDO;
+import com.wenshu.platform.model.enums.TaskInstanceState;
+import com.wenshu.platform.service.resource.k8s.K8sOrchestratorClient;
+import com.wenshu.platform.service.resource.k8s.SparkJobResult;
+import lombok.RequiredArgsConstructor;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
+
+@Service
+@RequiredArgsConstructor
+public class TaskResultPersistenceService {
+
+    private static final Logger LOGGER = LoggerFactory.getLogger(TaskResultPersistenceService.class);
+
+    private static final String TASK_TYPE_SPARK = "SPARK";
+    private static final String TASK_TYPE_SPARK_SQL = "SPARK_SQL";
+
+    private static final String RESULT_FORMAT_PARQUET = "PARQUET";
+    private static final int RESULT_PREVIEW_LIMIT_CHARS = 4000;
+    private static final int MAX_ERROR_MESSAGE_LENGTH = 512;
+
+    private final TaskInstanceDAO taskInstanceDAO;
+    private final TaskDefinitionDAO taskDefinitionDAO;
+    private final K8sOrchestratorClient k8sOrchestratorClient;
+    private final TaskResultStorageService taskResultStorageService;
+
+    public void persistTaskResult(Long taskInstanceId) {
+        if (taskInstanceId == null) {
+            return;
+        }
+
+        TaskInstanceDO taskInstance = taskInstanceDAO.findById(taskInstanceId);
+        if (taskInstance == null) {
+            return;
+        }
+
+        TaskInstanceState taskState = parseState(taskInstance.getState());
+        if (taskState == null || taskState.isTerminal() == false) {
+            return;
+        }
+        if (hasPersistedResult(taskInstance)) {
+            return;
+        }
+        if (StringUtils.hasText(taskInstance.getEngineTaskId()) == false) {
+            return;
+        }
+
+        TaskDefinitionDO taskDefinition = taskDefinitionDAO.findById(taskInstance.getTaskId());
+        if (taskDefinition == null) {
+            return;
+        }
+        if (isSparkTask(taskDefinition.getTaskType()) == false) {
+            return;
+        }
+
+        SparkJobResult sparkJobResult;
+        try {
+            sparkJobResult = k8sOrchestratorClient.getSparkJobResult(taskInstance.getEngineTaskId().trim());
+        } catch (RuntimeException ex) {
+            LOGGER.warn("Query spark task result failed. taskInstanceId={}, engineTaskId={}, error={}",
+                    taskInstanceId,
+                    taskInstance.getEngineTaskId(),
+                    ex.getMessage());
+            return;
+        }
+        if (sparkJobResult == null) {
+            return;
+        }
+
+        TaskResultSnapshot snapshot = buildSnapshot(taskDefinition, sparkJobResult);
+        if (snapshot == null) {
+            return;
+        }
+
+        String resultRef = normalizeText(sparkJobResult.resultRef());
+        if (StringUtils.hasText(resultRef) == false) {
+            resultRef = taskResultStorageService.storeText(taskInstanceId, snapshot.fullContent(), "parquet");
+        }
+
+        TaskInstanceDO update = new TaskInstanceDO();
+        update.setTaskInstanceId(taskInstanceId);
+        update.setResultFormat(snapshot.format());
+        update.setResultPreview(snapshot.previewContent());
+        update.setResultTruncated(snapshot.truncated() ? 1 : 0);
+        update.setResultSizeBytes(snapshot.sizeBytes());
+        update.setResultUpdatedAt(LocalDateTime.now());
+        if (StringUtils.hasText(resultRef)) {
+            update.setResultRef(resultRef);
+        }
+
+        if (taskState == TaskInstanceState.FAILED
+                && StringUtils.hasText(taskInstance.getErrorMessage()) == false
+                && StringUtils.hasText(sparkJobResult.message())) {
+            update.setErrorMessage(limitLength(sparkJobResult.message().trim(), MAX_ERROR_MESSAGE_LENGTH));
+        }
+
+        taskInstanceDAO.update(update);
+    }
+
+    private TaskResultSnapshot buildSnapshot(TaskDefinitionDO taskDefinition, SparkJobResult sparkJobResult) {
+        String result = normalizeText(sparkJobResult.result());
+        String resultRef = normalizeText(sparkJobResult.resultRef());
+        String message = normalizeText(sparkJobResult.message());
+
+        String fullContent = result;
+        if (StringUtils.hasText(fullContent) == false && StringUtils.hasText(message)) {
+            fullContent = message;
+        }
+        if (StringUtils.hasText(fullContent) == false && StringUtils.hasText(resultRef)) {
+            fullContent = "Spark SQL result stored at: " + resultRef;
+        }
+        if (StringUtils.hasText(fullContent) == false && StringUtils.hasText(taskDefinition.getTaskContent())) {
+            fullContent = "Spark SQL executed, no tabular output was produced.";
+        }
+        if (StringUtils.hasText(fullContent) == false) {
+            return null;
+        }
+
+        long sizeBytes = fullContent.getBytes(StandardCharsets.UTF_8).length;
+        boolean truncated = Boolean.TRUE.equals(sparkJobResult.truncated());
+        String preview = buildPreview(fullContent);
+
+        return new TaskResultSnapshot(
+                RESULT_FORMAT_PARQUET,
+                preview,
+                fullContent,
+                truncated,
+                sizeBytes);
+    }
+
+    private String buildPreview(String content) {
+        if (StringUtils.hasText(content) == false) {
+            return content;
+        }
+        if (content.length() <= RESULT_PREVIEW_LIMIT_CHARS) {
+            return content;
+        }
+        return content.substring(0, RESULT_PREVIEW_LIMIT_CHARS) + "\n...";
+    }
+
+    private boolean hasPersistedResult(TaskInstanceDO taskInstance) {
+        if (taskInstance == null) {
+            return false;
+        }
+        return StringUtils.hasText(taskInstance.getResultPreview())
+                || StringUtils.hasText(taskInstance.getResultRef());
+    }
+
+    private boolean isSparkTask(String taskType) {
+        if (StringUtils.hasText(taskType) == false) {
+            return false;
+        }
+        String normalized = taskType.trim().toUpperCase(Locale.ROOT);
+        return TASK_TYPE_SPARK.equals(normalized) || TASK_TYPE_SPARK_SQL.equals(normalized);
+    }
+
+    private TaskInstanceState parseState(String state) {
+        try {
+            return TaskInstanceState.fromCode(state);
+        } catch (IllegalArgumentException ex) {
+            return null;
+        }
+    }
+
+    private String normalizeText(String value) {
+        if (StringUtils.hasText(value) == false) {
+            return null;
+        }
+        return value.trim();
+    }
+
+    private String limitLength(String value, int maxLength) {
+        if (StringUtils.hasText(value) == false || value.length() <= maxLength) {
+            return value;
+        }
+        return value.substring(0, maxLength);
+    }
+}

+ 9 - 0
backend/src/main/java/com/wenshu/platform/service/taskexec/TaskResultSnapshot.java

@@ -0,0 +1,9 @@
+package com.wenshu.platform.service.taskexec;
+
+public record TaskResultSnapshot(
+        String format,
+        String previewContent,
+        String fullContent,
+        boolean truncated,
+        long sizeBytes) {
+}

+ 280 - 0
backend/src/main/java/com/wenshu/platform/service/taskexec/TaskResultStorageService.java

@@ -0,0 +1,280 @@
+package com.wenshu.platform.service.taskexec;
+
+import java.io.IOException;
+import java.net.URI;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.UUID;
+
+import com.wenshu.platform.config.PolarisProperties;
+import lombok.RequiredArgsConstructor;
+import org.apache.avro.Schema;
+import org.apache.avro.generic.GenericData;
+import org.apache.avro.generic.GenericRecord;
+import org.apache.hadoop.conf.Configuration;
+import org.apache.parquet.avro.AvroParquetReader;
+import org.apache.parquet.avro.AvroParquetWriter;
+import org.apache.parquet.hadoop.ParquetReader;
+import org.apache.parquet.hadoop.ParquetWriter;
+import org.apache.parquet.hadoop.metadata.CompressionCodecName;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
+import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
+import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
+import software.amazon.awssdk.core.sync.RequestBody;
+import software.amazon.awssdk.core.sync.ResponseTransformer;
+import software.amazon.awssdk.regions.Region;
+import software.amazon.awssdk.services.s3.S3Client;
+import software.amazon.awssdk.services.s3.S3Configuration;
+import software.amazon.awssdk.services.s3.model.GetObjectRequest;
+import software.amazon.awssdk.services.s3.model.PutObjectRequest;
+
+@Service
+@RequiredArgsConstructor
+public class TaskResultStorageService {
+
+    private static final Logger LOGGER = LoggerFactory.getLogger(TaskResultStorageService.class);
+
+    private static final String RESULT_REF_PREFIX = "oss://";
+    private static final String DEFAULT_EXTENSION = "parquet";
+    private static final String SCHEMA_JSON = "{"
+            + "\"type\":\"record\"," 
+            + "\"name\":\"TaskResultRecord\"," 
+            + "\"fields\":["
+            + "{\"name\":\"result\",\"type\":\"string\"},"
+            + "{\"name\":\"updated_at\",\"type\":\"string\"}"
+            + "]}";
+    private static final Schema RESULT_SCHEMA = new Schema.Parser().parse(SCHEMA_JSON);
+
+    private final PolarisProperties polarisProperties;
+
+    public String storeText(Long taskInstanceId, String content, String extension) {
+        if (taskInstanceId == null || StringUtils.hasText(content) == false) {
+            return null;
+        }
+        PolarisProperties.Oss oss = polarisProperties.getOss();
+        if (hasValidConfig(oss) == false) {
+            LOGGER.warn("Skip storing task result to OSS because OSS config is incomplete. taskInstanceId={}", taskInstanceId);
+            return null;
+        }
+
+        String ext = normalizeExtension(extension);
+        LocalDate now = LocalDate.now();
+        String key = String.format(
+                "task-results/%04d/%02d/task-%d-%s.%s",
+                now.getYear(),
+                now.getMonthValue(),
+                taskInstanceId,
+                UUID.randomUUID().toString().replace("-", ""),
+                ext);
+
+        Path tempFile = null;
+        S3Client s3Client = null;
+        try {
+            tempFile = Files.createTempFile("wenshu-task-result-", ".parquet");
+            // AvroParquetWriter requires target path not to exist.
+            Files.deleteIfExists(tempFile);
+            writeParquet(tempFile, content);
+
+            s3Client = buildS3Client(oss);
+            PutObjectRequest request = PutObjectRequest.builder()
+                    .bucket(oss.getBucket().trim())
+                    .key(key)
+                    .contentType("application/octet-stream")
+                    .build();
+            s3Client.putObject(request, RequestBody.fromFile(tempFile));
+            return RESULT_REF_PREFIX + oss.getBucket().trim() + "/" + key;
+        } catch (Exception ex) {
+            LOGGER.warn("Store task result to OSS failed. taskInstanceId={}, error={}", taskInstanceId, ex.getMessage());
+            return null;
+        } finally {
+            closeQuietly(s3Client);
+            deleteTempFileQuietly(tempFile);
+        }
+    }
+
+    public String loadText(String resultRef) {
+        if (StringUtils.hasText(resultRef) == false) {
+            return null;
+        }
+        PolarisProperties.Oss oss = polarisProperties.getOss();
+        if (hasValidConfig(oss) == false) {
+            return null;
+        }
+
+        OssRef ref = parseOssRef(resultRef.trim(), oss.getBucket());
+        if (ref == null || StringUtils.hasText(ref.bucket()) == false || StringUtils.hasText(ref.key()) == false) {
+            return null;
+        }
+
+        Path tempFile = null;
+        S3Client s3Client = null;
+        try {
+            tempFile = Files.createTempFile("wenshu-task-result-load-", ".parquet");
+            // ResponseTransformer.toFile requires destination path not to exist.
+            Files.deleteIfExists(tempFile);
+            s3Client = buildS3Client(oss);
+            GetObjectRequest request = GetObjectRequest.builder()
+                    .bucket(ref.bucket())
+                    .key(ref.key())
+                    .build();
+            s3Client.getObject(request, ResponseTransformer.toFile(tempFile));
+            return readParquet(tempFile);
+        } catch (Exception ex) {
+            LOGGER.warn("Load task result from OSS failed. resultRef={}, error={}", resultRef, ex.getMessage());
+            return null;
+        } finally {
+            closeQuietly(s3Client);
+            deleteTempFileQuietly(tempFile);
+        }
+    }
+
+    private void writeParquet(Path parquetFile, String content) throws IOException {
+        Configuration conf = new Configuration(false);
+        org.apache.hadoop.fs.Path hadoopPath = new org.apache.hadoop.fs.Path(parquetFile.toUri());
+        try (ParquetWriter<GenericRecord> writer = AvroParquetWriter.<GenericRecord>builder(hadoopPath)
+                .withSchema(RESULT_SCHEMA)
+                .withDataModel(GenericData.get())
+                .withConf(conf)
+                .withCompressionCodec(CompressionCodecName.SNAPPY)
+                .build()) {
+            GenericRecord record = new GenericData.Record(RESULT_SCHEMA);
+            record.put("result", content);
+            record.put("updated_at", LocalDateTime.now().toString());
+            writer.write(record);
+        }
+    }
+
+    private String readParquet(Path parquetFile) throws IOException {
+        Configuration conf = new Configuration(false);
+        org.apache.hadoop.fs.Path hadoopPath = new org.apache.hadoop.fs.Path(parquetFile.toUri());
+        StringBuilder builder = new StringBuilder();
+        try (ParquetReader<GenericRecord> reader = AvroParquetReader.<GenericRecord>builder(hadoopPath)
+                .withConf(conf)
+                .build()) {
+            for (; ; ) {
+                GenericRecord record = reader.read();
+                if (record == null) {
+                    break;
+                }
+                Object result = record.get("result");
+                if (result == null) {
+                    continue;
+                }
+                if (builder.length() > 0) {
+                    builder.append('\n');
+                }
+                builder.append(result.toString());
+            }
+        }
+        if (builder.length() == 0) {
+            return null;
+        }
+        return builder.toString();
+    }
+
+    private S3Client buildS3Client(PolarisProperties.Oss oss) {
+        String endpoint = trimTrailingSlash(oss.getEndpoint());
+        String region = StringUtils.hasText(oss.getRegion()) ? oss.getRegion().trim() : "cn-hangzhou";
+        return S3Client.builder()
+                .endpointOverride(URI.create(endpoint))
+                .region(Region.of(region))
+                .credentialsProvider(StaticCredentialsProvider.create(
+                        AwsBasicCredentials.create(
+                                oss.getAccessKeyId().trim(),
+                                oss.getAccessKeySecret().trim())))
+                .serviceConfiguration(S3Configuration.builder()
+                        // OSS S3 compatibility requires virtual-hosted-style access.
+                        .pathStyleAccessEnabled(false)
+                        .build())
+                .build();
+    }
+
+    private boolean hasValidConfig(PolarisProperties.Oss oss) {
+        if (oss == null) {
+            return false;
+        }
+        return StringUtils.hasText(oss.getEndpoint())
+                && StringUtils.hasText(oss.getAccessKeyId())
+                && StringUtils.hasText(oss.getAccessKeySecret())
+                && StringUtils.hasText(oss.getBucket());
+    }
+
+    private String normalizeExtension(String extension) {
+        if (StringUtils.hasText(extension) == false) {
+            return DEFAULT_EXTENSION;
+        }
+        String ext = extension.trim();
+        if (ext.startsWith(".")) {
+            ext = ext.substring(1);
+        }
+        if (StringUtils.hasText(ext) == false) {
+            return DEFAULT_EXTENSION;
+        }
+        return ext;
+    }
+
+    private String trimTrailingSlash(String value) {
+        if (StringUtils.hasText(value) == false) {
+            return "";
+        }
+        String normalized = value.trim();
+        while (normalized.endsWith("/")) {
+            normalized = normalized.substring(0, normalized.length() - 1);
+        }
+        return normalized;
+    }
+
+    private OssRef parseOssRef(String ref, String defaultBucket) {
+        if (StringUtils.hasText(ref) == false) {
+            return null;
+        }
+        if (ref.startsWith(RESULT_REF_PREFIX) || ref.startsWith("s3://")) {
+            int schemaLength = ref.startsWith(RESULT_REF_PREFIX) ? RESULT_REF_PREFIX.length() : "s3://".length();
+            String remaining = ref.substring(schemaLength);
+            int slashIndex = remaining.indexOf('/');
+            if (slashIndex < 1 || slashIndex >= remaining.length() - 1) {
+                return null;
+            }
+            String bucket = remaining.substring(0, slashIndex).trim();
+            String key = remaining.substring(slashIndex + 1).trim();
+            return new OssRef(bucket, key);
+        }
+        if (ref.contains("://")) {
+            return null;
+        }
+        if (StringUtils.hasText(defaultBucket) == false) {
+            return null;
+        }
+        return new OssRef(defaultBucket.trim(), ref);
+    }
+
+    private void closeQuietly(S3Client client) {
+        if (client == null) {
+            return;
+        }
+        try {
+            client.close();
+        } catch (Exception ignored) {
+            // ignore close errors
+        }
+    }
+
+    private void deleteTempFileQuietly(Path tempFile) {
+        if (tempFile == null) {
+            return;
+        }
+        try {
+            Files.deleteIfExists(tempFile);
+        } catch (IOException ignored) {
+            // ignore delete errors
+        }
+    }
+
+    private record OssRef(String bucket, String key) {
+    }
+}

+ 75 - 0
backend/src/main/java/com/wenshu/platform/service/taskexec/WorkflowService.java

@@ -36,6 +36,7 @@ import com.wenshu.platform.model.enums.WorkflowFailureStrategy;
 import com.wenshu.platform.model.enums.WorkflowInstanceState;
 import com.wenshu.platform.model.query.WorkflowInstanceQuery;
 import com.wenshu.platform.model.resp.PageResp;
+import com.wenshu.platform.model.resp.TaskResultDetailResp;
 import com.wenshu.platform.model.resp.WorkflowInstanceListResp;
 import com.wenshu.platform.model.resp.WorkflowStatusResp;
 import com.wenshu.platform.model.resp.WorkflowSubmitResp;
@@ -66,6 +67,8 @@ public class WorkflowService {
     private final TaskInstanceDAO taskInstanceDAO;
     private final ClusterDAO clusterDAO;
     private final SchedulerService schedulerService;
+    private final TaskResultPersistenceService taskResultPersistenceService;
+    private final TaskResultStorageService taskResultStorageService;
     private final ObjectMapper objectMapper;
 
     public PageResp<WorkflowInstanceListResp> listWorkflowInstances(WorkflowInstanceQuery query) {
@@ -177,6 +180,54 @@ public class WorkflowService {
         return WorkflowExecutionConverter.toStatusResp(workflowInstance, taskInstances, taskDefinitionById, workflowDefinition);
     }
 
+    public TaskResultDetailResp getTaskResult(Long workflowInstanceId, Long taskInstanceId) {
+        if (workflowInstanceId == null) {
+            throw new IllegalArgumentException("workflowInstanceId cannot be null");
+        }
+        if (taskInstanceId == null) {
+            throw new IllegalArgumentException("taskInstanceId cannot be null");
+        }
+
+        TaskInstanceDO taskInstance = taskInstanceDAO.findById(taskInstanceId);
+        if (taskInstance == null
+                || workflowInstanceId.equals(taskInstance.getWorkflowInstanceId()) == false) {
+            throw new IllegalArgumentException("task instance does not exist under workflow instance");
+        }
+
+        TaskInstanceState taskState = parseTaskInstanceStateSafely(taskInstance.getState());
+        if (taskState == null) {
+            // ignore invalid state for result lookup
+        } else if (taskState.isTerminal() && hasTaskResult(taskInstance) == false) {
+            taskResultPersistenceService.persistTaskResult(taskInstanceId);
+            TaskInstanceDO latest = taskInstanceDAO.findById(taskInstanceId);
+            if (latest == null) {
+                // keep previous snapshot
+            } else {
+                taskInstance = latest;
+            }
+        }
+
+        TaskResultDetailResp resp = new TaskResultDetailResp();
+        resp.setTaskInstanceId(taskInstanceId);
+        resp.setResultFormat(taskInstance.getResultFormat());
+        resp.setResultPreview(taskInstance.getResultPreview());
+        resp.setResultRef(taskInstance.getResultRef());
+        resp.setResultTruncated(taskInstance.getResultTruncated());
+        resp.setResultSizeBytes(taskInstance.getResultSizeBytes());
+        resp.setResultUpdatedAt(taskInstance.getResultUpdatedAt());
+
+        String fullResult = null;
+        if (isLoadableResultRef(taskInstance.getResultRef())) {
+            fullResult = taskResultStorageService.loadText(taskInstance.getResultRef());
+        }
+        if (StringUtils.hasText(fullResult) == false && StringUtils.hasText(taskInstance.getResultPreview())) {
+            fullResult = taskInstance.getResultPreview();
+        }
+        resp.setResultContent(fullResult);
+
+        return resp;
+    }
+
     public void terminateWorkflow(Long workflowInstanceId) {
         if (workflowInstanceId == null) {
             throw new IllegalArgumentException("workflowInstanceId cannot be null");
@@ -221,6 +272,30 @@ public class WorkflowService {
         schedulerService.clearExecutionHandles(workflowInstanceId);
     }
 
+    private boolean hasTaskResult(TaskInstanceDO taskInstance) {
+        if (taskInstance == null) {
+            return false;
+        }
+        return StringUtils.hasText(taskInstance.getResultPreview())
+                || StringUtils.hasText(taskInstance.getResultRef());
+    }
+
+    private TaskInstanceState parseTaskInstanceStateSafely(String state) {
+        try {
+            return TaskInstanceState.fromCode(state);
+        } catch (IllegalArgumentException ex) {
+            return null;
+        }
+    }
+
+    private boolean isLoadableResultRef(String resultRef) {
+        if (StringUtils.hasText(resultRef) == false) {
+            return false;
+        }
+        String trimmed = resultRef.trim();
+        return trimmed.startsWith("oss://") || trimmed.startsWith("s3://") || !trimmed.contains("://");
+    }
+
     private DagGraph parseDag(String dagJson, Set<Long> availableTaskIds) {
         Set<Long> dagTaskIds = parseDagTaskIds(dagJson);
         if (dagTaskIds.isEmpty()) {

+ 56 - 24
backend/src/main/resources/application.yml

@@ -1,11 +1,11 @@
 server:
-  port: 8080
+  port: ${SERVER_PORT:8080}
 
 spring:
   datasource:
-    url: jdbc:mysql://rm-bp19s1frlk2s1m5q8go.mysql.rds.aliyuncs.com:3306/wenshu_platform?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
-    username: cyrus
-    password: Wenshu_mysql
+    url: ${SPRING_DATASOURCE_URL:jdbc:mysql://${MYSQL_URL:localhost:3306}/${MYSQL_DATABASE:wenshu_platform}?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true}
+    username: ${SPRING_DATASOURCE_USERNAME:${MYSQL_USER:wenshu}}
+    password: ${SPRING_DATASOURCE_PASSWORD:${MYSQL_PASSWORD:wenshu_db_pass}}
     driver-class-name: com.mysql.cj.jdbc.Driver
   sql:
     init:
@@ -45,33 +45,65 @@ logging:
 
 stock:
   ssh:
-    crypto-key: change-me-stock-ssh-key
+    crypto-key: ${STOCK_SSH_CRYPTO_KEY:change-me-stock-ssh-key-32chars}
 
 polaris:
-  host: http://47.236.49.51:8181
-  realm: POLARIS
-  client-id: root
-  client-secret: s3cr3t
-  default-catalog: demo_catalog
+  host: ${POLARIS_HOST:http://polaris:8181}
+  realm: ${POLARIS_REALM:POLARIS}
+  client-id: ${POLARIS_CLIENT_ID:root}
+  client-secret: ${POLARIS_CLIENT_SECRET:s3cr3t}
+  default-catalog: ${POLARIS_DEFAULT_CATALOG:demo_catalog}
   oss:
-    endpoint: https://oss-cn-hangzhou.aliyuncs.com
-    access-key-id: LTAI5t8iN3wJivuUFWKJrEzQ
-    access-key-secret: DXBfI4GMDRE8bWRTxVauRuVPebN34m
-    region: cn-hangzhou
-    bucket: polaris-buck
-  cache-ttl-minutes: 30
+    endpoint: ${OSS_ENDPOINT:https://oss-cn-hangzhou.aliyuncs.com}
+    access-key-id: ${OSS_AK:}
+    access-key-secret: ${OSS_SK:}
+    region: ${OSS_REGION:cn-hangzhou}
+    bucket: ${OSS_BUCKET:}
+  cache-ttl-minutes: ${POLARIS_CACHE_TTL_MINUTES:30}
 
 iceberg-service:
-  url: http://localhost:8090
+  url: ${ICEBERG_SERVICE_URL:http://python-iceberg:8090}
+
+k8s:
+  orchestrator:
+    base-url: ${K8S_ORCHESTRATOR_BASE_URL:http://host.docker.internal:18080}
+    timeout-ms: ${K8S_ORCHESTRATOR_TIMEOUT_MS:5000}
+    wait-timeout-ms: ${K8S_ORCHESTRATOR_WAIT_TIMEOUT_MS:300000}
+    poll-interval-ms: ${K8S_ORCHESTRATOR_POLL_INTERVAL_MS:2000}
+    reconcile-create-delay-ms: ${K8S_ORCHESTRATOR_RECONCILE_CREATE_DELAY_MS:5000}
+    reconcile-batch-size: ${K8S_ORCHESTRATOR_RECONCILE_BATCH_SIZE:100}
+
+spark:
+  sql-runner:
+    type: ${SPARK_SQL_RUNNER_TYPE:Python}
+    mode: ${SPARK_SQL_RUNNER_MODE:cluster}
+    spark-version: ${SPARK_SQL_RUNNER_SPARK_VERSION:3.5.1}
+    python-version: ${SPARK_SQL_RUNNER_PYTHON_VERSION:3}
+    main-class: ${SPARK_SQL_RUNNER_MAIN_CLASS:org.apache.spark.sql.hive.thriftserver.SparkSQLCLIDriver}
+    main-application-file: ${SPARK_SQL_RUNNER_MAIN_APPLICATION_FILE:local:///opt/spark/wenshu/spark_sql_runner.py}
+    polaris-catalog-alias: ${SPARK_SQL_RUNNER_POLARIS_CATALOG_ALIAS:polaris}
+    iceberg-runtime-package: ${SPARK_SQL_RUNNER_ICEBERG_RUNTIME_PACKAGE:org.apache.iceberg:iceberg-spark-runtime-3.5_2.12:1.10.0}
+    iceberg-aws-bundle-package: ${SPARK_SQL_RUNNER_ICEBERG_AWS_BUNDLE_PACKAGE:org.apache.iceberg:iceberg-aws-bundle:1.10.0}
+    polaris-scope: ${SPARK_SQL_RUNNER_POLARIS_SCOPE:PRINCIPAL_ROLE:ALL}
+    default-driver-cores: ${SPARK_SQL_RUNNER_DEFAULT_DRIVER_CORES:1}
+    default-driver-memory: ${SPARK_SQL_RUNNER_DEFAULT_DRIVER_MEMORY:2g}
+    default-executor-instances: ${SPARK_SQL_RUNNER_DEFAULT_EXECUTOR_INSTANCES:2}
+    default-executor-cores: ${SPARK_SQL_RUNNER_DEFAULT_EXECUTOR_CORES:2}
+    default-executor-memory: ${SPARK_SQL_RUNNER_DEFAULT_EXECUTOR_MEMORY:4g}
 
 rag:
   embedding:
-    dimension: 768
-    local-url: http://47.236.49.51:7997/v1
-    local-model: intfloat/multilingual-e5-base
+    dimension: ${RAG_EMBEDDING_DIMENSION:768}
+    local-url: ${RAG_EMBEDDING_URL:http://tei:80/v1}
+    local-model: ${RAG_EMBEDDING_MODEL:intfloat/multilingual-e5-base}
   milvus:
-    uri: http://47.236.49.51:19530
-    collection: knowledge_vectors
+    uri: ${RAG_MILVUS_URI:http://milvus-standalone:19530}
+    collection: ${RAG_MILVUS_COLLECTION:knowledge_vectors}
   retrieval:
-    top-k: 8
-    score-threshold: 0.3
+    top-k: ${RAG_RETRIEVAL_TOP_K:8}
+    score-threshold: ${RAG_RETRIEVAL_SCORE_THRESHOLD:0.3}
+
+cluster:
+  supported-versions:
+    STARROCKS:
+      - "3.5-latest"

+ 10 - 4
backend/src/main/resources/cluster-default-config/spark.json

@@ -4,9 +4,15 @@
     "3.4.3"
   ],
   "defaultConfig": {
-    "executorMemory": "4g",
-    "executorCores": 2,
-    "driverMemory": "2g",
-    "shufflePartitions": 200
+    "spark.driver.memory": "2g",
+    "spark.driver.cores": 1,
+    "spark.executor.instances": 2,
+    "spark.executor.memory": "4g",
+    "spark.executor.cores": 2,
+    "spark.sql.shuffle.partitions": 200,
+    "spark.sql.adaptive.enabled": true,
+    "spark.dynamicAllocation.enabled": false,
+    "spark.default.parallelism": 200,
+    "spark.sql.autoBroadcastJoinThreshold": "10MB"
   }
 }

+ 15 - 0
backend/src/main/resources/db/schema.sql

@@ -112,10 +112,25 @@ CREATE TABLE IF NOT EXISTS task_instance (
   executor_id BIGINT,
   retry_count INT,
   error_message TEXT,
+  engine_task_id VARCHAR(128),
+  result_preview MEDIUMTEXT,
+  result_format VARCHAR(32),
+  result_ref VARCHAR(512),
+  result_truncated TINYINT,
+  result_size_bytes BIGINT,
+  result_updated_at DATETIME,
   KEY idx_task_instance_workflow_instance_id (workflow_instance_id),
   KEY idx_task_instance_task_id (task_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
 
+ALTER TABLE task_instance ADD COLUMN IF NOT EXISTS engine_task_id VARCHAR(128);
+ALTER TABLE task_instance ADD COLUMN IF NOT EXISTS result_preview MEDIUMTEXT;
+ALTER TABLE task_instance ADD COLUMN IF NOT EXISTS result_format VARCHAR(32);
+ALTER TABLE task_instance ADD COLUMN IF NOT EXISTS result_ref VARCHAR(512);
+ALTER TABLE task_instance ADD COLUMN IF NOT EXISTS result_truncated TINYINT;
+ALTER TABLE task_instance ADD COLUMN IF NOT EXISTS result_size_bytes BIGINT;
+ALTER TABLE task_instance ADD COLUMN IF NOT EXISTS result_updated_at DATETIME;
+
 CREATE TABLE IF NOT EXISTS config_version (
   config_id BIGINT AUTO_INCREMENT PRIMARY KEY,
   cluster_id BIGINT,

+ 25 - 2
backend/src/main/resources/mapper/resource/ClusterDAO.xml

@@ -67,7 +67,10 @@
             <if test="query.status != null and query.status != ''">
                 AND c.status = #{query.status}
             </if>
-            <if test="query.includeDeleted == null or query.includeDeleted == false">
+            <if test="(query.includeDeleted == null or query.includeDeleted == false) and (query.status == null or query.status == '')">
+                AND c.status NOT IN ('DELETED', 'STOPPING')
+            </if>
+            <if test="(query.includeDeleted == null or query.includeDeleted == false) and (query.status != null and query.status != '')">
                 AND c.status != 'DELETED'
             </if>
         </where>
@@ -101,9 +104,29 @@
             <if test="query.status != null and query.status != ''">
                 AND c.status = #{query.status}
             </if>
-            <if test="query.includeDeleted == null or query.includeDeleted == false">
+            <if test="(query.includeDeleted == null or query.includeDeleted == false) and (query.status == null or query.status == '')">
+                AND c.status NOT IN ('DELETED', 'STOPPING')
+            </if>
+            <if test="(query.includeDeleted == null or query.includeDeleted == false) and (query.status != null and query.status != '')">
                 AND c.status != 'DELETED'
             </if>
         </where>
     </select>
+
+    <select id="findByStatusAndComponentTypes" resultType="com.wenshu.platform.model.dataobject.ClusterDO">
+        <include refid="base_select"/>
+        WHERE c.status = #{status}
+          AND c.component_type IN
+          <foreach item="componentType" collection="componentTypes" open="(" separator="," close=")">
+              #{componentType}
+          </foreach>
+        GROUP BY c.cluster_id, c.cluster_name, c.description, c.component_type, c.version, c.status, c.created_time
+        ORDER BY c.cluster_id ASC
+        LIMIT #{limit}
+    </select>
+
+    <delete id="deleteById">
+        DELETE FROM cluster
+        WHERE cluster_id = #{clusterId}
+    </delete>
 </mapper>

+ 6 - 0
backend/src/main/resources/mapper/resource/ClusterNodeDAO.xml

@@ -116,4 +116,10 @@
         FROM cluster_node
         WHERE machine_id = #{machineId}
     </select>
+
+    <update id="updateStatusByClusterId">
+        UPDATE cluster_node
+        SET status = #{status}
+        WHERE cluster_id = #{clusterId}
+    </update>
 </mapper>

+ 5 - 0
backend/src/main/resources/mapper/resource/ConfigVersionDAO.xml

@@ -67,4 +67,9 @@
         WHERE cluster_id = #{clusterId}
         ORDER BY version_no DESC
     </select>
+
+    <delete id="deleteByClusterId">
+        DELETE FROM config_version
+        WHERE cluster_id = #{clusterId}
+    </delete>
 </mapper>

+ 53 - 4
backend/src/main/resources/mapper/taskexec/TaskInstanceDAO.xml

@@ -13,7 +13,14 @@
             end_time,
             executor_id,
             retry_count,
-            error_message
+            error_message,
+            engine_task_id,
+            result_preview,
+            result_format,
+            result_ref,
+            result_truncated,
+            result_size_bytes,
+            result_updated_at
         ) VALUES (
             #{taskInstance.workflowInstanceId},
             #{taskInstance.taskId},
@@ -22,7 +29,14 @@
             #{taskInstance.endTime},
             #{taskInstance.executorId},
             #{taskInstance.retryCount},
-            #{taskInstance.errorMessage}
+            #{taskInstance.errorMessage},
+            #{taskInstance.engineTaskId},
+            #{taskInstance.resultPreview},
+            #{taskInstance.resultFormat},
+            #{taskInstance.resultRef},
+            #{taskInstance.resultTruncated},
+            #{taskInstance.resultSizeBytes},
+            #{taskInstance.resultUpdatedAt}
         )
     </insert>
 
@@ -53,6 +67,27 @@
             <if test="taskInstance.errorMessage != null">
                 error_message = #{taskInstance.errorMessage},
             </if>
+            <if test="taskInstance.engineTaskId != null">
+                engine_task_id = #{taskInstance.engineTaskId},
+            </if>
+            <if test="taskInstance.resultPreview != null">
+                result_preview = #{taskInstance.resultPreview},
+            </if>
+            <if test="taskInstance.resultFormat != null">
+                result_format = #{taskInstance.resultFormat},
+            </if>
+            <if test="taskInstance.resultRef != null">
+                result_ref = #{taskInstance.resultRef},
+            </if>
+            <if test="taskInstance.resultTruncated != null">
+                result_truncated = #{taskInstance.resultTruncated},
+            </if>
+            <if test="taskInstance.resultSizeBytes != null">
+                result_size_bytes = #{taskInstance.resultSizeBytes},
+            </if>
+            <if test="taskInstance.resultUpdatedAt != null">
+                result_updated_at = #{taskInstance.resultUpdatedAt},
+            </if>
         </set>
         WHERE task_instance_id = #{taskInstance.taskInstanceId}
     </update>
@@ -67,7 +102,14 @@
             end_time,
             executor_id,
             retry_count,
-            error_message
+            error_message,
+            engine_task_id,
+            result_preview,
+            result_format,
+            result_ref,
+            result_truncated,
+            result_size_bytes,
+            result_updated_at
         FROM task_instance
         WHERE task_instance_id = #{taskInstanceId}
     </select>
@@ -82,7 +124,14 @@
             end_time,
             executor_id,
             retry_count,
-            error_message
+            error_message,
+            engine_task_id,
+            result_preview,
+            result_format,
+            result_ref,
+            result_truncated,
+            result_size_bytes,
+            result_updated_at
         FROM task_instance
         WHERE workflow_instance_id = #{workflowInstanceId}
         ORDER BY task_instance_id ASC

+ 132 - 0
backend/src/test/java/com/wenshu/platform/service/resource/ClusterCreateStatusReconcilerTest.java

@@ -0,0 +1,132 @@
+package com.wenshu.platform.service.resource;
+
+import java.util.List;
+
+import com.wenshu.platform.config.K8sOrchestratorProperties;
+import com.wenshu.platform.dao.ClusterDAO;
+import com.wenshu.platform.dao.ClusterNodeDAO;
+import com.wenshu.platform.dao.ConfigVersionDAO;
+import com.wenshu.platform.dao.StockDAO;
+import com.wenshu.platform.model.dataobject.ClusterDO;
+import com.wenshu.platform.model.enums.ClusterStatus;
+import com.wenshu.platform.service.resource.k8s.K8sOrchestratorClient;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class ClusterCreateStatusReconcilerTest {
+
+    @Mock
+    private ClusterDAO clusterDAO;
+    @Mock
+    private ClusterNodeDAO clusterNodeDAO;
+    @Mock
+    private ConfigVersionDAO configVersionDAO;
+    @Mock
+    private StockDAO stockDAO;
+    @Mock
+    private K8sOrchestratorClient k8sOrchestratorClient;
+    @Mock
+    private K8sOrchestratorProperties properties;
+
+    @InjectMocks
+    private ClusterCreateStatusReconciler reconciler;
+
+    @Test
+    void reconcileCreatingClusters_MoveToRunningWhenReady() {
+        ClusterDO cluster = new ClusterDO();
+        cluster.setClusterId(1L);
+        cluster.setComponentType("SPARK");
+        cluster.setStatus(ClusterStatus.CREATING.getCode());
+
+        when(properties.getReconcileBatchSize()).thenReturn(100);
+        when(clusterDAO.findByStatusAndComponentTypes(ClusterStatus.CREATING.getCode(), List.of("SPARK", "STARROCKS"), 100))
+                .thenReturn(List.of(cluster));
+        when(k8sOrchestratorClient.getSparkClusterPhase("spark-1")).thenReturn("READY");
+        when(clusterDAO.updateStatusByExpected(1L, ClusterStatus.CREATING.getCode(), ClusterStatus.RUNNING.getCode()))
+                .thenReturn(1);
+
+        reconciler.reconcileCreatingClusters();
+
+        verify(clusterDAO).updateStatusByExpected(1L, ClusterStatus.CREATING.getCode(), ClusterStatus.RUNNING.getCode());
+        verify(clusterNodeDAO).updateStatusByClusterId(1L, ClusterStatus.RUNNING.getCode());
+    }
+
+    @Test
+    void reconcileCreatingClusters_KeepCreatingWhenFailed() {
+        ClusterDO cluster = new ClusterDO();
+        cluster.setClusterId(2L);
+        cluster.setComponentType("STARROCKS");
+        cluster.setStatus(ClusterStatus.CREATING.getCode());
+
+        when(properties.getReconcileBatchSize()).thenReturn(100);
+        when(clusterDAO.findByStatusAndComponentTypes(ClusterStatus.CREATING.getCode(), List.of("SPARK", "STARROCKS"), 100))
+                .thenReturn(List.of(cluster));
+        when(k8sOrchestratorClient.getStarRocksClusterPhase("starrocks-2")).thenReturn("FAILED");
+
+        reconciler.reconcileCreatingClusters();
+
+        verify(clusterDAO, never()).updateStatusByExpected(
+                2L, ClusterStatus.CREATING.getCode(), ClusterStatus.RUNNING.getCode());
+        verify(clusterNodeDAO, never()).updateStatusByClusterId(2L, ClusterStatus.RUNNING.getCode());
+    }
+
+    @Test
+    void reconcileStoppingClusters_MoveToDeletedWhenReleased() {
+        ClusterDO cluster = new ClusterDO();
+        cluster.setClusterId(3L);
+        cluster.setComponentType("SPARK");
+        cluster.setStatus(ClusterStatus.STOPPING.getCode());
+
+        when(properties.getReconcileBatchSize()).thenReturn(100);
+        when(clusterDAO.findByStatusAndComponentTypes(ClusterStatus.CREATING.getCode(), List.of("SPARK", "STARROCKS"), 100))
+                .thenReturn(List.of());
+        when(clusterDAO.findByStatusAndComponentTypes(ClusterStatus.STOPPING.getCode(), List.of("SPARK", "STARROCKS"), 100))
+                .thenReturn(List.of(cluster));
+        when(k8sOrchestratorClient.getSparkClusterPhase("spark-3")).thenReturn("RELEASED");
+        when(clusterDAO.updateStatusByExpected(3L, ClusterStatus.STOPPING.getCode(), ClusterStatus.DELETED.getCode()))
+                .thenReturn(1);
+        when(clusterNodeDAO.findMachineIdsByClusterId(3L)).thenReturn(List.of(100L, 101L));
+        when(clusterDAO.deleteById(3L)).thenReturn(1);
+
+        reconciler.reconcileCreatingClusters();
+
+        verify(clusterDAO).updateStatusByExpected(3L, ClusterStatus.STOPPING.getCode(), ClusterStatus.DELETED.getCode());
+        verify(stockDAO).updateStatusByIdAndStatus(100L, "WORKING", "IDLE");
+        verify(stockDAO).updateStatusByIdAndStatus(101L, "WORKING", "IDLE");
+        verify(configVersionDAO).deleteByClusterId(3L);
+        verify(clusterNodeDAO).deleteByClusterId(3L);
+        verify(clusterDAO).deleteById(3L);
+    }
+
+    @Test
+    void reconcileScalingClusters_MoveToRunningWhenReady() {
+        ClusterDO cluster = new ClusterDO();
+        cluster.setClusterId(4L);
+        cluster.setComponentType("STARROCKS");
+        cluster.setStatus(ClusterStatus.SCALING.getCode());
+
+        when(properties.getReconcileBatchSize()).thenReturn(100);
+        when(clusterDAO.findByStatusAndComponentTypes(ClusterStatus.CREATING.getCode(), List.of("SPARK", "STARROCKS"), 100))
+                .thenReturn(List.of());
+        when(clusterDAO.findByStatusAndComponentTypes(ClusterStatus.STOPPING.getCode(), List.of("SPARK", "STARROCKS"), 100))
+                .thenReturn(List.of());
+        when(clusterDAO.findByStatusAndComponentTypes(ClusterStatus.SCALING.getCode(), List.of("SPARK", "STARROCKS"), 100))
+                .thenReturn(List.of(cluster));
+        when(k8sOrchestratorClient.getStarRocksClusterPhase("starrocks-4")).thenReturn("READY");
+        when(clusterDAO.updateStatusByExpected(4L, ClusterStatus.SCALING.getCode(), ClusterStatus.RUNNING.getCode()))
+                .thenReturn(1);
+
+        reconciler.reconcileCreatingClusters();
+
+        verify(clusterDAO).updateStatusByExpected(4L, ClusterStatus.SCALING.getCode(), ClusterStatus.RUNNING.getCode());
+        verify(clusterNodeDAO).updateStatusByClusterId(4L, ClusterStatus.RUNNING.getCode());
+    }
+}

+ 133 - 3
backend/src/test/java/com/wenshu/platform/service/resource/ClusterServiceTest.java

@@ -21,6 +21,7 @@ import com.wenshu.platform.model.query.ClusterQuery;
 import com.wenshu.platform.model.resp.ClusterResp;
 import com.wenshu.platform.model.resp.PageResp;
 import com.wenshu.platform.service.auth.ForbiddenException;
+import com.wenshu.platform.service.resource.k8s.K8sOrchestratorClient;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
@@ -31,6 +32,9 @@ import org.springframework.security.core.Authentication;
 import org.springframework.security.core.authority.SimpleGrantedAuthority;
 import org.springframework.security.core.context.SecurityContext;
 import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.transaction.TransactionStatus;
+import org.springframework.transaction.support.TransactionCallback;
+import org.springframework.transaction.support.TransactionTemplate;
 
 @ExtendWith(MockitoExtension.class)
 class ClusterServiceTest {
@@ -45,6 +49,10 @@ class ClusterServiceTest {
     private StockDAO stockDAO;
     @Mock
     private ClusterDefaultConfigProvider clusterDefaultConfigProvider;
+    @Mock
+    private K8sOrchestratorClient k8sOrchestratorClient;
+    @Mock
+    private TransactionTemplate transactionTemplate;
 
     @InjectMocks
     private ClusterService clusterService;
@@ -60,6 +68,10 @@ class ClusterServiceTest {
         SecurityContextHolder.setContext(securityContext);
         lenient().doReturn(Collections.singletonList(new SimpleGrantedAuthority("ROLE_OPS")))
             .when(authentication).getAuthorities();
+        lenient().when(transactionTemplate.execute(any())).thenAnswer(invocation -> {
+            TransactionCallback<?> callback = invocation.getArgument(0);
+            return callback.doInTransaction(mock(TransactionStatus.class));
+        });
     }
 
     @Test
@@ -111,6 +123,9 @@ class ClusterServiceTest {
         StockInfoDO machine = new StockInfoDO();
         machine.setMachineId(101L);
         machine.setStatus(StockMachineStatus.IDLE.getCode());
+        machine.setCpuCores(8);
+        machine.setRamGb(16);
+        machine.setSsdGb(256);
 
         when(stockDAO.findById(101L)).thenReturn(machine);
         when(stockDAO.updateStatusByIdAndStatus(101L, "IDLE", "WORKING")).thenReturn(1);
@@ -127,16 +142,99 @@ class ClusterServiceTest {
         creatingCluster.setClusterId(1L);
         creatingCluster.setStatus(ClusterStatus.CREATING.getCode());
         creatingCluster.setClusterName("new-cluster");
+        creatingCluster.setComponentType("SPARK");
 
-        when(clusterDAO.findById(1L)).thenReturn(creatingCluster);
-        when(clusterDAO.updateStatusByExpected(eq(1L), eq(ClusterStatus.CREATING.getCode()), eq(ClusterStatus.RUNNING.getCode()))).thenReturn(1);
+        when(clusterDAO.findById(1L))
+                .thenReturn(creatingCluster)
+                .thenReturn(creatingCluster);
+        when(k8sOrchestratorClient.createSparkCluster(eq("spark-1"), any())).thenReturn("op-1");
 
         ClusterResp resp = clusterService.createCluster(createBO);
 
         assertNotNull(resp);
+        assertEquals(ClusterStatus.CREATING.getCode(), resp.getStatus());
         verify(clusterDAO).save(any(ClusterDO.class));
         verify(clusterNodeDAO).save(any());
         verify(configVersionDAO).save(any());
+        verify(k8sOrchestratorClient).createSparkCluster(eq("spark-1"), any());
+    }
+
+    @Test
+    void createCluster_StarRocksFrontendCountMustBeOdd() {
+        ClusterCreateBO createBO = new ClusterCreateBO();
+        ClusterDO inputCluster = new ClusterDO();
+        inputCluster.setClusterName("sr-cluster");
+        inputCluster.setComponentType("STARROCKS");
+        inputCluster.setVersion("3.1.0");
+        createBO.setCluster(inputCluster);
+        createBO.setFrontendNodeCount(2);
+        createBO.setNodeMachineIds(List.of(101L, 102L, 103L, 104L));
+
+        when(clusterDefaultConfigProvider.getSupportedVersions("STARROCKS")).thenReturn(List.of("3.1.0"));
+
+        IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> clusterService.createCluster(createBO));
+
+        assertTrue(ex.getMessage().contains("odd number"));
+        verify(k8sOrchestratorClient, never()).createStarRocksCluster(anyString(), anyList(), anyList(), anyString());
+    }
+
+    @Test
+    void createCluster_StarRocksFrontendAndBackendSplitByFrontendNodeCount() {
+        ClusterCreateBO createBO = new ClusterCreateBO();
+        ClusterDO inputCluster = new ClusterDO();
+        inputCluster.setClusterName("sr-cluster");
+        inputCluster.setComponentType("STARROCKS");
+        inputCluster.setVersion("3.1.0");
+        createBO.setCluster(inputCluster);
+        createBO.setFrontendNodeCount(3);
+        createBO.setNodeMachineIds(List.of(101L, 102L, 103L, 104L, 105L));
+
+        when(clusterDefaultConfigProvider.getSupportedVersions("STARROCKS")).thenReturn(List.of("3.1.0"));
+        when(clusterDefaultConfigProvider.getDefaultConfigContent("STARROCKS")).thenReturn("{}");
+
+        for (long machineId = 101L; machineId <= 105L; machineId++) {
+            StockInfoDO machine = new StockInfoDO();
+            machine.setMachineId(machineId);
+            machine.setStatus(StockMachineStatus.IDLE.getCode());
+            machine.setCpuCores(8);
+            machine.setRamGb(16);
+            machine.setSsdGb(256);
+            when(stockDAO.findById(machineId)).thenReturn(machine);
+            when(stockDAO.updateStatusByIdAndStatus(machineId, "IDLE", "WORKING")).thenReturn(1);
+        }
+
+        doAnswer(invocation -> {
+            ClusterDO c = invocation.getArgument(0);
+            c.setClusterId(1L);
+            return null;
+        }).when(clusterDAO).save(any(ClusterDO.class));
+
+        ClusterDO creatingCluster = new ClusterDO();
+        creatingCluster.setClusterId(1L);
+        creatingCluster.setStatus(ClusterStatus.CREATING.getCode());
+        creatingCluster.setClusterName("sr-cluster");
+        creatingCluster.setComponentType("STARROCKS");
+        creatingCluster.setVersion("3.1.0");
+
+        when(clusterDAO.findById(1L))
+                .thenReturn(creatingCluster)
+                .thenReturn(creatingCluster);
+        when(k8sOrchestratorClient.createStarRocksCluster(
+                eq("starrocks-1"),
+                eq(List.of("node-101", "node-102", "node-103")),
+                eq(List.of("node-104", "node-105")),
+                eq("3.1.0")))
+                .thenReturn("op-sr-1");
+
+        ClusterResp resp = clusterService.createCluster(createBO);
+
+        assertNotNull(resp);
+        assertEquals(ClusterStatus.CREATING.getCode(), resp.getStatus());
+        verify(k8sOrchestratorClient).createStarRocksCluster(
+                eq("starrocks-1"),
+                eq(List.of("node-101", "node-102", "node-103")),
+                eq(List.of("node-104", "node-105")),
+                eq("3.1.0"));
     }
 
     @Test
@@ -171,17 +269,49 @@ class ClusterServiceTest {
     void deleteCluster_Success() {
         when(clusterNodeDAO.findMachineIdsByClusterId(1L)).thenReturn(List.of(101L));
         when(stockDAO.updateStatusByIdAndStatus(101L, "WORKING", "IDLE")).thenReturn(1);
-        
+
         ClusterDO clusterDO = new ClusterDO();
         clusterDO.setClusterId(1L);
         clusterDO.setStatus(ClusterStatus.STOPPED.getCode());
         when(clusterDAO.findById(1L)).thenReturn(clusterDO);
         when(clusterDAO.updateStatusByExpected(1L, ClusterStatus.STOPPED.getCode(), ClusterStatus.DELETED.getCode())).thenReturn(1);
+        when(clusterDAO.deleteById(1L)).thenReturn(1);
 
         clusterService.deleteCluster(1L);
 
         verify(stockDAO).updateStatusByIdAndStatus(101L, "WORKING", "IDLE");
+        verify(configVersionDAO).deleteByClusterId(1L);
         verify(clusterNodeDAO).deleteByClusterId(1L);
+        verify(clusterDAO).deleteById(1L);
+    }
+
+    @Test
+    void deleteCluster_SparkAsyncRelease() {
+        ClusterDO stoppedCluster = new ClusterDO();
+        stoppedCluster.setClusterId(1L);
+        stoppedCluster.setStatus(ClusterStatus.STOPPED.getCode());
+        stoppedCluster.setComponentType("SPARK");
+
+        ClusterDO stoppingCluster = new ClusterDO();
+        stoppingCluster.setClusterId(1L);
+        stoppingCluster.setStatus(ClusterStatus.STOPPING.getCode());
+        stoppingCluster.setComponentType("SPARK");
+
+        when(clusterDAO.findById(1L))
+                .thenReturn(stoppedCluster)
+                .thenReturn(stoppedCluster)
+                .thenReturn(stoppingCluster);
+        when(clusterDAO.updateStatusByExpected(1L, ClusterStatus.STOPPED.getCode(), ClusterStatus.STOPPING.getCode()))
+                .thenReturn(1);
+        when(k8sOrchestratorClient.releaseSparkCluster("spark-1")).thenReturn("op-release-1");
+
+        clusterService.deleteCluster(1L);
+
+        verify(k8sOrchestratorClient).releaseSparkCluster("spark-1");
+        verify(stockDAO, never()).updateStatusByIdAndStatus(anyLong(), anyString(), anyString());
+        verify(configVersionDAO, never()).deleteByClusterId(anyLong());
+        verify(clusterNodeDAO, never()).deleteByClusterId(anyLong());
+        verify(clusterDAO, never()).deleteById(anyLong());
     }
 
     @Test

+ 65 - 0
backend/src/test/java/com/wenshu/platform/service/resource/ConfigServiceTest.java

@@ -16,6 +16,7 @@ import com.wenshu.platform.model.resp.ConfigVersionResp;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
 import org.mockito.Mock;
 import org.mockito.junit.jupiter.MockitoExtension;
 import org.springframework.security.core.Authentication;
@@ -116,4 +117,68 @@ class ConfigServiceTest {
 
         assertThrows(IllegalArgumentException.class, () -> configService.commitConfig(clusterId, new ClusterConfigCommitReq()));
     }
+
+    @Test
+    void commitConfig_RemovesBackendManagedPolarisUri() throws Exception {
+        mockOpsRole();
+        Long clusterId = 1L;
+        ConfigService service = new ConfigService(clusterDAO, configVersionDAO, new ObjectMapper());
+
+        ClusterConfigCommitReq req = new ClusterConfigCommitReq();
+        req.setConfigContent("""
+                {
+                  "spark.sql.catalog.polaris.uri":"http://custom-polaris:18181/api/catalog",
+                  "spark.driver.memory":"4g"
+                }
+                """);
+        req.setDescription("test commit");
+
+        ClusterDO cluster = new ClusterDO();
+        cluster.setClusterId(clusterId);
+        cluster.setStatus(ClusterStatus.RUNNING.getCode());
+
+        when(clusterDAO.findById(clusterId)).thenReturn(cluster);
+        when(clusterDAO.updateStatusByExpected(clusterId, ClusterStatus.RUNNING.getCode(), ClusterStatus.UPDATING.getCode()))
+                .thenReturn(1);
+        when(clusterDAO.updateStatusByExpected(clusterId, ClusterStatus.UPDATING.getCode(), ClusterStatus.RUNNING.getCode()))
+                .thenReturn(1);
+        when(configVersionDAO.findMaxVersionNo(clusterId)).thenReturn(1);
+
+        service.commitConfig(clusterId, req);
+
+        ArgumentCaptor<ConfigVersionDO> captor = ArgumentCaptor.forClass(ConfigVersionDO.class);
+        verify(configVersionDAO).save(captor.capture());
+        JsonNode savedConfig = new ObjectMapper().readTree(captor.getValue().getConfigContent());
+        assertFalse(savedConfig.has("spark.sql.catalog.polaris.uri"));
+        assertEquals("4g", savedConfig.get("spark.driver.memory").asText());
+    }
+
+    @Test
+    void getCurrentConfig_HidesBackendManagedPolarisUri() throws Exception {
+        mockOpsRole();
+        Long clusterId = 1L;
+        ConfigService service = new ConfigService(clusterDAO, configVersionDAO, new ObjectMapper());
+
+        ClusterDO cluster = new ClusterDO();
+        cluster.setClusterId(clusterId);
+        cluster.setStatus(ClusterStatus.RUNNING.getCode());
+        when(clusterDAO.findById(clusterId)).thenReturn(cluster);
+
+        ConfigVersionDO version = new ConfigVersionDO();
+        version.setClusterId(clusterId);
+        version.setVersionNo(3);
+        version.setIsActive(1);
+        version.setConfigContent("""
+                {
+                  "spark.sql.catalog.polaris.uri":"http://custom-polaris:18181/api/catalog",
+                  "spark.executor.instances":2
+                }
+                """);
+        when(configVersionDAO.findActiveByClusterId(clusterId)).thenReturn(version);
+
+        ConfigVersionResp resp = service.getCurrentConfig(clusterId);
+        JsonNode responseConfig = new ObjectMapper().readTree(resp.getConfigContent());
+        assertFalse(responseConfig.has("spark.sql.catalog.polaris.uri"));
+        assertEquals(2, responseConfig.get("spark.executor.instances").asInt());
+    }
 }

+ 25 - 1
backend/src/test/java/com/wenshu/platform/service/resource/ScalingServiceTest.java

@@ -18,12 +18,14 @@ import com.wenshu.platform.model.enums.ClusterStatus;
 import com.wenshu.platform.model.enums.ScalingSelectMode;
 import com.wenshu.platform.model.enums.StockMachineStatus;
 import com.wenshu.platform.model.resp.ScalingEventResp;
+import com.wenshu.platform.service.resource.k8s.K8sOrchestratorClient;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
 import org.mockito.InjectMocks;
 import org.mockito.Mock;
+import org.mockito.ArgumentCaptor;
 import org.mockito.junit.jupiter.MockitoExtension;
 import org.springframework.security.core.Authentication;
 import org.springframework.security.core.authority.SimpleGrantedAuthority;
@@ -50,6 +52,8 @@ class ScalingServiceTest {
     private StockDAO stockDAO;
     @Mock
     private TransactionTemplate transactionTemplate;
+    @Mock
+    private K8sOrchestratorClient k8sOrchestratorClient;
 
     @InjectMocks
     private ScalingService scalingService;
@@ -108,6 +112,7 @@ class ScalingServiceTest {
         when(clusterDAO.updateStatusByExpected(any(), any(), any())).thenReturn(1);
         when(stockDAO.updateStatusByIdAndStatus(any(), any(), any())).thenReturn(1);
         when(scalingEventDAO.updateResult(any())).thenReturn(1);
+        when(k8sOrchestratorClient.scaleOutSparkCluster(eq("spark-1"), any())).thenReturn("op-scaleout-1");
         
         ScalingEventDO event = new ScalingEventDO();
         event.setScaleEventId(500L);
@@ -116,7 +121,17 @@ class ScalingServiceTest {
         scalingService.manualScaleOut(clusterId, request);
 
         verify(scalingEventDAO).save(any());
-        verify(clusterNodeDAO).save(any());
+        ArgumentCaptor<ClusterNodeDO> nodeCaptor = ArgumentCaptor.forClass(ClusterNodeDO.class);
+        verify(clusterNodeDAO).save(nodeCaptor.capture());
+        assertEquals(ClusterStatus.CREATING.getCode(), nodeCaptor.getValue().getStatus());
+        verify(clusterDAO).updateStatusByExpected(
+                clusterId,
+                ClusterStatus.RUNNING.getCode(),
+                ClusterStatus.SCALING.getCode());
+        verify(clusterDAO, never()).updateStatusByExpected(
+                clusterId,
+                ClusterStatus.SCALING.getCode(),
+                ClusterStatus.RUNNING.getCode());
         verify(scalingEventDAO).updateResult(any());
     }
 
@@ -154,6 +169,7 @@ class ScalingServiceTest {
         when(clusterNodeDAO.deleteByNodeIdAndClusterId(eq(11L), eq(clusterId))).thenReturn(1);
         when(stockDAO.updateStatusByIdAndStatus(any(), any(), any())).thenReturn(1);
         when(scalingEventDAO.updateResult(any())).thenReturn(1);
+        when(k8sOrchestratorClient.scaleInStarRocksCluster(eq("starrocks-1"), any())).thenReturn("op-scalein-1");
 
         ScalingEventDO event = new ScalingEventDO();
         event.setScaleEventId(600L);
@@ -163,6 +179,14 @@ class ScalingServiceTest {
 
         verify(scalingEventDAO).save(any());
         verify(clusterNodeDAO).deleteByNodeIdAndClusterId(eq(11L), eq(clusterId));
+        verify(clusterDAO).updateStatusByExpected(
+                clusterId,
+                ClusterStatus.RUNNING.getCode(),
+                ClusterStatus.SCALING.getCode());
+        verify(clusterDAO, never()).updateStatusByExpected(
+                clusterId,
+                ClusterStatus.SCALING.getCode(),
+                ClusterStatus.RUNNING.getCode());
         verify(scalingEventDAO).updateResult(any());
     }
 }

+ 60 - 1
backend/src/test/java/com/wenshu/platform/service/resource/StockServiceTest.java

@@ -14,6 +14,7 @@ import com.wenshu.platform.model.query.StockQuery;
 import com.wenshu.platform.model.resp.PageResp;
 import com.wenshu.platform.model.resp.StockMachineResp;
 import com.wenshu.platform.service.auth.ForbiddenException;
+import com.wenshu.platform.service.resource.k8s.K8sOrchestratorClient;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
@@ -33,6 +34,8 @@ class StockServiceTest {
 
     @Mock
     private SshCredentialCryptoService sshCredentialCryptoService;
+    @Mock
+    private K8sOrchestratorClient k8sOrchestratorClient;
 
     @InjectMocks
     private StockService stockService;
@@ -79,6 +82,13 @@ class StockServiceTest {
 
         when(sshCredentialCryptoService.encrypt("password123")).thenReturn("cipherText");
         when(stockDAO.findByIpAddress("192.168.1.100")).thenReturn(null);
+        doAnswer(invocation -> {
+            StockInfoDO saved = invocation.getArgument(0);
+            saved.setMachineId(1001L);
+            return null;
+        }).when(stockDAO).save(any(StockInfoDO.class));
+        when(k8sOrchestratorClient.addNode(any(), any(), any(), any(), any(), any(), any(), any()))
+                .thenReturn("op-node-add-1");
 
         StockMachineResp resp = stockService.addMachine(machine);
 
@@ -88,6 +98,9 @@ class StockServiceTest {
             "cipherText".equals(savedMachine.getSshPasswordCipher()) &&
             savedMachine.getSshPassword() == null
         ));
+        verify(k8sOrchestratorClient).addNode(eq("node-1001"), eq(4), eq(16), eq(500),
+                eq("192.168.1.100"), eq("admin"), eq("password123"), eq(22));
+        verify(k8sOrchestratorClient).waitForOperationSucceeded("op-node-add-1");
     }
 
     @Test
@@ -107,6 +120,32 @@ class StockServiceTest {
         assertThrows(IllegalArgumentException.class, () -> stockService.addMachine(machine));
     }
 
+    @Test
+    void addMachine_OnboardingFailed() {
+        StockInfoDO machine = new StockInfoDO();
+        machine.setIpAddress("192.168.1.101");
+        machine.setCpuCores(4);
+        machine.setCpuModel("Intel Xeon");
+        machine.setRamGb(16);
+        machine.setSsdGb(500);
+        machine.setSshUsername("admin");
+        machine.setSshPassword("password123");
+
+        when(sshCredentialCryptoService.encrypt("password123")).thenReturn("cipherText");
+        when(stockDAO.findByIpAddress("192.168.1.101")).thenReturn(null);
+        doAnswer(invocation -> {
+            StockInfoDO saved = invocation.getArgument(0);
+            saved.setMachineId(1002L);
+            return null;
+        }).when(stockDAO).save(any(StockInfoDO.class));
+        when(k8sOrchestratorClient.addNode(any(), any(), any(), any(), any(), any(), any(), any()))
+                .thenReturn("op-node-add-2");
+        doThrow(new IllegalStateException("node onboarding failed"))
+                .when(k8sOrchestratorClient).waitForOperationSucceeded("op-node-add-2");
+
+        assertThrows(IllegalStateException.class, () -> stockService.addMachine(machine));
+    }
+
     @Test
     void updateMachine_DescriptionOnly_WhenWorking() {
         Long machineId = 1L;
@@ -167,10 +206,30 @@ class StockServiceTest {
 
         when(stockDAO.findById(machineId)).thenReturn(current);
         when(stockDAO.deleteByIdAndStatus(machineId, StockMachineStatus.IDLE.getCode())).thenReturn(1);
+        when(k8sOrchestratorClient.deleteNode("node-1")).thenReturn("op-node-del-1");
 
         stockService.deleteMachine(machineId);
 
-        verify(stockDAO).deleteByIdAndStatus(machineId, StockMachineStatus.IDLE.getCode());
+        var inOrder = inOrder(k8sOrchestratorClient, stockDAO);
+        inOrder.verify(k8sOrchestratorClient).deleteNode("node-1");
+        inOrder.verify(k8sOrchestratorClient).waitForOperationSucceeded("op-node-del-1");
+        inOrder.verify(stockDAO).deleteByIdAndStatus(machineId, StockMachineStatus.IDLE.getCode());
+    }
+
+    @Test
+    void deleteMachine_WaitDeleteNodeFailed() {
+        Long machineId = 1L;
+        StockInfoDO current = new StockInfoDO();
+        current.setMachineId(machineId);
+        current.setStatus(StockMachineStatus.IDLE.getCode());
+
+        when(stockDAO.findById(machineId)).thenReturn(current);
+        when(k8sOrchestratorClient.deleteNode("node-1")).thenReturn("op-node-del-1");
+        doThrow(new IllegalStateException("node delete failed"))
+                .when(k8sOrchestratorClient).waitForOperationSucceeded("op-node-del-1");
+
+        assertThrows(IllegalStateException.class, () -> stockService.deleteMachine(machineId));
+        verify(stockDAO, never()).deleteByIdAndStatus(anyLong(), anyString());
     }
 
     @Test

+ 28 - 0
backend/src/test/java/com/wenshu/platform/service/taskexec/ExecutionServiceTest.java

@@ -73,6 +73,34 @@ class ExecutionServiceTest {
         assertNotNull(handleMap.get(taskInstanceId));
     }
 
+    @Test
+    void dispatch_SparkSqlTaskType_UsesSparkAdapter() {
+        Long workflowInstanceId = 1L;
+        Long taskInstanceId = 2L;
+        Long taskId = 3L;
+
+        TaskInstanceDO taskInstance = new TaskInstanceDO();
+        taskInstance.setTaskInstanceId(taskInstanceId);
+        taskInstance.setTaskId(taskId);
+        taskInstance.setState(TaskInstanceState.PENDING.getCode());
+
+        TaskDefinitionDO taskDefinition = new TaskDefinitionDO();
+        taskDefinition.setTaskId(taskId);
+        taskDefinition.setTaskType("SPARK_SQL");
+
+        when(taskInstanceDAO.findById(taskInstanceId)).thenReturn(taskInstance);
+        when(taskDefinitionDAO.findById(taskId)).thenReturn(taskDefinition);
+        when(engineAdapter.submitTask(any(), any())).thenReturn("engine-task-123");
+
+        executionService.dispatch(workflowInstanceId, taskInstanceId);
+
+        verify(engineAdapter).submitTask(any(), any());
+        verify(taskInstanceDAO).update(argThat(update ->
+                TaskInstanceState.RUNNING.getCode().equals(update.getState()) &&
+                        taskInstanceId.equals(update.getTaskInstanceId())
+        ));
+    }
+
     @Test
     void dispatch_UnsupportedType() {
         Long workflowInstanceId = 1L;

+ 248 - 0
backend/src/test/java/com/wenshu/platform/service/taskexec/SparkAdapterTest.java

@@ -0,0 +1,248 @@
+package com.wenshu.platform.service.taskexec;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.util.Map;
+
+import com.wenshu.platform.config.PolarisProperties;
+import com.wenshu.platform.config.SparkSqlRunnerProperties;
+import com.wenshu.platform.model.dataobject.TaskDefinitionDO;
+import com.wenshu.platform.model.dataobject.TaskInstanceDO;
+import com.wenshu.platform.model.enums.TaskInstanceState;
+import com.wenshu.platform.service.resource.k8s.K8sOrchestratorClient;
+import com.wenshu.platform.service.resource.k8s.SparkJobStatus;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+@ExtendWith(MockitoExtension.class)
+class SparkAdapterTest {
+
+    @Mock
+    private SparkClusterConfigResolver sparkClusterConfigResolver;
+
+    @Mock
+    private K8sOrchestratorClient k8sOrchestratorClient;
+
+    private SparkSqlRunnerProperties sparkSqlRunnerProperties;
+    private SparkAdapter sparkAdapter;
+
+    @BeforeEach
+    void setUp() {
+        sparkSqlRunnerProperties = new SparkSqlRunnerProperties();
+
+        PolarisProperties polarisProperties = new PolarisProperties();
+        polarisProperties.setHost("http://polaris:8181");
+        polarisProperties.setRealm("POLARIS");
+        polarisProperties.setClientId("root");
+        polarisProperties.setClientSecret("secret");
+        polarisProperties.setDefaultCatalog("demo_catalog");
+        PolarisProperties.Oss oss = polarisProperties.getOss();
+        oss.setRegion("cn-hangzhou");
+        oss.setEndpoint("https://oss-cn-hangzhou.aliyuncs.com");
+        oss.setAccessKeyId("ak");
+        oss.setAccessKeySecret("sk");
+
+        sparkAdapter = new SparkAdapter(
+                sparkClusterConfigResolver,
+                k8sOrchestratorClient,
+                sparkSqlRunnerProperties,
+                polarisProperties);
+    }
+
+    @Test
+    void submitTask_SubmitsMergedSparkConfigToK8s() {
+        TaskDefinitionDO taskDefinition = new TaskDefinitionDO();
+        taskDefinition.setTaskId(11L);
+        taskDefinition.setTaskContent("SELECT 1");
+
+        TaskInstanceDO taskInstance = new TaskInstanceDO();
+        taskInstance.setExecutorId(2001L);
+
+        when(sparkClusterConfigResolver.resolveSparkConfig(2001L))
+                .thenReturn(Map.of(
+                        "spark.driver.memory", "3g",
+                        "spark.driver.cores", "3",
+                        "spark.executor.instances", "5",
+                        "spark.executor.memory", "6g",
+                        "spark.executor.cores", "4",
+                        "spark.sql.shuffle.partitions", "128",
+                        "spark.image", "spark:test",
+                        "spark.imagePullPolicy", "IfNotPresent"));
+        when(k8sOrchestratorClient.submitSparkJob(eq("spark-2001"), any())).thenReturn("op-123");
+
+        String engineTaskId = sparkAdapter.submitTask(taskDefinition, taskInstance);
+        assertEquals("op-123", engineTaskId);
+
+        @SuppressWarnings("unchecked")
+        ArgumentCaptor<Map<String, Object>> specCaptor = ArgumentCaptor.forClass(Map.class);
+        verify(sparkClusterConfigResolver).resolveSparkConfig(2001L);
+        verify(k8sOrchestratorClient).submitSparkJob(eq("spark-2001"), specCaptor.capture());
+
+        Map<String, Object> submittedSpec = specCaptor.getValue();
+        assertEquals("Java", submittedSpec.get("type"));
+        assertEquals("cluster", submittedSpec.get("mode"));
+        assertEquals("3.5.1", submittedSpec.get("sparkVersion"));
+        assertEquals("org.apache.spark.sql.hive.thriftserver.SparkSQLCLIDriver", submittedSpec.get("mainClass"));
+        assertEquals("local:///opt/spark/jars/spark-sql_2.12-3.5.1.jar", submittedSpec.get("mainApplicationFile"));
+        assertEquals("spark:test", submittedSpec.get("image"));
+        assertEquals("IfNotPresent", submittedSpec.get("imagePullPolicy"));
+
+        @SuppressWarnings("unchecked")
+        Map<String, Object> driver = (Map<String, Object>) submittedSpec.get("driver");
+        @SuppressWarnings("unchecked")
+        Map<String, Object> executor = (Map<String, Object>) submittedSpec.get("executor");
+        @SuppressWarnings("unchecked")
+        Map<String, String> sparkConf = (Map<String, String>) submittedSpec.get("sparkConf");
+        assertNotNull(driver);
+        assertNotNull(executor);
+        assertNotNull(sparkConf);
+        assertEquals(3, driver.get("cores"));
+        assertEquals("3g", driver.get("memory"));
+        assertEquals(5, executor.get("instances"));
+        assertEquals(4, executor.get("cores"));
+        assertEquals("6g", executor.get("memory"));
+        assertEquals("128", sparkConf.get("spark.sql.shuffle.partitions"));
+        assertTrue(sparkConf.containsKey("spark.driver.memory"));
+        assertEquals("http://polaris:8181/api/catalog", sparkConf.get("spark.sql.catalog.polaris.uri"));
+        assertEquals("demo_catalog", sparkConf.get("spark.sql.catalog.polaris.warehouse"));
+        assertEquals("org.apache.iceberg.spark.SparkCatalog", sparkConf.get("spark.sql.catalog.polaris"));
+        assertEquals("true", sparkConf.get("spark.sql.catalog.polaris.token-refresh-enabled"));
+        assertEquals("org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions",
+                sparkConf.get("spark.sql.extensions"));
+        String packages = sparkConf.get("spark.jars.packages");
+        assertTrue(packages.contains("org.apache.iceberg:iceberg-spark-runtime-3.5_2.12:1.10.0"));
+        assertTrue(packages.contains("org.apache.iceberg:iceberg-aws-bundle:1.10.0"));
+        assertEquals("/tmp/.ivy2", sparkConf.get("spark.jars.ivy"));
+    }
+
+    @Test
+    void submitTask_UsesDefaultsWhenClusterConfigMissing() {
+        TaskDefinitionDO taskDefinition = new TaskDefinitionDO();
+        taskDefinition.setTaskId(11L);
+        taskDefinition.setTaskContent("SELECT 1");
+
+        TaskInstanceDO taskInstance = new TaskInstanceDO();
+        taskInstance.setExecutorId(2001L);
+
+        when(sparkClusterConfigResolver.resolveSparkConfig(2001L)).thenReturn(Map.of());
+        when(k8sOrchestratorClient.submitSparkJob(eq("spark-2001"), any())).thenReturn("op-123");
+
+        sparkAdapter.submitTask(taskDefinition, taskInstance);
+
+        @SuppressWarnings("unchecked")
+        ArgumentCaptor<Map<String, Object>> specCaptor = ArgumentCaptor.forClass(Map.class);
+        verify(k8sOrchestratorClient).submitSparkJob(eq("spark-2001"), specCaptor.capture());
+        @SuppressWarnings("unchecked")
+        Map<String, Object> driver = (Map<String, Object>) specCaptor.getValue().get("driver");
+        @SuppressWarnings("unchecked")
+        Map<String, Object> executor = (Map<String, Object>) specCaptor.getValue().get("executor");
+        @SuppressWarnings("unchecked")
+        Map<String, String> sparkConf = (Map<String, String>) specCaptor.getValue().get("sparkConf");
+        assertEquals("3.5.1", specCaptor.getValue().get("sparkVersion"));
+        assertEquals(1, driver.get("cores"));
+        assertEquals("2g", driver.get("memory"));
+        assertEquals(2, executor.get("instances"));
+        assertEquals(2, executor.get("cores"));
+        assertEquals("4g", executor.get("memory"));
+        assertEquals("http://polaris:8181/api/catalog", sparkConf.get("spark.sql.catalog.polaris.uri"));
+        assertEquals("root:secret", sparkConf.get("spark.sql.catalog.polaris.credential"));
+        assertEquals("org.apache.iceberg.spark.SparkCatalog", sparkConf.get("spark.sql.catalog.polaris"));
+    }
+
+    @Test
+    void submitTask_UsesPythonRunnerWhenConfigured() {
+        sparkSqlRunnerProperties.setType("Python");
+        sparkSqlRunnerProperties.setPythonVersion("3");
+        sparkSqlRunnerProperties.setMainApplicationFile("local:///opt/spark/wenshu/spark_sql_runner.py");
+
+        TaskDefinitionDO taskDefinition = new TaskDefinitionDO();
+        taskDefinition.setTaskId(11L);
+        taskDefinition.setTaskContent("SELECT 1");
+
+        TaskInstanceDO taskInstance = new TaskInstanceDO();
+        taskInstance.setExecutorId(2001L);
+
+        when(sparkClusterConfigResolver.resolveSparkConfig(2001L)).thenReturn(Map.of());
+        when(k8sOrchestratorClient.submitSparkJob(eq("spark-2001"), any())).thenReturn("op-123");
+
+        sparkAdapter.submitTask(taskDefinition, taskInstance);
+
+        @SuppressWarnings("unchecked")
+        ArgumentCaptor<Map<String, Object>> specCaptor = ArgumentCaptor.forClass(Map.class);
+        verify(k8sOrchestratorClient).submitSparkJob(eq("spark-2001"), specCaptor.capture());
+        Map<String, Object> submittedSpec = specCaptor.getValue();
+        assertEquals("Python", submittedSpec.get("type"));
+        assertEquals("3", submittedSpec.get("pythonVersion"));
+        assertEquals("local:///opt/spark/wenshu/spark_sql_runner.py", submittedSpec.get("mainApplicationFile"));
+        assertEquals(null, submittedSpec.get("mainClass"));
+    }
+
+    @Test
+    void submitTask_PolarisUriIsAlwaysManagedByBackend() {
+        TaskDefinitionDO taskDefinition = new TaskDefinitionDO();
+        taskDefinition.setTaskId(11L);
+        taskDefinition.setTaskContent("SELECT 1");
+
+        TaskInstanceDO taskInstance = new TaskInstanceDO();
+        taskInstance.setExecutorId(2001L);
+
+        when(sparkClusterConfigResolver.resolveSparkConfig(2001L))
+                .thenReturn(Map.of(
+                        "spark.sql.catalog.polaris", "custom.CatalogImpl",
+                        "spark.sql.catalog.polaris.uri", "http://custom-polaris:18181/api/catalog",
+                        "spark.sql.defaultCatalog", "custom_alias",
+                        "spark.jars.packages", "custom:pkg:1.0.0"));
+        when(k8sOrchestratorClient.submitSparkJob(eq("spark-2001"), any())).thenReturn("op-123");
+
+        sparkAdapter.submitTask(taskDefinition, taskInstance);
+
+        @SuppressWarnings("unchecked")
+        ArgumentCaptor<Map<String, Object>> specCaptor = ArgumentCaptor.forClass(Map.class);
+        verify(k8sOrchestratorClient).submitSparkJob(eq("spark-2001"), specCaptor.capture());
+        @SuppressWarnings("unchecked")
+        Map<String, String> sparkConf = (Map<String, String>) specCaptor.getValue().get("sparkConf");
+        assertEquals("custom.CatalogImpl", sparkConf.get("spark.sql.catalog.polaris"));
+        assertEquals("http://polaris:8181/api/catalog", sparkConf.get("spark.sql.catalog.polaris.uri"));
+        assertEquals("custom_alias", sparkConf.get("spark.sql.defaultCatalog"));
+        assertTrue(sparkConf.get("spark.jars.packages").contains("custom:pkg:1.0.0"));
+        assertTrue(sparkConf.get("spark.jars.packages").contains("org.apache.iceberg:iceberg-aws-bundle:1.10.0"));
+    }
+
+    @Test
+    void queryStatus_MapsSparkStates() {
+        when(k8sOrchestratorClient.getSparkJobStatus("op-running"))
+                .thenReturn(new SparkJobStatus("op-running", "SUCCEEDED", "ns/app", "ns", "app", "RUNNING", null));
+        when(k8sOrchestratorClient.getSparkJobStatus("op-success"))
+                .thenReturn(new SparkJobStatus("op-success", "SUCCEEDED", "ns/app", "ns", "app", "COMPLETED", null));
+        when(k8sOrchestratorClient.getSparkJobStatus("op-failed"))
+                .thenReturn(new SparkJobStatus("op-failed", "SUCCEEDED", "ns/app", "ns", "app", "FAILED", null));
+        when(k8sOrchestratorClient.getSparkJobStatus("op-op-failed"))
+                .thenReturn(new SparkJobStatus("op-op-failed", "FAILED", "ns/app", "ns", "app", "RUNNING", null));
+
+        assertEquals(TaskInstanceState.RUNNING, sparkAdapter.queryStatus("op-running"));
+        assertEquals(TaskInstanceState.SUCCESS, sparkAdapter.queryStatus("op-success"));
+        assertEquals(TaskInstanceState.FAILED, sparkAdapter.queryStatus("op-failed"));
+        assertEquals(TaskInstanceState.FAILED, sparkAdapter.queryStatus("op-op-failed"));
+    }
+
+    @Test
+    void queryStatus_FallbackWhenUnknownOrError() {
+        when(k8sOrchestratorClient.getSparkJobStatus("op-missing")).thenReturn(null);
+        doThrow(new IllegalStateException("network timeout"))
+                .when(k8sOrchestratorClient).getSparkJobStatus("op-error");
+
+        assertEquals(TaskInstanceState.SUBMITTED, sparkAdapter.queryStatus("op-missing"));
+        assertEquals(TaskInstanceState.RUNNING, sparkAdapter.queryStatus("op-error"));
+    }
+}

+ 76 - 0
backend/src/test/java/com/wenshu/platform/service/taskexec/SparkClusterConfigResolverTest.java

@@ -0,0 +1,76 @@
+package com.wenshu.platform.service.taskexec;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.when;
+
+import java.util.Map;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.wenshu.platform.dao.ConfigVersionDAO;
+import com.wenshu.platform.model.dataobject.ConfigVersionDO;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+@ExtendWith(MockitoExtension.class)
+class SparkClusterConfigResolverTest {
+
+    @Mock
+    private ConfigVersionDAO configVersionDAO;
+
+    private SparkClusterConfigResolver resolver;
+
+    @BeforeEach
+    void setUp() {
+        resolver = new SparkClusterConfigResolver(configVersionDAO, new ObjectMapper());
+    }
+
+    @Test
+    void resolveSparkConfig_FiltersAndMapsLegacyKeys() {
+        ConfigVersionDO active = new ConfigVersionDO();
+        active.setConfigContent("""
+                {
+                  "spark.driver.memory":"3g",
+                  "spark.sql.catalog.polaris.uri":"http://custom-polaris:18181/api/catalog",
+                  "spark.sql.adaptive.enabled": true,
+                  "executorMemory":"8g",
+                  "executorCores":4,
+                  "driverMemory":"2g",
+                  "shufflePartitions":300,
+                  "custom.foo":"bar"
+                }
+                """);
+        when(configVersionDAO.findActiveByClusterId(1001L)).thenReturn(active);
+
+        Map<String, String> result = resolver.resolveSparkConfig(1001L);
+
+        assertEquals("3g", result.get("spark.driver.memory"));
+        assertEquals("8g", result.get("spark.executor.memory"));
+        assertEquals("4", result.get("spark.executor.cores"));
+        assertEquals("300", result.get("spark.sql.shuffle.partitions"));
+        assertEquals("true", result.get("spark.sql.adaptive.enabled"));
+        assertTrue(!result.containsKey("custom.foo"));
+        assertTrue(!result.containsKey("driverMemory"));
+        assertTrue(!result.containsKey("spark.sql.catalog.polaris.uri"));
+    }
+
+    @Test
+    void resolveSparkConfig_InvalidJsonReturnsEmpty() {
+        ConfigVersionDO active = new ConfigVersionDO();
+        active.setConfigContent("{bad-json}");
+        when(configVersionDAO.findActiveByClusterId(1002L)).thenReturn(active);
+
+        Map<String, String> result = resolver.resolveSparkConfig(1002L);
+
+        assertTrue(result.isEmpty());
+    }
+
+    @Test
+    void resolveSparkConfig_EmptyClusterIdReturnsEmpty() {
+        Map<String, String> result = resolver.resolveSparkConfig(null);
+        assertTrue(result.isEmpty());
+    }
+}

+ 284 - 0
docker-compose.yml

@@ -0,0 +1,284 @@
+services:
+
+  # ──────────────────────────────────────────
+  # 1. MySQL 主数据库
+  # ──────────────────────────────────────────
+  mysql:
+    image: mysql:8.0
+    container_name: ws-mysql
+    restart: unless-stopped
+    environment:
+      TZ: ${TZ:-Asia/Shanghai}
+      MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-root_pass_change_me}
+      MYSQL_DATABASE: ${MYSQL_DATABASE:-wenshu_platform}
+      MYSQL_USER: ${MYSQL_USER:-wenshu}
+      MYSQL_PASSWORD: ${MYSQL_PASSWORD:-wenshu_db_pass}
+    volumes:
+      - ./data/mysql:/var/lib/mysql
+      - ./backend/src/main/resources/db/schema.sql:/docker-entrypoint-initdb.d/01-schema.sql:ro
+    ports:
+      - "3306:3306"
+    healthcheck:
+      test: ["CMD-SHELL", "mysqladmin ping -h localhost -u$${MYSQL_USER} -p$${MYSQL_PASSWORD} --silent"]
+      interval: 10s
+      timeout: 5s
+      retries: 10
+
+  # ──────────────────────────────────────────
+  # 2. Milvus 向量数据库(依赖 etcd + minio)
+  # ──────────────────────────────────────────
+  milvus-etcd:
+    image: quay.io/coreos/etcd:v3.5.18
+    container_name: ws-milvus-etcd
+    restart: unless-stopped
+    environment:
+      ETCD_AUTO_COMPACTION_MODE: revision
+      ETCD_AUTO_COMPACTION_RETENTION: "1000"
+      ETCD_QUOTA_BACKEND_BYTES: "4294967296"
+      ETCD_SNAPSHOT_COUNT: "50000"
+    volumes:
+      - ./data/milvus/etcd:/etcd
+    command: >
+      etcd
+      -advertise-client-urls=http://127.0.0.1:2379
+      -listen-client-urls=http://0.0.0.0:2379
+      --data-dir=/etcd
+    healthcheck:
+      test: ["CMD", "etcdctl", "endpoint", "health"]
+      interval: 30s
+      timeout: 20s
+      retries: 3
+
+  milvus-minio:
+    image: minio/minio:RELEASE.2023-03-20T20-16-18Z
+    container_name: ws-milvus-minio
+    restart: unless-stopped
+    environment:
+      MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY:-minioadmin}
+      MINIO_SECRET_KEY: ${MINIO_SECRET_KEY:-minioadmin}
+    volumes:
+      - ./data/milvus/minio:/minio_data
+    command: minio server /minio_data --console-address ":9001"
+    healthcheck:
+      test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
+      interval: 30s
+      timeout: 20s
+      retries: 3
+
+  milvus-standalone:
+    image: milvusdb/milvus:v2.4.15
+    container_name: ws-milvus-standalone
+    restart: unless-stopped
+    depends_on:
+      milvus-etcd:
+        condition: service_healthy
+      milvus-minio:
+        condition: service_healthy
+    environment:
+      ETCD_ENDPOINTS: milvus-etcd:2379
+      MINIO_ADDRESS: milvus-minio:9000
+    volumes:
+      - ./data/milvus/milvus:/var/lib/milvus
+    ports:
+      - "19530:19530"
+      - "9091:9091"
+    command: ["milvus", "run", "standalone"]
+    healthcheck:
+      test: ["CMD", "curl", "-f", "http://localhost:9091/healthz"]
+      interval: 30s
+      timeout: 20s
+      retries: 5
+
+  # ──────────────────────────────────────────
+  # 3. Apache Polaris(Iceberg REST Catalog)
+  #    依赖专属 Postgres
+  # ──────────────────────────────────────────
+  polaris-postgres:
+    image: postgres:16
+    container_name: ws-polaris-postgres
+    restart: unless-stopped
+    environment:
+      TZ: ${TZ:-Asia/Shanghai}
+      POSTGRES_DB: ${POLARIS_POSTGRES_DB:-polaris}
+      POSTGRES_USER: ${POLARIS_POSTGRES_USER:-polaris}
+      POSTGRES_PASSWORD: ${POLARIS_POSTGRES_PASSWORD:-polaris_pg_pass}
+    volumes:
+      - ./data/polaris/postgres:/var/lib/postgresql/data
+    healthcheck:
+      test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
+      interval: 10s
+      timeout: 5s
+      retries: 10
+
+  polaris:
+    image: apache/polaris:1.3.0-incubating
+    container_name: ws-polaris
+    restart: unless-stopped
+    depends_on:
+      polaris-postgres:
+        condition: service_healthy
+    ports:
+      - "8181:8181"
+    environment:
+      POLARIS_PERSISTENCE_TYPE: relational-jdbc
+      QUARKUS_DATASOURCE_USERNAME: ${POLARIS_POSTGRES_USER:-polaris}
+      QUARKUS_DATASOURCE_PASSWORD: ${POLARIS_POSTGRES_PASSWORD:-polaris_pg_pass}
+      QUARKUS_DATASOURCE_JDBC_URL: jdbc:postgresql://polaris-postgres:5432/${POLARIS_POSTGRES_DB:-polaris}
+      QUARKUS_HTTP_PORT: 8181
+      AWS_ACCESS_KEY_ID: ${OSS_AK:-}
+      AWS_SECRET_ACCESS_KEY: ${OSS_SK:-}
+      AWS_REGION: ${OSS_REGION:-cn-hangzhou}
+      AWS_DEFAULT_REGION: ${OSS_REGION:-cn-hangzhou}
+      AWS_REQUEST_CHECKSUM_CALCULATION: WHEN_REQUIRED
+      AWS_RESPONSE_CHECKSUM_VALIDATION: WHEN_REQUIRED
+    healthcheck:
+      test: ["CMD-SHELL", "bash -c '</dev/tcp/localhost/8181' 2>/dev/null && exit 0 || exit 1"]
+      interval: 15s
+      timeout: 5s
+      retries: 20
+      start_period: 60s
+
+  # ──────────────────────────────────────────
+  # 4. TEI 向量化服务(CPU 模式)
+  # ──────────────────────────────────────────
+  tei:
+    image: ghcr.io/huggingface/text-embeddings-inference:cpu-latest
+    container_name: ws-tei
+    restart: unless-stopped
+    volumes:
+      - ./data/huggingface:/data
+    ports:
+      - "7997:80"
+    command: ["--model-id", "${TEI_MODEL_ID:-intfloat/multilingual-e5-base}"]
+    healthcheck:
+      test: ["CMD", "curl", "-sf", "http://localhost:80/health"]
+      interval: 30s
+      timeout: 10s
+      retries: 10
+      start_period: 120s  # 首次下载模型需要时间
+
+  # ──────────────────────────────────────────
+  # 5. Spring Boot 后端
+  # ──────────────────────────────────────────
+  backend:
+    build:
+      context: ./backend
+      dockerfile: Dockerfile
+    container_name: ws-backend
+    restart: unless-stopped
+    environment:
+      TZ: ${TZ:-Asia/Shanghai}
+      MYSQL_URL: ${MYSQL_URL:-mysql:3306}
+      MYSQL_DATABASE: ${MYSQL_DATABASE:-wenshu_platform}
+      MYSQL_USER: ${MYSQL_USER:-wenshu}
+      MYSQL_PASSWORD: ${MYSQL_PASSWORD:-wenshu_db_pass}
+      STOCK_SSH_CRYPTO_KEY: ${STOCK_SSH_CRYPTO_KEY:-change-me-stock-ssh-key-32chars}
+      POLARIS_HOST: ${POLARIS_HOST:-http://polaris:8181}
+      POLARIS_REALM: ${POLARIS_REALM:-POLARIS}
+      POLARIS_CLIENT_ID: ${POLARIS_CLIENT_ID:-root}
+      POLARIS_CLIENT_SECRET: ${POLARIS_CLIENT_SECRET:-s3cr3t}
+      POLARIS_DEFAULT_CATALOG: ${POLARIS_DEFAULT_CATALOG:-demo_catalog}
+      POLARIS_CACHE_TTL_MINUTES: ${POLARIS_CACHE_TTL_MINUTES:-30}
+      OSS_ENDPOINT: ${OSS_ENDPOINT:-https://oss-cn-hangzhou.aliyuncs.com}
+      OSS_AK: ${OSS_AK:-}
+      OSS_SK: ${OSS_SK:-}
+      OSS_REGION: ${OSS_REGION:-cn-hangzhou}
+      OSS_BUCKET: ${OSS_BUCKET:-}
+      ICEBERG_SERVICE_URL: ${ICEBERG_SERVICE_URL:-http://python-iceberg:8090}
+      K8S_ORCHESTRATOR_BASE_URL: ${K8S_ORCHESTRATOR_BASE_URL:-http://ws-orchestrator-api:8080}
+      K8S_ORCHESTRATOR_TIMEOUT_MS: ${K8S_ORCHESTRATOR_TIMEOUT_MS:-5000}
+      K8S_ORCHESTRATOR_WAIT_TIMEOUT_MS: ${K8S_ORCHESTRATOR_WAIT_TIMEOUT_MS:-300000}
+      K8S_ORCHESTRATOR_POLL_INTERVAL_MS: ${K8S_ORCHESTRATOR_POLL_INTERVAL_MS:-2000}
+      K8S_ORCHESTRATOR_RECONCILE_CREATE_DELAY_MS: ${K8S_ORCHESTRATOR_RECONCILE_CREATE_DELAY_MS:-5000}
+      K8S_ORCHESTRATOR_RECONCILE_BATCH_SIZE: ${K8S_ORCHESTRATOR_RECONCILE_BATCH_SIZE:-100}
+      RAG_EMBEDDING_DIMENSION: ${RAG_EMBEDDING_DIMENSION:-768}
+      RAG_EMBEDDING_URL: ${RAG_EMBEDDING_URL:-http://tei:80/v1}
+      RAG_EMBEDDING_MODEL: ${RAG_EMBEDDING_MODEL:-intfloat/multilingual-e5-base}
+      RAG_MILVUS_URI: ${RAG_MILVUS_URI:-http://milvus-standalone:19530}
+      RAG_MILVUS_COLLECTION: ${RAG_MILVUS_COLLECTION:-knowledge_vectors}
+      RAG_RETRIEVAL_TOP_K: ${RAG_RETRIEVAL_TOP_K:-8}
+      RAG_RETRIEVAL_SCORE_THRESHOLD: ${RAG_RETRIEVAL_SCORE_THRESHOLD:-0.3}
+    extra_hosts:
+      - "host.docker.internal:host-gateway"
+    depends_on:
+      mysql:
+        condition: service_healthy
+      milvus-standalone:
+        condition: service_healthy
+    ports:
+      - "8080:8080"
+    healthcheck:
+      test: ["CMD-SHELL", "bash -c '</dev/tcp/localhost/8080' 2>/dev/null && exit 0 || exit 1"]
+      interval: 15s
+      timeout: 5s
+      retries: 20
+      start_period: 90s
+
+  # ──────────────────────────────────────────
+  # 6. Python Iceberg 服务
+  #    启动时会调用 backend 获取配置,必须在 backend 之后启动
+  # ──────────────────────────────────────────
+  python-iceberg:
+    build:
+      context: ./python-iceberg
+      dockerfile: Dockerfile
+    container_name: ws-python-iceberg
+    restart: unless-stopped
+    depends_on:
+      backend:
+        condition: service_healthy
+      polaris:
+        condition: service_healthy
+    ports:
+      - "8090:8090"
+    environment:
+      BACKEND_URL: ${ICEBERG_BACKEND_URL:-http://backend:8080}
+
+  # ──────────────────────────────────────────
+  # 7. 前端(Nginx)
+  # ──────────────────────────────────────────
+  frontend:
+    build:
+      context: ./frontend
+      dockerfile: Dockerfile
+    container_name: ws-frontend
+    restart: unless-stopped
+    depends_on:
+      - backend
+    ports:
+      - "80:80"
+
+  # ──────────────────────────────────────────
+  # 8. k8s-orchestrator(默认启动,需可访问 K8s API)
+  # ──────────────────────────────────────────
+  ws-orchestrator-api:
+    build:
+      context: ./k8s-orchestrator
+      dockerfile: Dockerfile.api
+    container_name: ws-orchestrator-api
+    restart: unless-stopped
+    user: "0:0"
+    environment:
+      TZ: ${TZ:-Asia/Shanghai}
+      API_BIND_ADDRESS: ":8080"
+      SECRET_NAMESPACE: ${K8S_ORCHESTRATOR_SECRET_NAMESPACE:-orchestrator-system}
+      KUBECONFIG: /kube/config
+    volumes:
+      - ${K8S_KUBECONFIG_PATH:-/root/.kube/config}:/kube/config:ro
+    ports:
+      - "18080:8080"
+
+  ws-orchestrator-controller:
+    build:
+      context: ./k8s-orchestrator
+      dockerfile: Dockerfile.controller
+    container_name: ws-orchestrator-controller
+    restart: unless-stopped
+    user: "0:0"
+    command: ["--leader-elect=false"]
+    environment:
+      TZ: ${TZ:-Asia/Shanghai}
+      KUBECONFIG: /kube/config
+      KUBEADM_JOIN_COMMAND: ${KUBEADM_JOIN_COMMAND:-}
+    volumes:
+      - ${K8S_KUBECONFIG_PATH:-/root/.kube/config}:/kube/config:ro

+ 13 - 0
frontend/Dockerfile

@@ -0,0 +1,13 @@
+# ---- 构建阶段 ----
+FROM node:20-alpine AS build
+WORKDIR /app
+COPY package*.json ./
+RUN npm ci
+COPY . .
+RUN npm run build
+
+# ---- Nginx 服务阶段 ----
+FROM nginx:1.27-alpine
+COPY --from=build /app/dist /usr/share/nginx/html
+COPY nginx.conf /etc/nginx/conf.d/default.conf
+EXPOSE 80

+ 31 - 0
frontend/nginx.conf

@@ -0,0 +1,31 @@
+server {
+    listen 80;
+    server_name _;
+    client_max_body_size 500M; 
+    
+    root /usr/share/nginx/html;
+    index index.html;
+
+    # 前端 SPA 路由(刷新不 404)
+    location / {
+        try_files $uri $uri/ /index.html;
+    }
+
+    # 代理 REST API 到后端
+    location /api/ {
+        proxy_pass http://backend:8080;
+        proxy_set_header Host $host;
+        proxy_set_header X-Real-IP $remote_addr;
+        proxy_read_timeout 300s;
+    }
+
+    # 代理 WebSocket(SSH 终端)
+    location /ws {
+        proxy_pass http://backend:8080;
+        proxy_http_version 1.1;
+        proxy_set_header Upgrade $http_upgrade;
+        proxy_set_header Connection "upgrade";
+        proxy_set_header Host $host;
+        proxy_read_timeout 3600s;
+    }
+}

+ 7 - 3
frontend/src/api/taskExec.js

@@ -5,13 +5,17 @@ export function listWorkflowInstances(params) {
 }
 
 export function getWorkflowStatus(workflowInstanceId) {
-  return http.get(`/api/task-exec/workflows/instances/${workflowInstanceId}`);
+  return http.get('/api/task-exec/workflows/instances/' + workflowInstanceId);
+}
+
+export function getTaskInstanceResult(workflowInstanceId, taskInstanceId) {
+  return http.get('/api/task-exec/workflows/instances/' + workflowInstanceId + '/tasks/' + taskInstanceId + '/result');
 }
 
 export function terminateWorkflow(workflowInstanceId) {
-  return http.post(`/api/task-exec/workflows/instances/${workflowInstanceId}/terminate`);
+  return http.post('/api/task-exec/workflows/instances/' + workflowInstanceId + '/terminate');
 }
 
 export function submitWorkflow(workflowId, data) {
-  return http.post(`/api/task-exec/workflows/${workflowId}/submit`, data);
+  return http.post('/api/task-exec/workflows/' + workflowId + '/submit', data);
 }

+ 233 - 53
frontend/src/views/resource-management/ClusterConsolePage.vue

@@ -23,17 +23,21 @@
           <div class="cluster-title">
             <span class="cluster-name">{{ cluster.clusterName }}</span>
             <span v-if="cluster.version" class="version-tag">{{ cluster.version }}</span>
-            <span :class="['status-badge', cluster.status === 'RUNNING' ? 'badge-running' : 'badge-stopped']">
-              {{ cluster.status === 'RUNNING' ? '运行中' : '已停止' }}
+            <span :class="['status-badge', resolveClusterStatusClass(cluster.status)]">
+              {{ resolveClusterStatusLabel(cluster.status) }}
             </span>
           </div>
           <div class="action-buttons">
             <button class="btn btn-default" :disabled="lifecycleLoading" @click="openEditClusterDialog">编辑信息</button>
             <template v-if="cluster.status === 'RUNNING'">
-              <button class="btn btn-danger" :disabled="lifecycleLoading" @click="handleStop">
+              <button class="btn btn-danger" :disabled="lifecycleLoading || !isClusterRunning" @click="handleStop">
                 {{ lifecycleLoading ? '处理中...' : '停止' }}
               </button>
-              <button class="btn btn-default" :disabled="lifecycleLoading" @click="handleRestart">重启</button>
+              <button class="btn btn-default" :disabled="lifecycleLoading || !isClusterRunning" @click="handleRestart">重启</button>
+            </template>
+            <template v-else-if="isBusyStatus(cluster.status)">
+              <button class="btn btn-default" disabled>{{ cluster.status === CLUSTER_STATUS_SCALING ? '扩缩容中...' : '创建中...' }}</button>
+              <button class="btn btn-danger" disabled>销毁</button>
             </template>
             <template v-else>
               <button class="btn btn-primary" :disabled="lifecycleLoading" @click="handleStart">
@@ -83,23 +87,54 @@
       <section class="panel flex-fill">
         <div class="panel-title-row">
           <div class="panel-title">节点列表</div>
+          <div v-if="cluster.componentType?.toUpperCase() === 'SPARK'" class="panel-actions">
+            <button class="btn btn-primary btn-xs" :disabled="!isClusterRunning" @click="openScaleOutDialog">扩容</button>
+            <button class="btn btn-danger btn-xs" :disabled="!isClusterRunning" @click="openScaleInDialog">缩容</button>
+          </div>
         </div>
         <div v-if="nodeError" class="error-banner"><span>{{ nodeError }}</span><button class="error-banner-close" @click="nodeError = ''">&times;</button></div>
         <div v-if="loadingNodes" class="loading-tip">加载中...</div>
         <div v-else-if="clusterNodes.length === 0" class="empty-tip">暂无节点数据</div>
 
-        <div v-else-if="['SPARK', 'STARROCKS'].includes(cluster.componentType?.toUpperCase())" class="nodes-container">
-          <!-- Column 1: Master / Frontend -->
+        <div v-else-if="cluster.componentType?.toUpperCase() === 'SPARK'" class="nodes-container">
+          <div class="node-table-wrapper mini-table">
+            <table class="node-table">
+              <thead>
+                <tr>
+                  <th style="width: 10%;">ID</th>
+                  <th style="width: 16%;">IP 地址</th>
+                  <th style="width: 12%;">CPU 核心数</th>
+                  <th style="width: 12%;">内存 (GB)</th>
+                  <th style="width: 12%;">SSD (GB)</th>
+                  <th style="width: 16%;">创建时间</th>
+                  <th style="width: 8%;">状态</th>
+                </tr>
+              </thead>
+              <tbody>
+                <tr v-for="node in sparkNodes" :key="node.nodeId">
+                  <td class="id-text">{{ node.nodeId }}</td>
+                  <td class="mono-text">{{ node.ipAddress || '-' }}</td>
+                  <td class="mono-text">{{ node.cpuCores ?? '-' }}</td>
+                  <td class="mono-text">{{ node.ramGb ?? '-' }}</td>
+                  <td class="mono-text">{{ node.ssdGb ?? '-' }}</td>
+                  <td>{{ formatTime(node.createdTime) }}</td>
+                  <td><span :class="['status-dot', resolveNodeStatusClass(node.status)]"></span> {{ resolveNodeStatusLabel(node.status) }}</td>
+                </tr>
+              </tbody>
+            </table>
+          </div>
+        </div>
+
+        <div v-else-if="cluster.componentType?.toUpperCase() === 'STARROCKS'" class="nodes-container">
           <div class="node-column">
             <div class="column-header">
-              <span class="column-title">{{ cluster.componentType === 'SPARK' ? 'Master' : 'Frontend' }} 节点</span>
+              <span class="column-title">Frontend 节点</span>
             </div>
             <div class="node-table-wrapper mini-table">
               <table class="node-table">
                 <thead>
                   <tr>
                     <th style="width: 10%;">ID</th>
-                    <th style="width: 14%;">节点名称</th>
                     <th style="width: 16%;">IP 地址</th>
                     <th style="width: 12%;">CPU 核心数</th>
                     <th style="width: 12%;">内存 (GB)</th>
@@ -111,13 +146,12 @@
                 <tbody>
                   <tr v-for="node in frontNodes" :key="node.nodeId">
                     <td class="id-text">{{ node.nodeId }}</td>
-                    <td>{{ node.nodeName || '-' }}</td>
                     <td class="mono-text">{{ node.ipAddress || '-' }}</td>
                     <td class="mono-text">{{ node.cpuCores ?? '-' }}</td>
                     <td class="mono-text">{{ node.ramGb ?? '-' }}</td>
                     <td class="mono-text">{{ node.ssdGb ?? '-' }}</td>
                     <td>{{ formatTime(node.createdTime) }}</td>
-                    <td><span :class="['status-dot', node.status === 'RUNNING' ? 'dot-running' : 'dot-stopped']"></span> {{ node.status === 'RUNNING' ? '运行中' : '停止' }}</td>
+                    <td><span :class="['status-dot', resolveNodeStatusClass(node.status)]"></span> {{ resolveNodeStatusLabel(node.status) }}</td>
                   </tr>
                 </tbody>
               </table>
@@ -126,13 +160,12 @@
 
           <div class="node-divider"></div>
 
-          <!-- Column 2: Worker / Backend -->
           <div class="node-column">
             <div class="column-header">
-              <span class="column-title">{{ cluster.componentType === 'SPARK' ? 'Worker' : 'Backend' }} 节点</span>
+              <span class="column-title">Backend 节点</span>
               <div class="panel-actions">
-                <button class="btn btn-primary btn-xs" @click="openScaleOutDialog">扩容</button>
-                <button class="btn btn-danger btn-xs" @click="openScaleInDialog">缩容</button>
+                <button class="btn btn-primary btn-xs" :disabled="!isClusterRunning" @click="openScaleOutDialog">扩容</button>
+                <button class="btn btn-danger btn-xs" :disabled="!isClusterRunning" @click="openScaleInDialog">缩容</button>
               </div>
             </div>
             <div class="node-table-wrapper mini-table">
@@ -140,7 +173,6 @@
                 <thead>
                   <tr>
                     <th style="width: 10%;">ID</th>
-                    <th style="width: 14%;">节点名称</th>
                     <th style="width: 16%;">IP 地址</th>
                     <th style="width: 12%;">CPU 核心数</th>
                     <th style="width: 12%;">内存 (GB)</th>
@@ -152,13 +184,12 @@
                 <tbody>
                   <tr v-for="node in backNodes" :key="node.nodeId">
                     <td class="id-text">{{ node.nodeId }}</td>
-                    <td>{{ node.nodeName || '-' }}</td>
                     <td class="mono-text">{{ node.ipAddress || '-' }}</td>
                     <td class="mono-text">{{ node.cpuCores ?? '-' }}</td>
                     <td class="mono-text">{{ node.ramGb ?? '-' }}</td>
                     <td class="mono-text">{{ node.ssdGb ?? '-' }}</td>
                     <td>{{ formatTime(node.createdTime) }}</td>
-                    <td><span :class="['status-dot', node.status === 'RUNNING' ? 'dot-running' : 'dot-stopped']"></span> {{ node.status === 'RUNNING' ? '运行中' : '停止' }}</td>
+                    <td><span :class="['status-dot', resolveNodeStatusClass(node.status)]"></span> {{ resolveNodeStatusLabel(node.status) }}</td>
                   </tr>
                 </tbody>
               </table>
@@ -171,7 +202,6 @@
             <thead>
               <tr>
                 <th>节点 ID</th>
-                <th>节点名称</th>
                 <th>节点角色</th>
                 <th>IP 地址</th>
                 <th>CPU 核心数</th>
@@ -184,7 +214,6 @@
             <tbody>
               <tr v-for="node in clusterNodes" :key="node.nodeId">
                 <td class="id-text">{{ node.nodeId }}</td>
-                <td>{{ node.nodeName || '-' }}</td>
                 <td class="mono-text">{{ node.nodeRole || '-' }}</td>
                 <td class="mono-text">{{ node.ipAddress || '-' }}</td>
                 <td class="mono-text">{{ node.cpuCores ?? '-' }}</td>
@@ -545,7 +574,7 @@
                 <span v-if="!loadingIdleMachines" class="hint">(当前库存 {{ idleMachines.length }} 台空闲)</span>
               </label>
               <input v-model.number="scaleForm.count" type="number" min="1" :max="idleMachines.length || 100" placeholder="请输入要增加的节点数" />
-              <div class="form-hint">系统将自动挑选空闲机器并加入集群,角色为计算节点(Worker/Backend)。</div>
+              <div class="form-hint">系统将自动挑选空闲机器并加入集群。</div>
             </div>
 
             <div v-else class="form-item">
@@ -563,7 +592,7 @@
             </div>
             <div class="modal-footer">
             <button class="btn btn-default" :disabled="scalingSubmitting" @click="showScaleOutDialog = false">取消</button>
-            <button class="btn btn-primary" :disabled="scalingSubmitting" @click="handleScaleOut">
+            <button class="btn btn-primary" :disabled="scalingSubmitting || !isClusterRunning" @click="handleScaleOut">
             {{ scalingSubmitting ? '提交中...' : '确认扩容' }}
             </button>
             </div>
@@ -591,15 +620,15 @@
             <div v-if="scaleForm.selectMode === 'AUTO'" class="form-item">
               <label>
                 缩容数量 <span class="required">*</span>
-                <span class="hint">(当前有 {{ scalableNodes.length }} 个计算节点)</span>
+                <span class="hint">(当前有 {{ scalableNodes.length }} 个可缩容节点)</span>
               </label>
               <input v-model.number="scaleForm.count" type="number" min="1" :max="Math.max(0, scalableNodes.length - 1)" placeholder="请输入要减少的节点数" />
-              <div class="form-hint">系统将优先选择低负载的计算节点(Worker/Backend)。注意:缩容后必须至少保留 1 个。</div>
+              <div class="form-hint">系统将优先选择低负载节点。注意:缩容后必须至少保留 1 个。</div>
             </div>
 
             <div v-else class="form-item">
-              <label>选择要移除的计算节点 (当前 {{ scalableNodes.length }} 个,已选 {{ scaleForm.selectedNodeIds.length }} 个)</label>
-              <div v-if="scalableNodes.length === 0" class="empty-tip">暂无符合条件的计算节点</div>
+              <label>选择要移除的节点 (当前 {{ scalableNodes.length }} 个,已选 {{ scaleForm.selectedNodeIds.length }} 个)</label>
+              <div v-if="scalableNodes.length === 0" class="empty-tip">暂无符合条件的节点</div>
               <div v-else class="machine-list-compact">
                 <label v-for="n in scalableNodes" :key="n.nodeId" class="machine-item-compact">
                   <input type="checkbox" :value="n.nodeId" v-model="scaleForm.selectedNodeIds" />
@@ -611,7 +640,7 @@
             </div>
             </div>        <div class="modal-footer">
           <button class="btn btn-default" :disabled="scalingSubmitting" @click="showScaleInDialog = false">取消</button>
-          <button class="btn btn-danger" :disabled="scalingSubmitting" @click="handleScaleIn">
+          <button class="btn btn-danger" :disabled="scalingSubmitting || !isClusterRunning" @click="handleScaleIn">
             {{ scalingSubmitting ? '提交中...' : '确认缩容' }}
           </button>
         </div>
@@ -705,6 +734,48 @@ function formatTime(value) {
   return value.replace('T', ' ').slice(0, 16);
 }
 
+const CLUSTER_STATUS_CREATING = 'CREATING';
+const CLUSTER_STATUS_SCALING = 'SCALING';
+const SCALING_STATUS_POLL_INTERVAL_MS = 3000;
+
+function isCreatingStatus(status) {
+  return status === CLUSTER_STATUS_CREATING;
+}
+
+function isBusyStatus(status) {
+  return status === CLUSTER_STATUS_CREATING || status === CLUSTER_STATUS_SCALING;
+}
+
+function isRunningStatus(status) {
+  return status === 'RUNNING';
+}
+
+function resolveClusterStatusLabel(status) {
+  if (status === 'RUNNING') return '运行中';
+  if (status === CLUSTER_STATUS_CREATING) return '创建中';
+  if (status === CLUSTER_STATUS_SCALING) return '扩缩容中';
+  return '已停止';
+}
+
+function resolveClusterStatusClass(status) {
+  if (status === 'RUNNING') return 'badge-running';
+  if (status === CLUSTER_STATUS_CREATING) return 'badge-creating';
+  if (status === CLUSTER_STATUS_SCALING) return 'badge-scaling';
+  return 'badge-stopped';
+}
+
+function resolveNodeStatusLabel(status) {
+  if (status === 'RUNNING') return '运行中';
+  if (status === CLUSTER_STATUS_CREATING) return '创建中';
+  return '停止';
+}
+
+function resolveNodeStatusClass(status) {
+  if (status === 'RUNNING') return 'dot-running';
+  if (status === CLUSTER_STATUS_CREATING) return 'dot-creating';
+  return 'dot-stopped';
+}
+
 function statusClass(status) {
   if (status === 'SUCCESS') return 'status-ok';
   if (status === 'FAILED') return 'status-fail';
@@ -713,6 +784,7 @@ function statusClass(status) {
 
 // Cluster info
 const cluster = reactive({});
+const isClusterRunning = computed(() => isRunningStatus(cluster.status));
 const clusterError = ref('');
 const lifecycleLoading = ref(false);
 const showEditClusterDialog = ref(false);
@@ -729,13 +801,23 @@ function loadClusterFromState() {
 async function fetchCluster() {
   if (!Number.isFinite(clusterId) || clusterId <= 0) {
     clusterError.value = '集群 ID 无效';
-    return;
+    return false;
   }
   try {
     const { data } = await getCluster(clusterId);
     Object.assign(cluster, data || {});
+    if (isCreatingStatus(cluster.status)) {
+      const clusterName = cluster.clusterName || `#${clusterId}`;
+      await router.replace({
+        path: '/resource/clusters',
+        query: { message: `集群「${clusterName}」创建中,控制台暂不可打开` }
+      });
+      return false;
+    }
+    return true;
   } catch (err) {
     clusterError.value = err?.response?.data?.message || '加载集群详情失败';
+    return false;
   }
 }
 
@@ -748,6 +830,7 @@ async function handleStart() {
 }
 
 async function handleStop() {
+  if (!isClusterRunning.value) return;
   if (!(await strongConfirm(`确认停止集群「${cluster.clusterName}」?`))) return;
   lifecycleLoading.value = true;
   clusterError.value = '';
@@ -757,6 +840,7 @@ async function handleStop() {
 }
 
 async function handleRestart() {
+  if (!isClusterRunning.value) return;
   if (!(await strongConfirm(`确认重启集群「${cluster.clusterName}」?`))) return;
   lifecycleLoading.value = true;
   clusterError.value = '';
@@ -838,6 +922,8 @@ const centerPrompt = reactive({
   type: 'success'
 });
 let centerPromptTimer = null;
+let scalingStatusPollTimer = null;
+let scalingStatusPollInFlight = false;
 
 function showCenterPrompt(text, type = 'success') {
   if (centerPromptTimer) {
@@ -853,6 +939,10 @@ function showCenterPrompt(text, type = 'success') {
 }
 
 async function openScaleOutDialog() {
+  if (!isClusterRunning.value) {
+    clusterError.value = '仅 RUNNING 状态支持扩容';
+    return;
+  }
   scaleForm.selectMode = 'AUTO';
   scaleForm.count = 1;
   scaleForm.selectedMachineIds = [];
@@ -871,6 +961,10 @@ async function openScaleOutDialog() {
 }
 
 function openScaleInDialog() {
+  if (!isClusterRunning.value) {
+    clusterError.value = '仅 RUNNING 状态支持缩容';
+    return;
+  }
   scaleForm.selectMode = 'AUTO';
   scaleForm.count = 1;
   scaleForm.selectedNodeIds = [];
@@ -879,6 +973,10 @@ function openScaleInDialog() {
 }
 
 async function handleScaleOut() {
+  if (!isClusterRunning.value) {
+    scalingDialogError.value = '仅 RUNNING 状态支持扩容';
+    return;
+  }
   if (scaleForm.selectMode === 'AUTO') {
     if (!scaleForm.count || scaleForm.count <= 0) {
       scalingDialogError.value = '请输入有效的扩容数量';
@@ -902,8 +1000,7 @@ async function handleScaleOut() {
     }
     await scaleOut(clusterId, payload);
     showScaleOutDialog.value = false;
-    // Refresh events if on scaling tab or just show a message
-    fetchScalingEvents();
+    await refreshAfterScalingSubmitted();
     showCenterPrompt('扩容任务已提交', 'success');
   } catch (err) {
     scalingDialogError.value = err?.response?.data?.message || '扩容失败';
@@ -913,6 +1010,10 @@ async function handleScaleOut() {
 }
 
 async function handleScaleIn() {
+  if (!isClusterRunning.value) {
+    scalingDialogError.value = '仅 RUNNING 状态支持缩容';
+    return;
+  }
   const currentScalableCount = scalableNodes.value.length;
   
   if (scaleForm.selectMode === 'AUTO') {
@@ -921,7 +1022,7 @@ async function handleScaleIn() {
       return;
     }
     if (currentScalableCount - scaleForm.count < 1) {
-      scalingDialogError.value = `缩容数量过多。当前有 ${currentScalableCount} 个计算节点,缩容后必须至少保留 1 个。`;
+      scalingDialogError.value = `缩容数量过多。当前有 ${currentScalableCount} 个可缩容节点,缩容后必须至少保留 1 个。`;
       return;
     }
   } else {
@@ -930,7 +1031,7 @@ async function handleScaleIn() {
       return;
     }
     if (currentScalableCount - scaleForm.selectedNodeIds.length < 1) {
-      scalingDialogError.value = `无法移除所有计算节点。缩容后必须至少保留 1 个计算节点。`;
+      scalingDialogError.value = '无法移除所有可缩容节点。缩容后必须至少保留 1 个节点。';
       return;
     }
   }
@@ -946,7 +1047,7 @@ async function handleScaleIn() {
     }
     await scaleIn(clusterId, payload);
     showScaleInDialog.value = false;
-    fetchScalingEvents();
+    await refreshAfterScalingSubmitted();
     showCenterPrompt('缩容任务已提交', 'success');
   } catch (err) {
     scalingDialogError.value = err?.response?.data?.message || '缩容失败';
@@ -958,8 +1059,15 @@ async function handleScaleIn() {
 // Cluster nodes
 const clusterNodes = ref([]);
 
+const isSparkCluster = computed(() => cluster.componentType?.toUpperCase() === 'SPARK');
+
+const sparkNodes = computed(() => {
+  if (!isSparkCluster.value) return [];
+  return clusterNodes.value;
+});
+
 const frontNodes = computed(() => {
-  const frontRoles = new Set(['spark_master', 'starrocks_frontend', 'starrocks_fe', 'master', 'frontend', 'fe']);
+  const frontRoles = new Set(['starrocks_frontend', 'starrocks_fe', 'frontend', 'fe']);
   return clusterNodes.value.filter(node => {
     const role = node.nodeRole?.toLowerCase();
     return frontRoles.has(role);
@@ -967,7 +1075,7 @@ const frontNodes = computed(() => {
 });
 
 const backNodes = computed(() => {
-  const backRoles = new Set(['spark_worker', 'starrocks_be', 'starrocks_backend', 'worker', 'be', 'backend']);
+  const backRoles = new Set(['starrocks_be', 'starrocks_backend', 'worker', 'be', 'backend']);
   return clusterNodes.value.filter(node => {
     const role = node.nodeRole?.toLowerCase();
     return backRoles.has(role);
@@ -975,7 +1083,7 @@ const backNodes = computed(() => {
 });
 
 const scalableNodes = computed(() => {
-  return backNodes.value;
+  return isSparkCluster.value ? sparkNodes.value : backNodes.value;
 });
 
 const totalCpuCores = computed(() => clusterNodes.value.reduce((sum, n) => sum + (n.cpuCores ?? 0), 0));
@@ -1007,6 +1115,9 @@ const savingConfig = ref(false);
 const rollingBack = ref(false);
 const configError = ref('');
 const loadingConfig = ref(false);
+const backendManagedSparkConfigKeys = new Set([
+  'spark.sql.catalog.polaris.uri',
+]);
 
 const configHistoryPage = ref(1);
 const configHistoryPageSize = 10;
@@ -1059,7 +1170,9 @@ function parseConfigToItems(jsonStr) {
   try {
     const obj = JSON.parse(jsonStr);
     if (obj && typeof obj === 'object' && !Array.isArray(obj)) {
-      return Object.entries(obj).map(([key, value]) => ({ key, value: String(value) }));
+      return Object.entries(obj)
+        .filter(([key]) => !isBackendManagedSparkConfigKey(key))
+        .map(([key, value]) => ({ key, value: String(value) }));
     }
   } catch { /* ignore */ }
   return [];
@@ -1068,11 +1181,18 @@ function parseConfigToItems(jsonStr) {
 function itemsToConfigContent() {
   const obj = {};
   for (const item of configItems.value) {
-    if (item.key.trim()) obj[item.key.trim()] = item.value;
+    if (!item.key.trim()) continue;
+    if (isBackendManagedSparkConfigKey(item.key)) continue;
+    obj[item.key.trim()] = item.value;
   }
   return JSON.stringify(obj);
 }
 
+function isBackendManagedSparkConfigKey(key) {
+  if (!key || typeof key !== 'string') return false;
+  return backendManagedSparkConfigKeys.has(key.trim().toLowerCase());
+}
+
 async function fetchConfigVersions() {
   loadingConfig.value = true;
   configError.value = '';
@@ -1106,6 +1226,11 @@ async function fetchConfigVersions() {
 async function handleCommitConfig() {
   const validItems = configItems.value.filter(item => item.key.trim());
   if (validItems.length === 0) { configError.value = '请至少添加一个配置项'; return; }
+  const managedKey = validItems.find(item => isBackendManagedSparkConfigKey(item.key));
+  if (managedKey) {
+    configError.value = `配置键 "${managedKey.key}" 由系统托管,请在后端环境配置中设置`;
+    return;
+  }
   const dupKey = validItems.find((item, i) => validItems.findIndex(o => o.key === item.key) !== i);
   if (dupKey) { configError.value = `配置键 "${dupKey.key}" 重复`; return; }
   savingConfig.value = true;
@@ -1250,35 +1375,92 @@ async function fetchScalingEvents() {
 function prevEventsPage() { if (eventsPage.value <= 1) return; eventsPage.value--; fetchScalingEvents(); }
 function nextEventsPage() { eventsPage.value++; fetchScalingEvents(); }
 
-function fetchDataByActiveTab(tab) {
+async function refreshAfterScalingSubmitted() {
+  eventsPage.value = 1;
+  await Promise.all([
+    fetchCluster(),
+    fetchClusterNodes(),
+    fetchScalingEvents()
+  ]);
+  if (cluster.status === CLUSTER_STATUS_SCALING) {
+    startScalingStatusPolling();
+  }
+}
+
+function stopScalingStatusPolling() {
+  if (scalingStatusPollTimer) {
+    clearInterval(scalingStatusPollTimer);
+    scalingStatusPollTimer = null;
+  }
+}
+
+async function pollScalingStatus() {
+  if (scalingStatusPollInFlight) {
+    return;
+  }
+  scalingStatusPollInFlight = true;
+  try {
+    const available = await fetchCluster();
+    if (!available || cluster.status !== CLUSTER_STATUS_SCALING) {
+      stopScalingStatusPolling();
+    }
+  } finally {
+    scalingStatusPollInFlight = false;
+  }
+}
+
+function startScalingStatusPolling() {
+  if (scalingStatusPollTimer) {
+    return;
+  }
+  scalingStatusPollTimer = setInterval(() => {
+    void pollScalingStatus();
+  }, SCALING_STATUS_POLL_INTERVAL_MS);
+}
+
+async function fetchDataByActiveTab(tab) {
   if (tab === 'detail') {
-    fetchCluster();
-    fetchClusterNodes();
+    const available = await fetchCluster();
+    if (available) {
+      await fetchClusterNodes();
+    }
     return;
   }
   if (tab === 'config') {
-    fetchConfigVersions();
+    await fetchConfigVersions();
     return;
   }
   if (tab === 'scaling') {
-    fetchScalingRules();
-    fetchScalingEvents();
+    await Promise.all([fetchScalingRules(), fetchScalingEvents()]);
   }
 }
 
 watch(activeTab, tab => {
-  fetchDataByActiveTab(tab);
+  void fetchDataByActiveTab(tab);
+});
+
+watch(() => cluster.status, (newStatus, oldStatus) => {
+  if (newStatus === CLUSTER_STATUS_SCALING) {
+    startScalingStatusPolling();
+    return;
+  }
+  stopScalingStatusPolling();
+  if (oldStatus === CLUSTER_STATUS_SCALING && newStatus) {
+    eventsPage.value = 1;
+    void Promise.all([fetchClusterNodes(), fetchScalingEvents()]);
+  }
 });
 
 onMounted(() => {
   loadClusterFromState();
-  fetchDataByActiveTab(activeTab.value);
+  void fetchDataByActiveTab(activeTab.value);
   getMe().then(({ data }) => { user.username = data.username; user.role = data.role; }).catch(() => {});
   document.addEventListener('click', closeMenu);
 });
 
 onUnmounted(() => {
   document.removeEventListener('click', closeMenu);
+  stopScalingStatusPolling();
   if (centerPromptTimer) {
     clearTimeout(centerPromptTimer);
     centerPromptTimer = null;
@@ -1465,6 +1647,7 @@ onUnmounted(() => {
 }
 
 .dot-running { background: var(--success); }
+.dot-creating { background: #409eff; }
 .dot-stopped { background: var(--text-muted); }
 
 .btn-xs {
@@ -1529,7 +1712,9 @@ onUnmounted(() => {
 .cluster-name { font-size: 18px; font-weight: 600; }
 .version-tag { font-size: 12px; padding: 2px 8px; background: #f4f4f5; border-radius: 4px; color: var(--text-muted); }
 .status-badge { padding: 3px 12px; border-radius: 4px; font-size: 12px; font-weight: 500; }
-.badge-running { background: var(--accent); color: #fff; }
+.badge-running { background: #67c23a; color: #fff; }
+.badge-creating { background: #409eff; color: #fff; }
+.badge-scaling { background: #e6a23c; color: #fff; }
 .badge-stopped { background: #909399; color: #fff; }
 .action-buttons { display: flex; gap: 10px; }
 
@@ -1648,7 +1833,7 @@ onUnmounted(() => {
 
 .config-item-row { display: flex; align-items: center; gap: 12px; }
 .config-item-row label {
-  width: 180px;
+  width: 240px;
   font-size: 13px;
   color: var(--text-regular);
   font-weight: 500;
@@ -1657,7 +1842,7 @@ onUnmounted(() => {
   overflow: hidden;
   text-overflow: ellipsis;
 }
-.config-inputs { display: flex; gap: 8px; flex: 1; min-width: 0; }
+.config-inputs { display: flex; gap: 8px; flex: 0 1 360px; max-width: 100%; min-width: 0; }
 .config-inputs input {
   flex: 1;
   min-width: 0;
@@ -1928,8 +2113,3 @@ onUnmounted(() => {
   }
 }
 </style>
-
-
-
-
-

+ 158 - 32
frontend/src/views/resource-management/ClusterPage.vue

@@ -27,8 +27,8 @@
               <span class="cluster-name" :title="cluster.clusterName">{{ cluster.clusterName }}</span>
               <span :class="['type-tag', 'type-' + cluster.componentType?.toLowerCase()]">{{ cluster.componentType }}</span>
             </div>
-            <span :class="['status-badge', cluster.status === 'RUNNING' ? 'badge-running' : 'badge-stopped']">
-              {{ cluster.status === 'RUNNING' ? '运行中' : '已停止' }}
+            <span :class="['status-badge', resolveClusterStatusClass(cluster.status)]">
+              {{ resolveClusterStatusLabel(cluster.status) }}
             </span>
           </div>
 
@@ -56,11 +56,19 @@
           </div>
 
           <div class="card-actions" @click.stop>
-            <button class="btn btn-default console-btn" @click="goToConsole(cluster)">控制台</button>
+            <button
+              class="btn btn-default console-btn"
+              :disabled="isCreatingStatus(cluster.status)"
+              @click="goToConsole(cluster)"
+            >控制台</button>
             <template v-if="cluster.status === 'RUNNING'">
               <button class="btn btn-danger" :disabled="actionLoading[cluster.clusterId]" @click="handleStop(cluster)">停止</button>
               <button class="btn btn-default" :disabled="actionLoading[cluster.clusterId]" @click="handleRestart(cluster)">重启</button>
             </template>
+            <template v-else-if="isBusyStatus(cluster.status)">
+              <button class="btn btn-default" disabled>{{ cluster.status === CLUSTER_STATUS_SCALING ? '扩缩容中...' : '创建中...' }}</button>
+              <button class="btn btn-danger" disabled>销毁</button>
+            </template>
             <template v-else>
               <button class="btn btn-primary" :disabled="actionLoading[cluster.clusterId]" @click="handleStart(cluster)">启动</button>
               <button class="btn btn-danger" :disabled="actionLoading[cluster.clusterId]" @click="handleDestroy(cluster)">销毁</button>
@@ -134,14 +142,33 @@
               </div>
             </div>
 
-            <div class="section-label">节点配置</div>
+            <div class="section-label">
+              节点配置
+              <span
+                v-if="selectedEngine === 'STARROCKS'"
+                class="section-help-icon question-circle soft"
+                title="FE/BE 节点组内机器配置应保持一致,不一致时将按最小配置生效。"
+                aria-label="StarRocks 节点配置说明"
+                tabindex="0"
+              >?</span>
+            </div>
             <div v-if="currentEngine.lockedCounts" class="locked-counts-tip">
               {{ currentEngine.name }} 固定部署 1 个节点
             </div>
             <div v-else class="role-counts">
               <div v-for="role in currentEngine.roles" :key="role" class="role-count-item">
-                <label>{{ currentEngine.roleLabels[role] }} 节点数</label>
+                <label class="role-count-label">
+                  {{ currentEngine.roleLabels[role] }} 节点数
+                  <span
+                    v-if="getStarRocksRoleHelpText(role)"
+                    class="field-help-icon question-circle soft"
+                    :title="getStarRocksRoleHelpText(role)"
+                    :aria-label="`${currentEngine.roleLabels[role]} 节点说明`"
+                    tabindex="0"
+                  >?</span>
+                </label>
                 <input v-model.number="nodeCounts[role]" type="number" min="1" max="20" />
+                <div v-if="isStarRocksFeRole(role)" class="field-tip">Frontend 节点数必须为奇数(1/3/5...)</div>
               </div>
             </div>
 
@@ -165,7 +192,7 @@
                     :style="{ background: currentEngine.color + '18', color: currentEngine.color, borderColor: currentEngine.color + '40' }"
                   >{{ currentEngine.roleLabels[role] }}</span>
                   <span class="role-select-hint">
-                    需选 {{ nodeCounts[role] || 1 }} 台 &nbsp;·&nbsp; 已选 {{ (roleMachineIds[role] || []).length }} 台
+                    需选 {{ getRequiredNodeCount(role) }} 台 &nbsp;·&nbsp; 已选 {{ (roleMachineIds[role] || []).length }} 台
                   </span>
                 </div>
                 <div class="machine-checklist">
@@ -209,7 +236,7 @@
 
 <script setup>
 import { computed, onMounted, reactive, ref, watch } from 'vue';
-import { useRouter } from 'vue-router';
+import { useRoute, useRouter } from 'vue-router';
 import Sidebar from '../../components/Sidebar.vue';
 import { strongConfirm } from '../../utils/strongConfirm';
 import {
@@ -218,6 +245,7 @@ import {
 import { queryStocks } from '../../api/stock';
 
 const router = useRouter();
+const route = useRoute();
 
 const ENGINES = [
   {
@@ -227,9 +255,9 @@ const ENGINES = [
     logo: '/spark.svg',
     tagline: '大数据批处理',
     desc: '通用分布式计算引擎,生态成熟,适合 ETL 数据处理、实时流计算和 MLlib 机器学习。',
-    roles: ['master', 'worker'],
-    roleLabels: { master: 'Master', worker: 'Worker' },
-    defaultCounts: { master: 1, worker: 3 },
+    roles: ['node'],
+    roleLabels: { node: 'Node' },
+    defaultCounts: { node: 4 },
     defaultVersions: ['3.5.1'],
   },
   {
@@ -242,20 +270,7 @@ const ENGINES = [
     roles: ['fe', 'be'],
     roleLabels: { fe: 'Frontend', be: 'Backend' },
     defaultCounts: { fe: 1, be: 3 },
-    defaultVersions: ['3.1.0'],
-  },
-  {
-    type: 'ICEBERG',
-    name: 'Apache Iceberg',
-    color: '#0EA5E9',
-    logo: '/Iceberg.svg',
-    tagline: '开放数据湖表格式',
-    desc: '面向 PB 级数据湖的开放表格式,支持 ACID 事务、时间旅行和 Schema 演进。',
-    roles: ['node'],
-    roleLabels: { node: 'Node' },
-    defaultCounts: { node: 1 },
-    defaultVersions: ['1.4.3'],
-    lockedCounts: true,
+    defaultVersions: ['3.5-latest'],
   },
 ];
 
@@ -264,6 +279,34 @@ const loading = ref(false);
 const errorMessage = ref('');
 const clusters = ref([]);
 const actionLoading = reactive({});
+const CLUSTER_STATUS_CREATING = 'CREATING';
+const CLUSTER_STATUS_SCALING = 'SCALING';
+
+function isCreatingStatus(status) {
+  return status === CLUSTER_STATUS_CREATING;
+}
+
+function isBusyStatus(status) {
+  return status === CLUSTER_STATUS_CREATING || status === CLUSTER_STATUS_SCALING;
+}
+
+function isRunningStatus(status) {
+  return status === 'RUNNING';
+}
+
+function resolveClusterStatusLabel(status) {
+  if (status === 'RUNNING') return '运行中';
+  if (status === CLUSTER_STATUS_CREATING) return '创建中';
+  if (status === CLUSTER_STATUS_SCALING) return '扩缩容中';
+  return '已停止';
+}
+
+function resolveClusterStatusClass(status) {
+  if (status === 'RUNNING') return 'badge-running';
+  if (status === CLUSTER_STATUS_CREATING) return 'badge-creating';
+  if (status === CLUSTER_STATUS_SCALING) return 'badge-scaling';
+  return 'badge-stopped';
+}
 
 function formatTime(value) {
   if (!value) return '-';
@@ -303,6 +346,10 @@ async function fetchComponentVersions() {
 }
 
 function goToConsole(cluster) {
+  if (isCreatingStatus(cluster?.status)) {
+    errorMessage.value = `集群「${cluster?.clusterName || cluster?.clusterId}」创建中,控制台暂不可打开`;
+    return;
+  }
   router.push({ path: `/resource/clusters/${cluster.clusterId}` });
 }
 
@@ -320,6 +367,7 @@ async function handleStart(cluster) {
 }
 
 async function handleStop(cluster) {
+  if (!isRunningStatus(cluster?.status)) return;
   if (!(await strongConfirm(`确认停止集群「${cluster.clusterName}」?`))) return;
   actionLoading[cluster.clusterId] = true;
   errorMessage.value = '';
@@ -334,6 +382,7 @@ async function handleStop(cluster) {
 }
 
 async function handleRestart(cluster) {
+  if (!isRunningStatus(cluster?.status)) return;
   if (!(await strongConfirm(`确认重启集群「${cluster.clusterName}」?`))) return;
   actionLoading[cluster.clusterId] = true;
   errorMessage.value = '';
@@ -387,10 +436,30 @@ function getVersionsForEngine(componentType) {
   return fallbackEngine?.defaultVersions || [];
 }
 
+function getRequiredNodeCount(role) {
+  const count = Number(nodeCounts[role]);
+  return Number.isInteger(count) && count > 0 ? count : 1;
+}
+
+function isStarRocksFeRole(role) {
+  return selectedEngine.value === "STARROCKS" && role === "fe";
+}
+
+function getStarRocksRoleHelpText(role) {
+  if (selectedEngine.value !== 'STARROCKS') return '';
+  if (role === 'fe') {
+    return 'FE(Frontend)负责元数据管理、SQL 解析和查询调度。建议配置:至少 8 核 CPU、16GB 内存、100GB SSD。';
+  }
+  if (role === 'be') {
+    return 'BE(Backend)负责数据存储、查询执行和副本管理。建议配置:至少 16 核 CPU、64GB 内存、1000GB SSD。';
+  }
+  return '';
+}
+
 // Trim role selections when node count decreases
 watch(nodeCounts, () => {
   for (const role of currentEngine.value.roles) {
-    const needed = nodeCounts[role] || 1;
+    const needed = getRequiredNodeCount(role);
     if (roleMachineIds[role] && roleMachineIds[role].length > needed) {
       roleMachineIds[role] = roleMachineIds[role].slice(0, needed);
     }
@@ -422,7 +491,7 @@ function isChecked(role, machineId) {
 }
 
 function isFull(role) {
-  const needed = nodeCounts[role] || 1;
+  const needed = getRequiredNodeCount(role);
   return (roleMachineIds[role] || []).length >= needed;
 }
 
@@ -492,8 +561,15 @@ async function handleCreate() {
     createError.value = '版本不在可选列表中';
     return;
   }
+  if (selectedEngine.value === 'STARROCKS') {
+    const frontendCount = getRequiredNodeCount('fe');
+    if (frontendCount % 2 === 0) {
+      createError.value = 'StarRocks Frontend 节点数必须是奇数';
+      return;
+    }
+  }
   for (const role of currentEngine.value.roles) {
-    const needed = nodeCounts[role] || 1;
+    const needed = getRequiredNodeCount(role);
     const selected = (roleMachineIds[role] || []).length;
     if (selected !== needed) {
       createError.value = `请为 ${currentEngine.value.roleLabels[role]} 选择 ${needed} 台机器(当前已选 ${selected} 台)`;
@@ -509,6 +585,9 @@ async function handleCreate() {
       version: createForm.version,
       nodeMachineIds: currentEngine.value.roles.flatMap(role => roleMachineIds[role] || []),
     };
+    if (selectedEngine.value === 'STARROCKS') {
+      payload.frontendNodeCount = getRequiredNodeCount('fe');
+    }
     if (createForm.description) payload.description = createForm.description;
     const { data } = await createCluster(payload);
     clusters.value.unshift(data);
@@ -521,6 +600,9 @@ async function handleCreate() {
 }
 
 onMounted(() => {
+  if (typeof route.query?.message === 'string' && route.query.message.trim()) {
+    errorMessage.value = route.query.message.trim();
+  }
   fetchClusters();
   fetchComponentVersions();
 });
@@ -740,7 +822,9 @@ onMounted(() => {
 }
 
 .status-badge { padding: 2px 10px; border-radius: 4px; font-size: 12px; font-weight: 500; flex-shrink: 0; }
-.badge-running { background: var(--accent); color: #fff; }
+.badge-running { background: #67c23a; color: #fff; }
+.badge-creating { background: #409eff; color: #fff; }
+.badge-scaling { background: #e6a23c; color: #fff; }
 .badge-stopped { background: #909399; color: #fff; }
 
 .card-info { display: flex; flex-direction: column; gap: 8px; }
@@ -913,6 +997,33 @@ onMounted(() => {
   display: flex; align-items: center; gap: 8px;
 }
 
+.question-circle {
+  border-radius: 50%;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  font-family: Arial, sans-serif;
+  line-height: 1;
+  cursor: default;
+  user-select: none;
+}
+
+/* softer visual style */
+.question-circle.soft {
+  border-color: #555;
+  color: #555;
+  background: #f8f8f8;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
+}
+
+.section-help-icon {
+  width: 14px;
+  height: 14px;
+  border: 1px solid #c0c4cc;
+  font-size: 9px;
+  font-weight: 600;
+}
+
 .section-hint { font-size: 11px; font-weight: 400; color: var(--text-muted); text-transform: none; letter-spacing: 0; }
 
 .role-counts {
@@ -927,6 +1038,25 @@ onMounted(() => {
   font-size: 12px; color: var(--text-regular); font-weight: 500;
 }
 
+.role-count-label {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+}
+
+.field-help-icon {
+  width: 12px;
+  height: 12px;
+  border: 1px solid #c0c4cc;
+  font-size: 8px;
+  font-weight: 600;
+}
+
+.field-tip {
+  font-size: 11px;
+  color: var(--text-muted);
+}
+
 .role-count-item input {
   padding: 6px 10px; border: 1px solid #dcdfe6; border-radius: 4px;
   font-size: 13px; outline: none; transition: border-color 0.2s; background: #fff; width: 80px;
@@ -1046,7 +1176,3 @@ onMounted(() => {
   }
 }
 </style>
-
-
-
-

+ 67 - 1
frontend/src/views/resource-management/StockPage.vue

@@ -3,6 +3,12 @@
     <Sidebar />
 
     <main class="main-content">
+      <transition name="toast-fade">
+        <div v-if="toast.visible" class="toast-wrap">
+          <div class="toast-box" :class="toast.type">{{ toast.message }}</div>
+        </div>
+      </transition>
+
       <section class="panel filter-panel">
         <div class="panel-header">
           <h1>库存管理</h1>
@@ -203,6 +209,8 @@ const table = reactive({
   hasMore: false
 });
 const clusterIdByMachineId = reactive({});
+const toast = reactive({ visible: false, message: '', type: 'success' });
+let toastTimer = null;
 
 function buildParams() {
   return {
@@ -282,11 +290,22 @@ async function handleDelete(item) {
     await deleteStock(item.machineId);
     table.list = table.list.filter(m => m.machineId !== item.machineId);
     table.total = Math.max(0, table.total - 1);
+    showToast('删除成功');
   } catch (err) {
     table.error = err?.response?.data?.message || '删除失败';
   }
 }
 
+function showToast(message, type = 'success', duration = 2600) {
+  if (toastTimer) clearTimeout(toastTimer);
+  toast.message = message;
+  toast.type = type;
+  toast.visible = true;
+  toastTimer = setTimeout(() => {
+    toast.visible = false;
+  }, duration);
+}
+
 function emptyForm() {
   return {
     ipAddress: '',
@@ -416,6 +435,7 @@ async function submitForm() {
       const { data } = await createStock(payload);
       table.list.unshift(data);
       table.total += 1;
+      showToast('纳管成功');
     }
     dialog.visible = false;
   } catch (err) {
@@ -457,6 +477,10 @@ function goToStockConsole(item) {
 onMounted(() => {
   fetchStocks();
 });
+
+onUnmounted(() => {
+  if (toastTimer) clearTimeout(toastTimer);
+});
 </script>
 
 <style scoped>
@@ -500,6 +524,49 @@ onMounted(() => {
 .main-content::-webkit-scrollbar { width: 6px; }
 .main-content::-webkit-scrollbar-thumb { background-color: #c0c4cc; border-radius: 4px; }
 
+.toast-wrap {
+  position: fixed;
+  top: 56px;
+  left: 50%;
+  transform: translateX(-50%);
+  z-index: 3000;
+  pointer-events: none;
+}
+
+.toast-box {
+  padding: 10px 26px;
+  border-radius: 8px;
+  font-size: 14px;
+  font-weight: 500;
+  box-shadow: 0 6px 20px rgba(0, 0, 0, 0.14);
+  border: 1px solid transparent;
+  background: #f0f9eb;
+  color: #67c23a;
+}
+
+.toast-box.success {
+  background: #f0f9eb;
+  color: #67c23a;
+  border-color: #e1f3d8;
+}
+
+.toast-box.error {
+  background: #fef0f0;
+  color: #f56c6c;
+  border-color: #fbc4c4;
+}
+
+.toast-fade-enter-active,
+.toast-fade-leave-active {
+  transition: opacity 0.22s ease, transform 0.22s ease;
+}
+
+.toast-fade-enter-from,
+.toast-fade-leave-to {
+  opacity: 0;
+  transform: translateX(-50%) translateY(-8px);
+}
+
 .panel {
   background: var(--panel-bg);
   border-radius: 8px;
@@ -888,4 +955,3 @@ tbody tr:hover { background: var(--accent-hover); }
 
 
 
-

+ 52 - 27
frontend/src/views/task-build/TaskBuildWorkflowListPage.vue

@@ -170,13 +170,17 @@
                   :key="node.taskId"
                   type="button"
                   class="dag-node"
-                  :class="[taskTypeClass(node.taskType), { active: selectedTaskId === node.taskId }]"
+                  :class="{ active: selectedTaskId === node.taskId }"
                   :style="{ left: `${node.x}px`, top: `${node.y}px` }"
                   @click="selectTask(node)"
                 >
-                  <span class="node-name">{{ node.taskName || `任务-${node.taskId}` }}</span>
-                  <span class="node-id">#{{ node.taskId }}</span>
-                  <span class="node-type">{{ taskTypeLabel(node.taskType) }}</span>
+                  <span class="node-header" :class="taskTypeClass(node.taskType)">
+                    <span class="node-engine">{{ taskTypeLabel(node.taskType) }}</span>
+                  </span>
+                  <span class="node-body">
+                    <span class="node-name" :title="node.taskName || `任务-${node.taskId}`">{{ node.taskName || `任务-${node.taskId}` }}</span>
+                    <span class="node-id">#{{ node.taskId }}</span>
+                  </span>
                 </button>
               </div>
             </div>
@@ -1629,51 +1633,72 @@ tbody tr.active { background: var(--accent-soft); }
 .dag-node {
   position: absolute;
   width: 160px;
-  min-height: 84px;
-  padding: 10px 8px;
-  border: none;
-  border-radius: 6px;
-  color: #fff;
-  text-align: center;
+  min-height: 82px;
+  padding: 0;
+  border: 2px solid var(--border-color);
+  border-radius: 8px;
+  background: #fff;
   cursor: pointer;
-  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.12);
-  transition: transform 0.2s, box-shadow 0.2s;
+  box-shadow: 0 2px 4px rgba(15, 23, 42, 0.08);
+  transition: transform 0.2s, box-shadow 0.2s, border-color 0.2s;
   display: flex;
   flex-direction: column;
-  justify-content: center;
-  gap: 6px;
+  overflow: hidden;
   z-index: 2;
 }
 
 .dag-node:hover {
   transform: translateY(-2px);
-  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
+  box-shadow: 0 6px 14px rgba(15, 23, 42, 0.16);
 }
 
 .dag-node.active {
-  box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.95), 0 0 0 4px var(--accent);
+  border-color: var(--accent);
+  box-shadow: 0 0 0 4px var(--accent-soft);
+}
+
+.node-header {
+  min-height: 24px;
+  padding: 4px 8px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.node-engine {
+  font-size: 10px;
+  font-weight: 800;
+  color: #fff;
+  text-transform: uppercase;
+  letter-spacing: 0.5px;
+}
+
+.node-body {
+  padding: 10px;
+  background: #fff;
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
 }
 
 .node-name {
   font-size: 13px;
   font-weight: 600;
-  line-height: 1.3;
-  word-break: break-word;
+  color: var(--text-main);
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
 }
 
 .node-id {
+  font-family: monospace;
   font-size: 11px;
-  opacity: 0.92;
-}
-
-.node-type {
-  font-size: 11px;
-  opacity: 0.95;
+  color: var(--text-muted);
 }
 
-.node-spark { background: #E25A1C; }
-.node-starrocks { background: #7C3AED; }
-.node-default { background: #909399; }
+.node-header.node-spark { background: #E25A1C; }
+.node-header.node-starrocks { background: #7C3AED; }
+.node-header.node-default { background: #909399; }
 
 .node-details {
   border: 1px solid var(--border-color);

+ 83 - 5
frontend/src/views/task-exec/WorkflowExecutionPage.vue

@@ -192,8 +192,19 @@
                 <span class="detail-value">{{ selectedTask.retryCount ?? 0 }}</span>
               </div>
 
+              <div class="detail-title">执行结果:</div>
+              <div v-if="detail.resultError" class="result-error">{{ detail.resultError }}</div>
+              <pre class="log-console result-console">{{ taskResultText() }}</pre>
+
               <div class="detail-title">执行脚本:</div>
-              <pre class="log-console script-console">{{ selectedTask.taskContent || '-' }}</pre>
+              <div class="script-editor-wrapper">
+                <SqlEditor
+                  :modelValue="selectedTask.taskContent || String()"
+                  :readOnly="true"
+                  placeholder="暂无执行脚本"
+                  height="240px"
+                />
+              </div>
 
               <div class="detail-title">日志输出:</div>
               <pre class="log-console">{{ taskLog(selectedTask) }}</pre>
@@ -208,7 +219,8 @@
 <script setup>
 import { computed, reactive, ref } from 'vue';
 import Sidebar from '../../components/Sidebar.vue';
-import { getWorkflowStatus, listWorkflowInstances, terminateWorkflow } from '../../api/taskExec';
+import SqlEditor from '../../components/SqlEditor.vue';
+import { getTaskInstanceResult, getWorkflowStatus, listWorkflowInstances, terminateWorkflow } from '../../api/taskExec';
 
 const filters = reactive({
   workflowName: '',
@@ -232,11 +244,14 @@ const detail = reactive({
   workflowName: '',
   dagJson: '',
   taskInstances: [],
-  terminating: false
+  terminating: false,
+  resultLoading: false,
+  resultError: '',
 });
 
 const selectedWorkflowInstanceId = ref(null);
 const selectedTask = ref(null);
+const taskResultDetail = ref(null);
 
 const DAG_NODE_WIDTH = 148;
 const DAG_NODE_HEIGHT = 70;
@@ -337,6 +352,39 @@ function taskLog(task) {
   return lines.join('\n');
 }
 
+function taskResultText() {
+  if (detail.resultLoading) {
+    return '结果加载中...';
+  }
+  if (taskResultDetail.value?.resultContent) {
+    return taskResultDetail.value.resultContent;
+  }
+  if (taskResultDetail.value?.resultPreview) {
+    return taskResultDetail.value.resultPreview;
+  }
+  return '暂无执行结果';
+}
+
+async function fetchTaskResult(task) {
+  if (!selectedWorkflowInstanceId.value || !task?.taskInstanceId) {
+    taskResultDetail.value = null;
+    detail.resultLoading = false;
+    detail.resultError = '';
+    return;
+  }
+  detail.resultLoading = true;
+  detail.resultError = '';
+  taskResultDetail.value = null;
+  try {
+    const { data } = await getTaskInstanceResult(selectedWorkflowInstanceId.value, task.taskInstanceId);
+    taskResultDetail.value = data || null;
+  } catch (err) {
+    detail.resultError = err?.response?.data?.message || '查询任务结果失败';
+  } finally {
+    detail.resultLoading = false;
+  }
+}
+
 function parseTaskId(raw) {
   if (raw === undefined || raw === null || raw === '') return null;
   const num = Number(raw);
@@ -496,6 +544,7 @@ function buildDagLayout(taskInstances, dagJson) {
 
 function selectTask(task) {
   selectedTask.value = task;
+  fetchTaskResult(task);
 }
 
 async function fetchWorkflowInstances() {
@@ -532,11 +581,17 @@ function resetDetail() {
   detail.workflowName = '';
   detail.dagJson = '';
   detail.taskInstances = [];
+  detail.resultLoading = false;
+  detail.resultError = '';
+  taskResultDetail.value = null;
 }
 
 function clearSelection() {
   selectedWorkflowInstanceId.value = null;
   selectedTask.value = null;
+  taskResultDetail.value = null;
+  detail.resultLoading = false;
+  detail.resultError = '';
   resetDetail();
 }
 
@@ -544,6 +599,9 @@ async function fetchWorkflowDetail(workflowInstanceId) {
   detail.loading = true;
   detail.error = '';
   selectedTask.value = null;
+  taskResultDetail.value = null;
+  detail.resultLoading = false;
+  detail.resultError = '';
   try {
     const { data } = await getWorkflowStatus(workflowInstanceId);
     detail.state = data?.state || '';
@@ -994,8 +1052,9 @@ tbody tr.active { background: var(--accent-soft); }
 }
 
 .log-console {
-  background-color: #282c34;
-  color: #abb2bf;
+  background: #fafbfc;
+  color: #303133;
+  border: 1px solid #ebeef5;
   padding: 10px;
   border-radius: 4px;
   font-family: Consolas, Monaco, monospace;
@@ -1011,6 +1070,25 @@ tbody tr.active { background: var(--accent-soft); }
   min-height: 56px;
 }
 
+.script-editor-wrapper {
+  margin-top: 4px;
+}
+
+.script-editor-wrapper :deep(.sql-editor-outer) {
+  border-color: #ebeef5;
+  background: #fafbfc;
+}
+
+.result-console {
+  min-height: 88px;
+}
+
+.result-error {
+  margin-bottom: 8px;
+  color: var(--danger);
+  font-size: 12px;
+}
+
 .btn {
   padding: 6px 14px;
   border-radius: 4px;

+ 12 - 0
k8s-orchestrator/Dockerfile.api

@@ -0,0 +1,12 @@
+FROM golang:1.22 AS build
+WORKDIR /src
+
+COPY go.mod go.sum ./
+RUN go mod download
+
+COPY . .
+RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/orchestrator-api ./cmd/orchestrator-api
+
+FROM gcr.io/distroless/static-debian12:nonroot
+COPY --from=build /out/orchestrator-api /orchestrator-api
+ENTRYPOINT ["/orchestrator-api"]

+ 12 - 0
k8s-orchestrator/Dockerfile.controller

@@ -0,0 +1,12 @@
+FROM golang:1.22 AS build
+WORKDIR /src
+
+COPY go.mod go.sum ./
+RUN go mod download
+
+COPY . .
+RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/orchestrator-controller ./cmd/orchestrator-controller
+
+FROM gcr.io/distroless/static-debian12:nonroot
+COPY --from=build /out/orchestrator-controller /orchestrator-controller
+ENTRYPOINT ["/orchestrator-controller"]

+ 18 - 1
k8s-orchestrator/Makefile

@@ -1,4 +1,9 @@
-.PHONY: tidy test run-api run-controller
+IMAGE_REGISTRY ?= your-registry
+IMAGE_TAG ?= latest
+API_IMAGE ?= $(IMAGE_REGISTRY)/ws-orchestrator-api:$(IMAGE_TAG)
+CONTROLLER_IMAGE ?= $(IMAGE_REGISTRY)/ws-orchestrator-controller:$(IMAGE_TAG)
+
+.PHONY: tidy test run-api run-controller docker-build-api docker-build-controller docker-build docker-push
 
 tidy:
 	go mod tidy
@@ -11,3 +16,15 @@ run-api:
 
 run-controller:
 	go run ./cmd/orchestrator-controller --kubeadm-join-command "$$KUBEADM_JOIN_COMMAND"
+
+docker-build-api:
+	docker build -f Dockerfile.api -t $(API_IMAGE) .
+
+docker-build-controller:
+	docker build -f Dockerfile.controller -t $(CONTROLLER_IMAGE) .
+
+docker-build: docker-build-api docker-build-controller
+
+docker-push:
+	docker push $(API_IMAGE)
+	docker push $(CONTROLLER_IMAGE)

+ 8 - 27
k8s-orchestrator/README.md

@@ -212,37 +212,11 @@ Spark 集群创建接口的 `sparkConfig` 支持以下键:
 
 ## 6. 构建并推送 orchestrator 镜像
 
-项目默认未提供 Dockerfile,下面命令会在项目根目录生成两个 Dockerfile
+项目已内置 `Dockerfile.api` 与 `Dockerfile.controller`,可直接构建
 
 ```bash
 cd /path/to/k8s-orchestrator
 
-cat > Dockerfile.api <<'EOF'
-FROM golang:1.22 AS build
-WORKDIR /src
-COPY go.mod go.sum ./
-RUN go mod download
-COPY . .
-RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/orchestrator-api ./cmd/orchestrator-api
-
-FROM gcr.io/distroless/static-debian12:nonroot
-COPY --from=build /out/orchestrator-api /orchestrator-api
-ENTRYPOINT ["/orchestrator-api"]
-EOF
-
-cat > Dockerfile.controller <<'EOF'
-FROM golang:1.22 AS build
-WORKDIR /src
-COPY go.mod go.sum ./
-RUN go mod download
-COPY . .
-RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/orchestrator-controller ./cmd/orchestrator-controller
-
-FROM gcr.io/distroless/static-debian12:nonroot
-COPY --from=build /out/orchestrator-controller /orchestrator-controller
-ENTRYPOINT ["/orchestrator-controller"]
-EOF
-
 export REGISTRY=<your-registry>
 export TAG=v1.0.0
 
@@ -253,6 +227,13 @@ docker push ${REGISTRY}/spark-orchestrator-api:${TAG}
 docker push ${REGISTRY}/spark-orchestrator-controller:${TAG}
 ```
 
+也可以用 Makefile:
+
+```bash
+make docker-build IMAGE_REGISTRY=${REGISTRY} IMAGE_TAG=${TAG}
+make docker-push IMAGE_REGISTRY=${REGISTRY} IMAGE_TAG=${TAG}
+```
+
 ---
 
 ## 7. 部署 orchestrator 到集群

+ 20 - 0
k8s-orchestrator/internal/api/server.go

@@ -47,6 +47,8 @@ func (s *Server) Router() http.Handler {
 		r.Post("/starrocks-jobs", s.handleSubmitStarRocksJob)
 
 		r.Post("/spark-jobs", s.handleSubmitSparkJob)
+		r.Get("/spark-jobs/operations/{operationId}", s.handleGetSparkJobStatus)
+		r.Get("/spark-jobs/operations/{operationId}/result", s.handleGetSparkJobResult)
 		r.Get("/operations/{operationId}", s.handleGetOperation)
 	})
 
@@ -191,6 +193,24 @@ func (s *Server) handleSubmitSparkJob(w http.ResponseWriter, r *http.Request) {
 	writeJSON(w, http.StatusAccepted, resp)
 }
 
+func (s *Server) handleGetSparkJobStatus(w http.ResponseWriter, r *http.Request) {
+	status, err := s.svc.GetSparkJobStatusByOperation(r.Context(), chi.URLParam(r, "operationId"))
+	if err != nil {
+		writeServiceError(w, err)
+		return
+	}
+	writeJSON(w, http.StatusOK, status)
+}
+
+func (s *Server) handleGetSparkJobResult(w http.ResponseWriter, r *http.Request) {
+	result, err := s.svc.GetSparkJobResultByOperation(r.Context(), chi.URLParam(r, "operationId"))
+	if err != nil {
+		writeServiceError(w, err)
+		return
+	}
+	writeJSON(w, http.StatusOK, result)
+}
+
 func (s *Server) handleCreateStarRocksCluster(w http.ResponseWriter, r *http.Request) {
 	idKey, err := requiredIdempotencyKey(r)
 	if err != nil {

+ 81 - 4
k8s-orchestrator/internal/controller/clusteroperation_controller.go

@@ -27,6 +27,11 @@ type ClusterOperationReconciler struct {
 	Scheme *runtime.Scheme
 }
 
+const (
+	sparkNamespaceOwnerKind     = "spark"
+	starRocksNamespaceOwnerKind = "starrocks"
+)
+
 func (r *ClusterOperationReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
 	logger := log.FromContext(ctx).WithValues("clusterOperation", req.Name)
 
@@ -164,7 +169,10 @@ func (r *ClusterOperationReconciler) handleAddNode(ctx context.Context, op *orcv
 	case orcv1.NodePhaseIdle:
 		return true, 0, nodeName, "Node is idle and ready", nil
 	case orcv1.NodePhaseFailed:
-		return false, 0, nodeName, "", fmt.Errorf("node onboarding failed: %s", nodeInv.Status.LastError)
+		if nodeInv.Status.LastError != "" {
+			return false, 8 * time.Second, nodeName, fmt.Sprintf("Node onboarding retrying: %s", nodeInv.Status.LastError), nil
+		}
+		return false, 8 * time.Second, nodeName, "Node onboarding retrying", nil
 	default:
 		return false, 5 * time.Second, nodeName, fmt.Sprintf("Node phase=%s", nodeInv.Status.Phase), nil
 	}
@@ -186,15 +194,27 @@ func (r *ClusterOperationReconciler) handleDeleteNode(ctx context.Context, op *o
 	if nodeInv.Status.Phase != orcv1.NodePhaseIdle {
 		return false, 0, nodeID, "", fmt.Errorf("node %s is not idle, current phase=%s", nodeID, nodeInv.Status.Phase)
 	}
-	if nodeInv.Status.KubernetesNodeName != "" {
-		if err := k8s.ClearNodeIsolation(ctx, r.Client, nodeInv.Status.KubernetesNodeName); err != nil {
+	nodeName := nodeInv.Status.KubernetesNodeName
+	if nodeName == "" {
+		resolvedNodeName, err := k8s.FindNodeByInternalIP(ctx, r.Client, nodeInv.Spec.Address)
+		if err == nil {
+			nodeName = resolvedNodeName
+		} else if !apierrors.IsNotFound(err) {
+			return false, 0, "", "", err
+		}
+	}
+	if nodeName != "" {
+		if err := k8s.ClearNodeIsolation(ctx, r.Client, nodeName); err != nil {
+			return false, 0, "", "", err
+		}
+		if err := k8s.DeleteNodeIfExists(ctx, r.Client, nodeName); err != nil {
 			return false, 0, "", "", err
 		}
 	}
 	if err := r.Delete(ctx, &nodeInv); err != nil {
 		return false, 0, "", "", err
 	}
-	return true, 0, nodeID, "Node removed from idle pool", nil
+	return true, 0, nodeID, "Node removed from idle pool and cluster", nil
 }
 
 func (r *ClusterOperationReconciler) handleCreateCluster(ctx context.Context, op *orcv1.ClusterOperation) (bool, time.Duration, string, string, error) {
@@ -324,6 +344,11 @@ func (r *ClusterOperationReconciler) handleReleaseCluster(ctx context.Context, o
 	var cluster orcv1.SparkVirtualCluster
 	err := r.Get(ctx, types.NamespacedName{Name: clusterName}, &cluster)
 	if apierrors.IsNotFound(err) {
+		ns := util.SafeName("spark", clusterName)
+		cleanupMessage := r.tryDeleteOwnedNamespace(ctx, ns, sparkNamespaceOwnerKind, clusterName)
+		if cleanupMessage != "" {
+			return true, 0, clusterName, "Cluster already released; " + cleanupMessage, nil
+		}
 		return true, 0, clusterName, "Cluster already released", nil
 	}
 	if err != nil {
@@ -360,6 +385,10 @@ func (r *ClusterOperationReconciler) handleReleaseCluster(ctx context.Context, o
 	if err := r.Delete(ctx, &cluster); err != nil && !apierrors.IsNotFound(err) {
 		return false, 0, "", "", err
 	}
+	cleanupMessage := r.tryDeleteOwnedNamespace(ctx, ns, sparkNamespaceOwnerKind, clusterName)
+	if cleanupMessage != "" {
+		return true, 0, clusterName, "Cluster released; " + cleanupMessage, nil
+	}
 	return true, 0, clusterName, "Cluster released", nil
 }
 
@@ -587,6 +616,11 @@ func (r *ClusterOperationReconciler) handleReleaseStarRocksCluster(ctx context.C
 	var cluster orcv1.StarRocksVirtualCluster
 	err := r.Get(ctx, types.NamespacedName{Name: clusterName}, &cluster)
 	if apierrors.IsNotFound(err) {
+		ns := util.SafeName("starrocks", clusterName)
+		cleanupMessage := r.tryDeleteOwnedNamespace(ctx, ns, starRocksNamespaceOwnerKind, clusterName)
+		if cleanupMessage != "" {
+			return true, 0, clusterName, "StarRocks cluster already released; " + cleanupMessage, nil
+		}
 		return true, 0, clusterName, "StarRocks cluster already released", nil
 	}
 	if err != nil {
@@ -618,9 +652,52 @@ func (r *ClusterOperationReconciler) handleReleaseStarRocksCluster(ctx context.C
 	if err := r.Delete(ctx, &cluster); err != nil && !apierrors.IsNotFound(err) {
 		return false, 0, "", "", err
 	}
+	cleanupMessage := r.tryDeleteOwnedNamespace(ctx, ns, starRocksNamespaceOwnerKind, clusterName)
+	if cleanupMessage != "" {
+		return true, 0, clusterName, "StarRocks cluster released; " + cleanupMessage, nil
+	}
 	return true, 0, clusterName, "StarRocks cluster released", nil
 }
 
+func (r *ClusterOperationReconciler) tryDeleteOwnedNamespace(
+	ctx context.Context,
+	namespace string,
+	ownerKind string,
+	ownerName string,
+) string {
+	if strings.TrimSpace(namespace) == "" {
+		return ""
+	}
+
+	var ns corev1.Namespace
+	if err := r.Get(ctx, types.NamespacedName{Name: namespace}, &ns); err != nil {
+		if apierrors.IsNotFound(err) {
+			return "namespace already deleted"
+		}
+		return fmt.Sprintf("namespace cleanup skipped: %v", err)
+	}
+	labels := ns.GetLabels()
+	if labels[util.LabelManagedBy] != util.ManagedByValue {
+		return "namespace cleanup skipped: not managed by orchestrator"
+	}
+	if labels[util.LabelNamespaceAutoDelete] != "true" {
+		return "namespace cleanup skipped: auto-delete disabled"
+	}
+	if labels[util.LabelNamespaceOwnerKind] != ownerKind || labels[util.LabelNamespaceOwnerName] != ownerName {
+		return "namespace cleanup skipped: namespace owner mismatch"
+	}
+	if ns.DeletionTimestamp != nil {
+		return "namespace deletion in progress"
+	}
+	if err := r.Delete(ctx, &ns); err != nil {
+		if apierrors.IsNotFound(err) {
+			return "namespace already deleted"
+		}
+		return fmt.Sprintf("namespace cleanup skipped: %v", err)
+	}
+	return "namespace deletion requested"
+}
+
 func (r *ClusterOperationReconciler) ensureNoRunningPodsOnNodes(ctx context.Context, cluster *orcv1.SparkVirtualCluster, nodeIDs []string) error {
 	ns := cluster.Status.Namespace
 	if ns == "" {

+ 199 - 0
k8s-orchestrator/internal/controller/clusteroperation_controller_test.go

@@ -6,6 +6,7 @@ import (
 	"testing"
 
 	corev1 "k8s.io/api/core/v1"
+	apierrors "k8s.io/apimachinery/pkg/api/errors"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/runtime"
 	"k8s.io/apimachinery/pkg/types"
@@ -212,3 +213,201 @@ func TestHandleScaleInStarRocksClusterSerial(t *testing.T) {
 		t.Fatalf("expected completed message, got: %s", message)
 	}
 }
+
+func TestHandleDeleteNode_RemovesNodeInventoryAndKubernetesNode(t *testing.T) {
+	scheme := runtime.NewScheme()
+	utilruntime.Must(corev1.AddToScheme(scheme))
+	utilruntime.Must(orcv1.AddToScheme(scheme))
+
+	nodeInv := &orcv1.NodeInventory{
+		ObjectMeta: metav1.ObjectMeta{Name: "node-16"},
+		Spec:       orcv1.NodeInventorySpec{Address: "172.30.194.144"},
+		Status: orcv1.NodeInventoryStatus{
+			Phase:              orcv1.NodePhaseIdle,
+			KubernetesNodeName: "node-16",
+		},
+	}
+	k8sNode := &corev1.Node{
+		ObjectMeta: metav1.ObjectMeta{
+			Name: "node-16",
+			Labels: map[string]string{
+				util.LabelSparkClusterID: "spark-1",
+				util.LabelManagedBy:      util.ManagedByValue,
+			},
+		},
+	}
+	op := &orcv1.ClusterOperation{
+		Spec: orcv1.ClusterOperationSpec{
+			DeleteNode: "node-16",
+		},
+	}
+
+	fakeClient := ctrlclientfake.NewClientBuilder().WithScheme(scheme).WithObjects(nodeInv, k8sNode).Build()
+	r := &ClusterOperationReconciler{Client: fakeClient, Scheme: scheme}
+
+	done, _, _, message, err := r.handleDeleteNode(context.Background(), op)
+	if err != nil {
+		t.Fatalf("handleDeleteNode returned error: %v", err)
+	}
+	if !done {
+		t.Fatalf("expected delete operation completed")
+	}
+	if !strings.Contains(strings.ToLower(message), "cluster") {
+		t.Fatalf("expected cluster removal message, got: %s", message)
+	}
+
+	var invAfter orcv1.NodeInventory
+	if err := fakeClient.Get(context.Background(), types.NamespacedName{Name: "node-16"}, &invAfter); !apierrors.IsNotFound(err) {
+		t.Fatalf("expected node inventory deleted, get err=%v", err)
+	}
+
+	var nodeAfter corev1.Node
+	if err := fakeClient.Get(context.Background(), types.NamespacedName{Name: "node-16"}, &nodeAfter); !apierrors.IsNotFound(err) {
+		t.Fatalf("expected kubernetes node deleted, get err=%v", err)
+	}
+}
+
+func TestHandleDeleteNode_FallbackDeleteByInternalIP(t *testing.T) {
+	scheme := runtime.NewScheme()
+	utilruntime.Must(corev1.AddToScheme(scheme))
+	utilruntime.Must(orcv1.AddToScheme(scheme))
+
+	nodeInv := &orcv1.NodeInventory{
+		ObjectMeta: metav1.ObjectMeta{Name: "node-17"},
+		Spec:       orcv1.NodeInventorySpec{Address: "10.0.0.17"},
+		Status: orcv1.NodeInventoryStatus{
+			Phase: orcv1.NodePhaseIdle,
+		},
+	}
+	k8sNode := &corev1.Node{
+		ObjectMeta: metav1.ObjectMeta{Name: "real-node-17"},
+		Status: corev1.NodeStatus{
+			Addresses: []corev1.NodeAddress{
+				{Type: corev1.NodeInternalIP, Address: "10.0.0.17"},
+			},
+		},
+	}
+	op := &orcv1.ClusterOperation{
+		Spec: orcv1.ClusterOperationSpec{
+			DeleteNode: "node-17",
+		},
+	}
+
+	fakeClient := ctrlclientfake.NewClientBuilder().WithScheme(scheme).WithObjects(nodeInv, k8sNode).Build()
+	r := &ClusterOperationReconciler{Client: fakeClient, Scheme: scheme}
+
+	done, _, _, _, err := r.handleDeleteNode(context.Background(), op)
+	if err != nil {
+		t.Fatalf("handleDeleteNode returned error: %v", err)
+	}
+	if !done {
+		t.Fatalf("expected delete operation completed")
+	}
+
+	var nodeAfter corev1.Node
+	if err := fakeClient.Get(context.Background(), types.NamespacedName{Name: "real-node-17"}, &nodeAfter); !apierrors.IsNotFound(err) {
+		t.Fatalf("expected fallback matched kubernetes node deleted, get err=%v", err)
+	}
+}
+
+func TestHandleAddNode_FailedPhaseShouldKeepRetrying(t *testing.T) {
+	scheme := runtime.NewScheme()
+	utilruntime.Must(corev1.AddToScheme(scheme))
+	utilruntime.Must(orcv1.AddToScheme(scheme))
+
+	nodeInv := &orcv1.NodeInventory{
+		ObjectMeta: metav1.ObjectMeta{Name: "node-30"},
+		Status: orcv1.NodeInventoryStatus{
+			Phase:     orcv1.NodePhaseFailed,
+			LastError: "run join command: Process exited with status 1",
+		},
+	}
+	op := &orcv1.ClusterOperation{
+		Spec: orcv1.ClusterOperationSpec{
+			AddNode: &orcv1.AddNodeSpec{
+				NodeName: "node-30",
+				Address:  "10.0.0.30",
+			},
+		},
+	}
+
+	fakeClient := ctrlclientfake.NewClientBuilder().WithScheme(scheme).WithObjects(nodeInv).Build()
+	r := &ClusterOperationReconciler{Client: fakeClient, Scheme: scheme}
+
+	done, requeueAfter, resourceRef, message, err := r.handleAddNode(context.Background(), op)
+	if err != nil {
+		t.Fatalf("handleAddNode returned unexpected error: %v", err)
+	}
+	if done {
+		t.Fatalf("failed phase should keep retrying, not done")
+	}
+	if resourceRef != "node-30" {
+		t.Fatalf("unexpected resourceRef: %s", resourceRef)
+	}
+	if requeueAfter <= 0 {
+		t.Fatalf("expected positive requeueAfter for retry")
+	}
+	if !strings.Contains(strings.ToLower(message), "retry") {
+		t.Fatalf("expected retry message, got: %s", message)
+	}
+}
+
+func TestTryDeleteOwnedNamespace_DeletesOwnedNamespace(t *testing.T) {
+	scheme := runtime.NewScheme()
+	utilruntime.Must(corev1.AddToScheme(scheme))
+	utilruntime.Must(orcv1.AddToScheme(scheme))
+
+	ns := &corev1.Namespace{
+		ObjectMeta: metav1.ObjectMeta{
+			Name: "starrocks-sr-9",
+			Labels: map[string]string{
+				util.LabelManagedBy:           util.ManagedByValue,
+				util.LabelNamespaceAutoDelete: "true",
+				util.LabelNamespaceOwnerKind:  "starrocks",
+				util.LabelNamespaceOwnerName:  "sr-9",
+			},
+		},
+	}
+	fakeClient := ctrlclientfake.NewClientBuilder().WithScheme(scheme).WithObjects(ns).Build()
+	r := &ClusterOperationReconciler{Client: fakeClient, Scheme: scheme}
+
+	message := r.tryDeleteOwnedNamespace(context.Background(), "starrocks-sr-9", "starrocks", "sr-9")
+	if !strings.Contains(strings.ToLower(message), "deletion requested") {
+		t.Fatalf("expected namespace deletion request message, got: %s", message)
+	}
+
+	var nsAfter corev1.Namespace
+	if err := fakeClient.Get(context.Background(), types.NamespacedName{Name: "starrocks-sr-9"}, &nsAfter); !apierrors.IsNotFound(err) {
+		t.Fatalf("expected namespace deleted, get err=%v", err)
+	}
+}
+
+func TestTryDeleteOwnedNamespace_SkipsWhenOwnerMismatch(t *testing.T) {
+	scheme := runtime.NewScheme()
+	utilruntime.Must(corev1.AddToScheme(scheme))
+	utilruntime.Must(orcv1.AddToScheme(scheme))
+
+	ns := &corev1.Namespace{
+		ObjectMeta: metav1.ObjectMeta{
+			Name: "starrocks-sr-10",
+			Labels: map[string]string{
+				util.LabelManagedBy:           util.ManagedByValue,
+				util.LabelNamespaceAutoDelete: "true",
+				util.LabelNamespaceOwnerKind:  "starrocks",
+				util.LabelNamespaceOwnerName:  "sr-11",
+			},
+		},
+	}
+	fakeClient := ctrlclientfake.NewClientBuilder().WithScheme(scheme).WithObjects(ns).Build()
+	r := &ClusterOperationReconciler{Client: fakeClient, Scheme: scheme}
+
+	message := r.tryDeleteOwnedNamespace(context.Background(), "starrocks-sr-10", "starrocks", "sr-10")
+	if !strings.Contains(strings.ToLower(message), "owner mismatch") {
+		t.Fatalf("expected owner mismatch message, got: %s", message)
+	}
+
+	var nsAfter corev1.Namespace
+	if err := fakeClient.Get(context.Background(), types.NamespacedName{Name: "starrocks-sr-10"}, &nsAfter); err != nil {
+		t.Fatalf("expected namespace remains, get err=%v", err)
+	}
+}

+ 9 - 4
k8s-orchestrator/internal/controller/sparkvirtualcluster_controller.go

@@ -30,7 +30,8 @@ type SparkVirtualClusterReconciler struct {
 }
 
 const (
-	sparkPrePullStateConfigMap = "spark-prepull-state"
+	sparkPrePullStateConfigMap   = "spark-prepull-state"
+	sparkNamespaceOwnerKindLabel = "spark"
 )
 
 func (r *SparkVirtualClusterReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
@@ -95,9 +96,13 @@ func (r *SparkVirtualClusterReconciler) Reconcile(ctx context.Context, req ctrl.
 	ns := cluster.Spec.Namespace
 	if ns == "" {
 		ns = util.SafeName("spark", cluster.Name)
-	}
-	if err := k8s.EnsureNamespace(ctx, r.Client, ns); err != nil {
-		return ctrl.Result{}, err
+		if err := k8s.EnsureNamespaceOwnedByCluster(ctx, r.Client, ns, sparkNamespaceOwnerKindLabel, cluster.Name); err != nil {
+			return ctrl.Result{}, err
+		}
+	} else {
+		if err := k8s.EnsureNamespace(ctx, r.Client, ns); err != nil {
+			return ctrl.Result{}, err
+		}
 	}
 	if err := k8s.EnsureSparkOperatorRBAC(ctx, r.Client, ns); err != nil {
 		return ctrl.Result{}, err

+ 12 - 7
k8s-orchestrator/internal/controller/starrocksvirtualcluster_controller.go

@@ -25,10 +25,11 @@ import (
 )
 
 const (
-	starRocksClusterAPIVersion = "starrocks.com/v1"
-	starRocksClusterKind       = "StarRocksCluster"
-	starRocksDefaultFeImage    = "starrocks/fe-ubuntu:3.3.10"
-	starRocksDefaultBeImage    = "starrocks/be-ubuntu:3.3.10"
+	starRocksClusterAPIVersion       = "starrocks.com/v1"
+	starRocksClusterKind             = "StarRocksCluster"
+	starRocksDefaultFeImage          = "starrocks/fe-ubuntu:3.3.10"
+	starRocksDefaultBeImage          = "starrocks/be-ubuntu:3.3.10"
+	starRocksNamespaceOwnerKindLabel = "starrocks"
 )
 
 type StarRocksVirtualClusterReconciler struct {
@@ -78,9 +79,13 @@ func (r *StarRocksVirtualClusterReconciler) Reconcile(ctx context.Context, req c
 	ns := cluster.Spec.Namespace
 	if ns == "" {
 		ns = resolvedStarRocksNamespace(cluster.Name, "")
-	}
-	if err := k8s.EnsureNamespace(ctx, r.Client, ns); err != nil {
-		return ctrl.Result{}, err
+		if err := k8s.EnsureNamespaceOwnedByCluster(ctx, r.Client, ns, starRocksNamespaceOwnerKindLabel, cluster.Name); err != nil {
+			return ctrl.Result{}, err
+		}
+	} else {
+		if err := k8s.EnsureNamespace(ctx, r.Client, ns); err != nil {
+			return ctrl.Result{}, err
+		}
 	}
 
 	if cluster.Spec.DesiredState == orcv1.VirtualClusterDesiredReleased {

+ 77 - 0
k8s-orchestrator/internal/k8s/node.go

@@ -3,6 +3,7 @@ package k8s
 import (
 	"context"
 	"fmt"
+	"strings"
 
 	corev1 "k8s.io/api/core/v1"
 	rbacv1 "k8s.io/api/rbac/v1"
@@ -116,6 +117,23 @@ func clearNodeIsolationWithKeys(ctx context.Context, c ctrlclient.Client, nodeNa
 	return c.Update(ctx, &node)
 }
 
+func DeleteNodeIfExists(ctx context.Context, c ctrlclient.Client, nodeName string) error {
+	if nodeName == "" {
+		return nil
+	}
+	var node corev1.Node
+	if err := c.Get(ctx, types.NamespacedName{Name: nodeName}, &node); err != nil {
+		if apierrors.IsNotFound(err) {
+			return nil
+		}
+		return err
+	}
+	if err := c.Delete(ctx, &node); err != nil && !apierrors.IsNotFound(err) {
+		return err
+	}
+	return nil
+}
+
 func EnsureNamespace(ctx context.Context, c ctrlclient.Client, namespace string) error {
 	if namespace == "" {
 		return fmt.Errorf("namespace is empty")
@@ -132,6 +150,65 @@ func EnsureNamespace(ctx context.Context, c ctrlclient.Client, namespace string)
 	return c.Create(ctx, &newNS)
 }
 
+func EnsureNamespaceOwnedByCluster(ctx context.Context, c ctrlclient.Client, namespace, ownerKind, ownerName string) error {
+	if namespace == "" {
+		return fmt.Errorf("namespace is empty")
+	}
+	if strings.TrimSpace(ownerKind) == "" {
+		return fmt.Errorf("ownerKind is empty")
+	}
+	if strings.TrimSpace(ownerName) == "" {
+		return fmt.Errorf("ownerName is empty")
+	}
+
+	labels := map[string]string{
+		util.LabelManagedBy:           util.ManagedByValue,
+		util.LabelNamespaceAutoDelete: "true",
+		util.LabelNamespaceOwnerKind:  ownerKind,
+		util.LabelNamespaceOwnerName:  ownerName,
+	}
+
+	var ns corev1.Namespace
+	err := c.Get(ctx, types.NamespacedName{Name: namespace}, &ns)
+	if apierrors.IsNotFound(err) {
+		newNS := corev1.Namespace{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:   namespace,
+				Labels: labels,
+			},
+		}
+		return c.Create(ctx, &newNS)
+	}
+	if err != nil {
+		return err
+	}
+
+	if ns.Labels == nil {
+		ns.Labels = map[string]string{}
+	}
+	if managedBy, ok := ns.Labels[util.LabelManagedBy]; ok && managedBy != util.ManagedByValue {
+		return fmt.Errorf("namespace %s is not managed by orchestrator", namespace)
+	}
+	if existingOwner := strings.TrimSpace(ns.Labels[util.LabelNamespaceOwnerName]); existingOwner != "" && existingOwner != ownerName {
+		return fmt.Errorf("namespace %s is already owned by %s", namespace, existingOwner)
+	}
+	if existingKind := strings.TrimSpace(ns.Labels[util.LabelNamespaceOwnerKind]); existingKind != "" && existingKind != ownerKind {
+		return fmt.Errorf("namespace %s owner kind mismatch: %s", namespace, existingKind)
+	}
+
+	changed := false
+	for k, v := range labels {
+		if ns.Labels[k] != v {
+			ns.Labels[k] = v
+			changed = true
+		}
+	}
+	if !changed {
+		return nil
+	}
+	return c.Update(ctx, &ns)
+}
+
 // EnsureSparkOperatorRBAC creates the Role and RoleBinding needed for the
 // Spark operator controller and Spark driver pods in the given namespace.
 // This must be called whenever a new SparkVirtualCluster namespace is created.

+ 6 - 0
k8s-orchestrator/internal/k8s/spark.go

@@ -49,6 +49,12 @@ func CreateSparkApplication(ctx context.Context, c ctrlclient.Client, cluster *o
 	if err := ResolveSparkSubmissionImage(specMap, cluster.Spec.SparkConfig); err != nil {
 		return err
 	}
+	if IsSparkSQLRunnerSpec(specMap) {
+		if err := EnsureSparkSQLRunnerConfigMap(ctx, c, ns); err != nil {
+			return err
+		}
+		InjectSparkSQLRunnerMounts(specMap)
+	}
 	injectIsolation(specMap, cluster.Name)
 	injectCleanupConfig(specMap)
 

+ 181 - 0
k8s-orchestrator/internal/k8s/spark_sql_runner.go

@@ -0,0 +1,181 @@
+package k8s
+
+import (
+	"context"
+	_ "embed"
+	"fmt"
+	"strings"
+
+	corev1 "k8s.io/api/core/v1"
+	apierrors "k8s.io/apimachinery/pkg/api/errors"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	ctrlclient "sigs.k8s.io/controller-runtime/pkg/client"
+
+	"github.com/cyrus/spark-orchestrator/internal/util"
+)
+
+const (
+	SparkSQLRunnerConfigMapName = "wenshu-spark-sql-runner"
+	SparkSQLRunnerScriptKey     = "spark_sql_runner.py"
+	SparkSQLRunnerVolumeName    = "wenshu-spark-sql-runner"
+	SparkSQLRunnerMountPath     = "/opt/spark/wenshu"
+	SparkSQLRunnerMainAppFile   = "local:///opt/spark/wenshu/spark_sql_runner.py"
+	DriverMainContainerName     = "spark-kubernetes-driver"
+	ExecutorMainContainerName   = "spark-kubernetes-executor"
+)
+
+//go:embed spark_sql_runner.py
+var sparkSQLRunnerScript string
+
+func EnsureSparkSQLRunnerConfigMap(ctx context.Context, c ctrlclient.Client, namespace string) error {
+	ns := strings.TrimSpace(namespace)
+	if ns == "" {
+		return fmt.Errorf("spark sql runner namespace is empty")
+	}
+
+	desired := map[string]string{
+		SparkSQLRunnerScriptKey: sparkSQLRunnerScript,
+	}
+
+	var cm corev1.ConfigMap
+	key := ctrlclient.ObjectKey{Namespace: ns, Name: SparkSQLRunnerConfigMapName}
+	if err := c.Get(ctx, key, &cm); err != nil {
+		if !apierrors.IsNotFound(err) {
+			return fmt.Errorf("get spark sql runner configmap: %w", err)
+		}
+		create := &corev1.ConfigMap{
+			ObjectMeta: metav1.ObjectMeta{
+				Namespace: ns,
+				Name:      SparkSQLRunnerConfigMapName,
+				Labels: map[string]string{
+					util.LabelManagedBy: util.ManagedByValue,
+				},
+			},
+			Data: desired,
+		}
+		if err := c.Create(ctx, create); err != nil {
+			return fmt.Errorf("create spark sql runner configmap: %w", err)
+		}
+		return nil
+	}
+
+	if cm.Data != nil && cm.Data[SparkSQLRunnerScriptKey] == sparkSQLRunnerScript {
+		return nil
+	}
+	cm.Data = desired
+	if cm.Labels == nil {
+		cm.Labels = map[string]string{}
+	}
+	cm.Labels[util.LabelManagedBy] = util.ManagedByValue
+	if err := c.Update(ctx, &cm); err != nil {
+		return fmt.Errorf("update spark sql runner configmap: %w", err)
+	}
+	return nil
+}
+
+func IsSparkSQLRunnerSpec(spec map[string]interface{}) bool {
+	if spec == nil {
+		return false
+	}
+	mainFile := strings.TrimSpace(toString(spec["mainApplicationFile"]))
+	if !strings.EqualFold(mainFile, SparkSQLRunnerMainAppFile) {
+		return false
+	}
+	typ := strings.TrimSpace(strings.ToUpper(toString(spec["type"])))
+	return typ == "PYTHON"
+}
+
+func InjectSparkSQLRunnerMounts(spec map[string]interface{}) {
+	if spec == nil {
+		return
+	}
+
+	injectRunnerTemplateMount(spec, "driver", DriverMainContainerName)
+	injectRunnerTemplateMount(spec, "executor", ExecutorMainContainerName)
+}
+
+func injectRunnerTemplateMount(spec map[string]interface{}, roleKey, containerName string) {
+	roleMap, ok := toMap(spec[roleKey])
+	if !ok {
+		roleMap = map[string]interface{}{}
+		spec[roleKey] = roleMap
+	}
+
+	template := ensureChildMap(roleMap, "template")
+	templateSpec := ensureChildMap(template, "spec")
+
+	volumes := toSlice(templateSpec["volumes"])
+	if !containsNamedMap(volumes, "name", SparkSQLRunnerVolumeName) {
+		volumes = append(volumes, map[string]interface{}{
+			"name": SparkSQLRunnerVolumeName,
+			"configMap": map[string]interface{}{
+				"name": SparkSQLRunnerConfigMapName,
+			},
+		})
+	}
+	templateSpec["volumes"] = volumes
+
+	containers := toSlice(templateSpec["containers"])
+	containerIndex := findContainerIndex(containers, containerName)
+	var container map[string]interface{}
+	if containerIndex >= 0 {
+		container, _ = toMap(containers[containerIndex])
+	}
+	if container == nil {
+		container = map[string]interface{}{
+			"name": containerName,
+		}
+		containers = append(containers, container)
+		containerIndex = len(containers) - 1
+	}
+
+	mounts := toSlice(container["volumeMounts"])
+	if !containsNamedMap(mounts, "name", SparkSQLRunnerVolumeName) {
+		mounts = append(mounts, map[string]interface{}{
+			"name":      SparkSQLRunnerVolumeName,
+			"mountPath": SparkSQLRunnerMountPath,
+			"readOnly":  true,
+		})
+	}
+	container["volumeMounts"] = mounts
+	containers[containerIndex] = container
+	templateSpec["containers"] = containers
+}
+
+func ensureChildMap(parent map[string]interface{}, key string) map[string]interface{} {
+	if parent == nil {
+		return map[string]interface{}{}
+	}
+	if child, ok := toMap(parent[key]); ok {
+		return child
+	}
+	child := map[string]interface{}{}
+	parent[key] = child
+	return child
+}
+
+func findContainerIndex(containers []interface{}, containerName string) int {
+	for i, item := range containers {
+		m, ok := toMap(item)
+		if !ok {
+			continue
+		}
+		if strings.TrimSpace(toString(m["name"])) == containerName {
+			return i
+		}
+	}
+	return -1
+}
+
+func containsNamedMap(items []interface{}, key, value string) bool {
+	for _, item := range items {
+		m, ok := toMap(item)
+		if !ok {
+			continue
+		}
+		if strings.TrimSpace(toString(m[key])) == value {
+			return true
+		}
+	}
+	return false
+}

+ 46 - 0
k8s-orchestrator/internal/k8s/spark_sql_runner.py

@@ -0,0 +1,46 @@
+#!/usr/bin/env python3
+import argparse
+import traceback
+
+from pyspark.sql import SparkSession
+
+
+def parse_args():
+    parser = argparse.ArgumentParser(description="Wenshu Spark SQL runner")
+    parser.add_argument("--sql", required=True, help="SQL text to execute")
+    parser.add_argument("--show", type=int, default=20, help="Rows to print for query result")
+    parser.add_argument("--truncate", action="store_true", help="Truncate displayed columns")
+    parser.add_argument("--result-output", default="", help="Optional parquet output path for full result")
+    parser.add_argument("--result-output-mode", default="overwrite", help="Spark write mode for result output")
+    return parser.parse_args()
+
+
+def main():
+    args = parse_args()
+    spark = SparkSession.builder.appName("wenshu-spark-sql-runner").getOrCreate()
+    try:
+        result = spark.sql(args.sql)
+        # Print a deterministic header so logs are easy to parse in e2e checks.
+        print("WENSHU_SPARK_SQL_BEGIN")
+        print(args.sql)
+        if args.result_output:
+            try:
+                result.write.mode(args.result_output_mode).parquet(args.result_output)
+                print("WENSHU_SPARK_SQL_RESULT_REF")
+                print(args.result_output)
+            except Exception as err:
+                print("WENSHU_SPARK_SQL_RESULT_REF")
+                print("")
+                print(f"WENSHU_SPARK_SQL_RESULT_REF_ERROR {err}")
+        print("WENSHU_SPARK_SQL_RESULT")
+        result.show(args.show, truncate=args.truncate)
+        print("WENSHU_SPARK_SQL_END")
+    except Exception:
+        traceback.print_exc()
+        raise
+    finally:
+        spark.stop()
+
+
+if __name__ == "__main__":
+    main()

+ 130 - 0
k8s-orchestrator/internal/k8s/spark_sql_runner_test.go

@@ -0,0 +1,130 @@
+package k8s
+
+import "testing"
+
+func TestIsSparkSQLRunnerSpec(t *testing.T) {
+	t.Parallel()
+
+	if !IsSparkSQLRunnerSpec(map[string]interface{}{
+		"type":                "Python",
+		"mainApplicationFile": SparkSQLRunnerMainAppFile,
+	}) {
+		t.Fatalf("expected python spark sql runner spec to be detected")
+	}
+
+	if IsSparkSQLRunnerSpec(map[string]interface{}{
+		"type":                "Scala",
+		"mainApplicationFile": SparkSQLRunnerMainAppFile,
+	}) {
+		t.Fatalf("non-python runner should not be detected")
+	}
+
+	if IsSparkSQLRunnerSpec(map[string]interface{}{
+		"type":                "Python",
+		"mainApplicationFile": "local:///opt/spark/app.py",
+	}) {
+		t.Fatalf("other application file should not be detected")
+	}
+}
+
+func TestInjectSparkSQLRunnerMounts_InjectsDriverAndExecutorTemplate(t *testing.T) {
+	t.Parallel()
+
+	spec := map[string]interface{}{
+		"driver": map[string]interface{}{
+			"cores": 1,
+		},
+		"executor": map[string]interface{}{
+			"instances": 2,
+		},
+	}
+
+	InjectSparkSQLRunnerMounts(spec)
+
+	assertTemplateMount(t, spec, "driver", DriverMainContainerName)
+	assertTemplateMount(t, spec, "executor", ExecutorMainContainerName)
+}
+
+func TestInjectSparkSQLRunnerMounts_IsIdempotent(t *testing.T) {
+	t.Parallel()
+
+	spec := map[string]interface{}{}
+	InjectSparkSQLRunnerMounts(spec)
+	InjectSparkSQLRunnerMounts(spec)
+
+	assertTemplateMount(t, spec, "driver", DriverMainContainerName)
+	assertTemplateMount(t, spec, "executor", ExecutorMainContainerName)
+
+	for _, role := range []string{"driver", "executor"} {
+		roleMap, ok := toMap(spec[role])
+		if !ok {
+			t.Fatalf("%s role not found", role)
+		}
+		template := ensureChildMap(roleMap, "template")
+		templateSpec := ensureChildMap(template, "spec")
+		volumes := toSlice(templateSpec["volumes"])
+		if countNamedMap(volumes, "name", SparkSQLRunnerVolumeName) != 1 {
+			t.Fatalf("%s template volumes should contain exactly one sql runner volume", role)
+		}
+
+		containerName := DriverMainContainerName
+		if role == "executor" {
+			containerName = ExecutorMainContainerName
+		}
+		idx := findContainerIndex(toSlice(templateSpec["containers"]), containerName)
+		if idx < 0 {
+			t.Fatalf("%s template should contain container %s", role, containerName)
+		}
+		container, _ := toMap(toSlice(templateSpec["containers"])[idx])
+		mounts := toSlice(container["volumeMounts"])
+		if countNamedMap(mounts, "name", SparkSQLRunnerVolumeName) != 1 {
+			t.Fatalf("%s template container should contain exactly one sql runner mount", role)
+		}
+	}
+}
+
+func assertTemplateMount(t *testing.T, spec map[string]interface{}, role, containerName string) {
+	t.Helper()
+	roleMap, ok := toMap(spec[role])
+	if !ok {
+		t.Fatalf("%s role not found", role)
+	}
+	template, ok := toMap(roleMap["template"])
+	if !ok {
+		t.Fatalf("%s template not found", role)
+	}
+	templateSpec, ok := toMap(template["spec"])
+	if !ok {
+		t.Fatalf("%s template spec not found", role)
+	}
+
+	volumes := toSlice(templateSpec["volumes"])
+	if !containsNamedMap(volumes, "name", SparkSQLRunnerVolumeName) {
+		t.Fatalf("%s template missing sql runner volume", role)
+	}
+
+	containers := toSlice(templateSpec["containers"])
+	idx := findContainerIndex(containers, containerName)
+	if idx < 0 {
+		t.Fatalf("%s template missing container %s", role, containerName)
+	}
+	container, _ := toMap(containers[idx])
+	mounts := toSlice(container["volumeMounts"])
+	if !containsNamedMap(mounts, "name", SparkSQLRunnerVolumeName) {
+		t.Fatalf("%s template container missing sql runner mount", role)
+	}
+}
+
+func countNamedMap(items []interface{}, key, value string) int {
+	count := 0
+	for _, item := range items {
+		m, ok := toMap(item)
+		if !ok {
+			continue
+		}
+		if toString(m[key]) == value {
+			count++
+		}
+	}
+	return count
+}

+ 63 - 3
k8s-orchestrator/internal/onboarding/onboarder.go

@@ -1,9 +1,11 @@
 package onboarding
 
 import (
+	"bytes"
 	"context"
 	"fmt"
 	"net"
+	"strings"
 	"time"
 
 	"golang.org/x/crypto/ssh"
@@ -109,6 +111,26 @@ systemctl enable --now kubelet
 echo "[setup] done"
 `
 
+// k8sResetScript clears stale kubeadm/kubelet state so re-onboarding the same machine works.
+// This is safe for fresh nodes (all operations are best-effort).
+const k8sResetScript = `#!/bin/bash
+set -euo pipefail
+
+if command -v kubeadm >/dev/null 2>&1; then
+  kubeadm reset -f || true
+fi
+
+systemctl stop kubelet || true
+rm -rf /etc/cni/net.d/* || true
+rm -rf /etc/kubernetes/pki /etc/kubernetes/manifests || true
+rm -f /etc/kubernetes/kubelet.conf /etc/kubernetes/bootstrap-kubelet.conf || true
+rm -f /var/lib/kubelet/config.yaml /var/lib/kubelet/kubeadm-flags.env || true
+rm -rf /var/lib/kubelet/pki || true
+ip link delete cni0 2>/dev/null || true
+ip link delete flannel.1 2>/dev/null || true
+systemctl restart containerd || true
+`
+
 func (s *SSHOnboarder) Onboard(ctx context.Context, req NodeJoinRequest) error {
 	if req.Address == "" {
 		return fmt.Errorf("address is required")
@@ -155,7 +177,12 @@ func (s *SSHOnboarder) Onboard(ctx context.Context, req NodeJoinRequest) error {
 		return fmt.Errorf("node setup: %w", err)
 	}
 
-	// Step 2: kubeadm join
+	// Step 2: cleanup stale state from previous joins/removals (idempotent).
+	if err := s.runRemoteCmd(ctx, client, k8sResetScript); err != nil {
+		return fmt.Errorf("node cleanup: %w", err)
+	}
+
+	// Step 3: kubeadm join
 	// Append --node-name so the K8s node name is predictable and matches NodeInventory name.
 	joinWithName := join
 	if req.NodeNameHint != "" {
@@ -174,6 +201,11 @@ func (s *SSHOnboarder) runRemoteCmd(ctx context.Context, client *ssh.Client, cmd
 	}
 	defer sess.Close()
 
+	var stdoutBuf bytes.Buffer
+	var stderrBuf bytes.Buffer
+	sess.Stdout = &stdoutBuf
+	sess.Stderr = &stderrBuf
+
 	runCtx := ctx
 	if s.CommandTimeout > 0 {
 		var cancel context.CancelFunc
@@ -189,11 +221,39 @@ func (s *SSHOnboarder) runRemoteCmd(ctx context.Context, client *ssh.Client, cmd
 	select {
 	case err := <-done:
 		if err != nil {
-			return err
+			return fmt.Errorf("%w%s", err, renderRemoteOutput(stdoutBuf.String(), stderrBuf.String()))
 		}
 		return nil
 	case <-runCtx.Done():
 		_ = sess.Close()
-		return fmt.Errorf("timed out: %w", runCtx.Err())
+		return fmt.Errorf("timed out: %w%s", runCtx.Err(), renderRemoteOutput(stdoutBuf.String(), stderrBuf.String()))
+	}
+}
+
+func renderRemoteOutput(stdout, stderr string) string {
+	stdoutText := compactText(stdout)
+	stderrText := compactText(stderr)
+	if stdoutText == "" && stderrText == "" {
+		return ""
+	}
+	if stdoutText != "" && stderrText != "" {
+		return fmt.Sprintf("; stdout=%s; stderr=%s", stdoutText, stderrText)
+	}
+	if stdoutText != "" {
+		return fmt.Sprintf("; stdout=%s", stdoutText)
+	}
+	return fmt.Sprintf("; stderr=%s", stderrText)
+}
+
+func compactText(raw string) string {
+	text := strings.TrimSpace(raw)
+	if text == "" {
+		return ""
+	}
+	text = strings.ReplaceAll(text, "\n", " | ")
+	const maxLen = 1024
+	if len(text) > maxLen {
+		return text[:maxLen] + "...(truncated)"
 	}
+	return text
 }

+ 91 - 2
k8s-orchestrator/internal/service/orchestrator.go

@@ -10,23 +10,26 @@ import (
 	corev1 "k8s.io/api/core/v1"
 	apierrors "k8s.io/apimachinery/pkg/api/errors"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
 	"k8s.io/apimachinery/pkg/runtime"
 	ctrlclient "sigs.k8s.io/controller-runtime/pkg/client"
 
 	orcv1 "github.com/cyrus/spark-orchestrator/api/v1alpha1"
+	"github.com/cyrus/spark-orchestrator/internal/k8s"
 	"github.com/cyrus/spark-orchestrator/internal/util"
 )
 
 type OrchestratorService struct {
 	client          ctrlclient.Client
 	secretNamespace string
+	podLogReader    sparkPodLogReader
 }
 
 func NewOrchestratorService(client ctrlclient.Client, secretNamespace string) *OrchestratorService {
 	if secretNamespace == "" {
 		secretNamespace = "orchestrator-system"
 	}
-	return &OrchestratorService{client: client, secretNamespace: secretNamespace}
+	return &OrchestratorService{client: client, secretNamespace: secretNamespace, podLogReader: newSparkPodLogReader()}
 }
 
 type AddNodeRequest struct {
@@ -57,6 +60,7 @@ type CreateStarRocksClusterRequest struct {
 	Namespace       string                       `json:"namespace,omitempty"`
 	FeNodeIDs       []string                     `json:"feNodeIds"`
 	BeNodeIDs       []string                     `json:"beNodeIds"`
+	Version         string                       `json:"version,omitempty"`
 	StarRocksConfig orcv1.StarRocksClusterConfig `json:"starRocksConfig,omitempty"`
 }
 
@@ -79,6 +83,18 @@ type OperationResponse struct {
 	ResourceID  string `json:"resourceId,omitempty"`
 }
 
+type SparkJobStatusResponse struct {
+	OperationID    string `json:"operationId"`
+	OperationType  string `json:"operationType,omitempty"`
+	OperationPhase string `json:"operationPhase,omitempty"`
+	ResourceRef    string `json:"resourceRef,omitempty"`
+	ClusterID      string `json:"clusterId,omitempty"`
+	Namespace      string `json:"namespace,omitempty"`
+	Application    string `json:"application,omitempty"`
+	AppState       string `json:"appState,omitempty"`
+	Message        string `json:"message,omitempty"`
+}
+
 func (s *OrchestratorService) AddNode(ctx context.Context, req AddNodeRequest, idempotencyKey string) (*OperationResponse, error) {
 	if req.IPAddress == "" {
 		return nil, fmt.Errorf("ipAddress is required")
@@ -284,6 +300,7 @@ func (s *OrchestratorService) CreateStarRocksCluster(ctx context.Context, req Cr
 	if clusterID == "" {
 		clusterID = util.SafeName("starrocks-cluster", uuid.NewString())
 	}
+	resolvedConfig := resolveStarRocksConfigByVersion(req.StarRocksConfig, req.Version)
 	op, err := s.createOperation(ctx, idempotencyKey, orcv1.ClusterOperationSpec{
 		Type:      orcv1.OperationCreateStarRocksCluster,
 		RequestID: idempotencyKey,
@@ -292,7 +309,7 @@ func (s *OrchestratorService) CreateStarRocksCluster(ctx context.Context, req Cr
 			Namespace:       req.Namespace,
 			FeNodeIDs:       req.FeNodeIDs,
 			BeNodeIDs:       req.BeNodeIDs,
-			StarRocksConfig: req.StarRocksConfig,
+			StarRocksConfig: resolvedConfig,
 		},
 	})
 	if err != nil {
@@ -301,6 +318,20 @@ func (s *OrchestratorService) CreateStarRocksCluster(ctx context.Context, req Cr
 	return &OperationResponse{OperationID: op.Name, ResourceID: clusterID}, nil
 }
 
+func resolveStarRocksConfigByVersion(cfg orcv1.StarRocksClusterConfig, version string) orcv1.StarRocksClusterConfig {
+	trimmedVersion := strings.TrimSpace(version)
+	if trimmedVersion == "" {
+		return cfg
+	}
+	if cfg.FeImage == "" {
+		cfg.FeImage = fmt.Sprintf("starrocks/fe-ubuntu:%s", trimmedVersion)
+	}
+	if cfg.BeImage == "" {
+		cfg.BeImage = fmt.Sprintf("starrocks/be-ubuntu:%s", trimmedVersion)
+	}
+	return cfg
+}
+
 func (s *OrchestratorService) ScaleOutStarRocksCluster(ctx context.Context, clusterID string, req ResizeStarRocksClusterRequest, idempotencyKey string) (*OperationResponse, error) {
 	if clusterID == "" {
 		return nil, fmt.Errorf("clusterID is required")
@@ -438,6 +469,52 @@ func (s *OrchestratorService) GetOperation(ctx context.Context, operationID stri
 	return &op, nil
 }
 
+func (s *OrchestratorService) GetSparkJobStatusByOperation(ctx context.Context, operationID string) (*SparkJobStatusResponse, error) {
+	op, err := s.GetOperation(ctx, operationID)
+	if err != nil {
+		return nil, err
+	}
+	if op.Spec.Type != orcv1.OperationSubmitSparkJob {
+		return nil, fmt.Errorf("operation %s is not a spark job submission", operationID)
+	}
+
+	resp := &SparkJobStatusResponse{
+		OperationID:    op.Name,
+		OperationType:  string(op.Spec.Type),
+		OperationPhase: string(op.Status.Phase),
+		ResourceRef:    op.Status.ResourceRef,
+		Message:        op.Status.Message,
+	}
+	if op.Spec.SubmitJob != nil {
+		resp.ClusterID = strings.TrimSpace(op.Spec.SubmitJob.ClusterName)
+	}
+
+	namespace, appName := parseResourceRef(op.Status.ResourceRef)
+	resp.Namespace = namespace
+	resp.Application = appName
+	if namespace == "" || appName == "" {
+		return resp, nil
+	}
+
+	obj := &unstructured.Unstructured{}
+	obj.SetAPIVersion(k8s.SparkApplicationAPIVersion)
+	obj.SetKind(k8s.SparkApplicationKind)
+	err = s.client.Get(ctx, ctrlclient.ObjectKey{Name: appName, Namespace: namespace}, obj)
+	if err != nil {
+		if apierrors.IsNotFound(err) {
+			resp.AppState = "NOT_FOUND"
+			return resp, nil
+		}
+		return nil, err
+	}
+	appState := strings.TrimSpace(k8s.GetSparkApplicationState(obj))
+	if appState == "" {
+		appState = "UNKNOWN"
+	}
+	resp.AppState = strings.ToUpper(appState)
+	return resp, nil
+}
+
 func (s *OrchestratorService) ListNodes(ctx context.Context) ([]orcv1.NodeInventory, error) {
 	var list orcv1.NodeInventoryList
 	if err := s.client.List(ctx, &list); err != nil {
@@ -501,3 +578,15 @@ func (s *OrchestratorService) findByRequestID(ctx context.Context, requestID str
 	}
 	return nil, nil
 }
+
+func parseResourceRef(resourceRef string) (string, string) {
+	trimmed := strings.TrimSpace(resourceRef)
+	if trimmed == "" {
+		return "", ""
+	}
+	parts := strings.Split(trimmed, "/")
+	if len(parts) != 2 {
+		return "", ""
+	}
+	return strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1])
+}

+ 42 - 0
k8s-orchestrator/internal/service/orchestrator_version_test.go

@@ -0,0 +1,42 @@
+package service
+
+import (
+	"testing"
+
+	orcv1 "github.com/cyrus/spark-orchestrator/api/v1alpha1"
+)
+
+func TestResolveStarRocksConfigByVersion_UsesSeparateFeBeImages(t *testing.T) {
+	cfg := resolveStarRocksConfigByVersion(orcv1.StarRocksClusterConfig{}, "3.5-latest")
+
+	if cfg.FeImage != "starrocks/fe-ubuntu:3.5-latest" {
+		t.Fatalf("unexpected fe image: %s", cfg.FeImage)
+	}
+	if cfg.BeImage != "starrocks/be-ubuntu:3.5-latest" {
+		t.Fatalf("unexpected be image: %s", cfg.BeImage)
+	}
+}
+
+func TestResolveStarRocksConfigByVersion_DoesNotOverrideExplicitImages(t *testing.T) {
+	base := orcv1.StarRocksClusterConfig{
+		FeImage: "custom-fe:1",
+		BeImage: "custom-be:1",
+	}
+	cfg := resolveStarRocksConfigByVersion(base, "3.5-latest")
+
+	if cfg.FeImage != "custom-fe:1" {
+		t.Fatalf("fe image should keep explicit value, got: %s", cfg.FeImage)
+	}
+	if cfg.BeImage != "custom-be:1" {
+		t.Fatalf("be image should keep explicit value, got: %s", cfg.BeImage)
+	}
+}
+
+func TestResolveStarRocksConfigByVersion_BlankVersionKeepsConfig(t *testing.T) {
+	base := orcv1.StarRocksClusterConfig{}
+	cfg := resolveStarRocksConfigByVersion(base, "   ")
+
+	if cfg.FeImage != "" || cfg.BeImage != "" {
+		t.Fatalf("blank version should not fill images, fe=%q be=%q", cfg.FeImage, cfg.BeImage)
+	}
+}

+ 210 - 0
k8s-orchestrator/internal/service/spark_result.go

@@ -0,0 +1,210 @@
+package service
+
+import (
+	"context"
+	"fmt"
+	"io"
+	"strings"
+
+	corev1 "k8s.io/api/core/v1"
+	apierrors "k8s.io/apimachinery/pkg/api/errors"
+	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+	"k8s.io/client-go/kubernetes"
+	ctrl "sigs.k8s.io/controller-runtime"
+	ctrlclient "sigs.k8s.io/controller-runtime/pkg/client"
+
+	"github.com/cyrus/spark-orchestrator/internal/k8s"
+)
+
+const (
+	sparkResultLogLimitBytes int64 = 512 * 1024
+	sparkSQLBeginMarker            = "WENSHU_SPARK_SQL_BEGIN"
+	sparkSQLResultRefMarker        = "WENSHU_SPARK_SQL_RESULT_REF"
+	sparkSQLResultMarker           = "WENSHU_SPARK_SQL_RESULT"
+	sparkSQLEndMarker              = "WENSHU_SPARK_SQL_END"
+)
+
+type SparkJobResultResponse struct {
+	OperationID string `json:"operationId"`
+	Namespace   string `json:"namespace"`
+	Application string `json:"application"`
+	SQL         string `json:"sql"`
+	Result      string `json:"result"`
+	ResultRef   string `json:"resultRef"`
+	Truncated   bool   `json:"truncated"`
+	Message     string `json:"message"`
+}
+
+type sparkPodLogReader interface {
+	ReadPodLogs(ctx context.Context, namespace, podName, containerName string, limitBytes int64) (string, bool, error)
+}
+
+type kubeSparkPodLogReader struct {
+	clientset kubernetes.Interface
+}
+
+func newSparkPodLogReader() sparkPodLogReader {
+	cfg, err := ctrl.GetConfig()
+	if err == nil {
+		clientset, clientErr := kubernetes.NewForConfig(cfg)
+		if clientErr == nil {
+			return &kubeSparkPodLogReader{clientset: clientset}
+		}
+	}
+	return nil
+}
+
+func (r *kubeSparkPodLogReader) ReadPodLogs(
+	ctx context.Context,
+	namespace,
+	podName,
+	containerName string,
+	limitBytes int64,
+) (string, bool, error) {
+	if r == nil || r.clientset == nil {
+		return "", false, fmt.Errorf("kubernetes clientset is not initialized")
+	}
+	if strings.TrimSpace(namespace) == "" || strings.TrimSpace(podName) == "" {
+		return "", false, fmt.Errorf("namespace and podName are required")
+	}
+
+	opts := &corev1.PodLogOptions{}
+	if strings.TrimSpace(containerName) != "" {
+		opts.Container = strings.TrimSpace(containerName)
+	}
+	if limitBytes > 0 {
+		limitCopy := limitBytes
+		opts.LimitBytes = &limitCopy
+	}
+
+	stream, err := r.clientset.CoreV1().Pods(namespace).GetLogs(podName, opts).Stream(ctx)
+	if err == nil {
+		defer stream.Close()
+		body, readErr := io.ReadAll(stream)
+		if readErr == nil {
+			truncated := limitBytes > 0 && int64(len(body)) >= limitBytes
+			return string(body), truncated, nil
+		}
+		return "", false, readErr
+	}
+	return "", false, err
+}
+
+func (s *OrchestratorService) GetSparkJobResultByOperation(ctx context.Context, operationID string) (*SparkJobResultResponse, error) {
+	status, err := s.GetSparkJobStatusByOperation(ctx, operationID)
+	if err != nil {
+		return nil, err
+	}
+
+	resp := &SparkJobResultResponse{
+		OperationID: status.OperationID,
+		Namespace:   status.Namespace,
+		Application: status.Application,
+		Message:     strings.TrimSpace(status.Message),
+	}
+	if strings.TrimSpace(status.Namespace) == "" || strings.TrimSpace(status.Application) == "" {
+		return resp, nil
+	}
+
+	obj := &unstructured.Unstructured{}
+	obj.SetAPIVersion(k8s.SparkApplicationAPIVersion)
+	obj.SetKind(k8s.SparkApplicationKind)
+	getErr := s.client.Get(ctx, ctrlclient.ObjectKey{Name: status.Application, Namespace: status.Namespace}, obj)
+	if getErr == nil {
+		driverPodName, _, _ := unstructured.NestedString(obj.Object, "status", "driverInfo", "podName")
+		driverPodName = strings.TrimSpace(driverPodName)
+		if driverPodName == "" {
+			resp.Message = firstNonEmpty(resp.Message, "Spark driver pod not found")
+			return resp, nil
+		}
+		if s.podLogReader == nil {
+			resp.Message = firstNonEmpty(resp.Message, "Spark pod log reader is not configured")
+			return resp, nil
+		}
+		logs, truncatedByLimit, logErr := s.podLogReader.ReadPodLogs(
+			ctx,
+			status.Namespace,
+			driverPodName,
+			k8s.DriverMainContainerName,
+			sparkResultLogLimitBytes,
+		)
+		if logErr == nil {
+			sql, result, resultRef, found, truncatedByMarker := extractSparkSQLResult(logs)
+			if found {
+				resp.SQL = sql
+				resp.Result = result
+				resp.ResultRef = resultRef
+			}
+			resp.Truncated = truncatedByLimit || truncatedByMarker
+			if found == false {
+				resp.Message = firstNonEmpty(resp.Message, "Spark SQL result markers were not found in driver logs")
+			}
+			return resp, nil
+		}
+		if apierrors.IsNotFound(logErr) {
+			resp.Message = firstNonEmpty(resp.Message, "Spark driver pod logs not found")
+			return resp, nil
+		}
+		return nil, logErr
+	}
+	if apierrors.IsNotFound(getErr) {
+		resp.Message = firstNonEmpty(resp.Message, "SparkApplication not found")
+		return resp, nil
+	}
+	return nil, getErr
+}
+
+func firstNonEmpty(candidates ...string) string {
+	for _, candidate := range candidates {
+		trimmed := strings.TrimSpace(candidate)
+		if trimmed != "" {
+			return trimmed
+		}
+	}
+	return ""
+}
+
+func extractSparkSQLResult(logText string) (string, string, string, bool, bool) {
+	normalized := strings.ReplaceAll(logText, "\r\n", "\n")
+
+	beginIndex := strings.LastIndex(normalized, sparkSQLBeginMarker)
+	if beginIndex < 0 {
+		return "", "", "", false, false
+	}
+	remaining := normalized[beginIndex+len(sparkSQLBeginMarker):]
+
+	sqlAndResultPart := remaining
+	resultRef := ""
+	resultRefIndex := strings.Index(remaining, sparkSQLResultRefMarker)
+	if resultRefIndex >= 0 {
+		sqlPrefix := remaining[:resultRefIndex]
+		refAndRemaining := remaining[resultRefIndex+len(sparkSQLResultRefMarker):]
+		resultMarkerIndex := strings.Index(refAndRemaining, sparkSQLResultMarker)
+		if resultMarkerIndex < 0 {
+			return strings.TrimSpace(sqlPrefix), strings.TrimSpace(refAndRemaining), "", true, true
+		}
+		resultRef = strings.TrimSpace(refAndRemaining[:resultMarkerIndex])
+		sqlAndResultPart = sqlPrefix + refAndRemaining[resultMarkerIndex:]
+	}
+
+	resultIndex := strings.Index(sqlAndResultPart, sparkSQLResultMarker)
+	if resultIndex < 0 {
+		return "", "", resultRef, false, false
+	}
+
+	sqlText := strings.TrimSpace(sqlAndResultPart[:resultIndex])
+	resultRemaining := sqlAndResultPart[resultIndex+len(sparkSQLResultMarker):]
+	endIndex := strings.Index(resultRemaining, sparkSQLEndMarker)
+	if endIndex < 0 {
+		return sqlText, strings.TrimSpace(resultRemaining), resultRef, true, true
+	}
+
+	resultText := strings.TrimSpace(resultRemaining[:endIndex])
+	return sqlText, resultText, resultRef, true, false
+}
+
+func extractSparkSQLResultLegacy(logText string) (string, string, bool, bool) {
+	// Kept for compatibility in tests and rollback checks.
+	sql, result, _, found, truncated := extractSparkSQLResult(logText)
+	return sql, result, found, truncated
+}

+ 59 - 0
k8s-orchestrator/internal/service/spark_result_test.go

@@ -0,0 +1,59 @@
+package service
+
+import "testing"
+
+func TestExtractSparkSQLResult_WithResultRef(t *testing.T) {
+	logText := "...\n" +
+		"WENSHU_SPARK_SQL_BEGIN\n" +
+		"select * from db1.orders2\n" +
+		"WENSHU_SPARK_SQL_RESULT_REF\n" +
+		"s3a://polaris-bucket/task-results/2026/03/task-1-abc\n" +
+		"WENSHU_SPARK_SQL_RESULT\n" +
+		"+---+\n|id |\n+---+\n|1  |\n+---+\n" +
+		"WENSHU_SPARK_SQL_END\n"
+
+	sql, result, resultRef, found, truncated := extractSparkSQLResult(logText)
+	if !found {
+		t.Fatalf("expected found=true")
+	}
+	if truncated {
+		t.Fatalf("expected truncated=false")
+	}
+	if sql != "select * from db1.orders2" {
+		t.Fatalf("unexpected sql: %s", sql)
+	}
+	if resultRef != "s3a://polaris-bucket/task-results/2026/03/task-1-abc" {
+		t.Fatalf("unexpected resultRef: %s", resultRef)
+	}
+	if result == "" {
+		t.Fatalf("result should not be empty")
+	}
+}
+
+func TestExtractSparkSQLResult_WithoutResultRef(t *testing.T) {
+	logText := "WENSHU_SPARK_SQL_BEGIN\nselect 1\nWENSHU_SPARK_SQL_RESULT\n+---+\n|1  |\n+---+\nWENSHU_SPARK_SQL_END\n"
+	sql, result, resultRef, found, truncated := extractSparkSQLResult(logText)
+	if !found || truncated {
+		t.Fatalf("expected found=true truncated=false, got found=%v truncated=%v", found, truncated)
+	}
+	if sql != "select 1" {
+		t.Fatalf("unexpected sql: %s", sql)
+	}
+	if resultRef != "" {
+		t.Fatalf("resultRef should be empty, got: %s", resultRef)
+	}
+	if result == "" {
+		t.Fatalf("result should not be empty")
+	}
+}
+
+func TestExtractSparkSQLResult_TruncatedByMissingEndMarker(t *testing.T) {
+	logText := "WENSHU_SPARK_SQL_BEGIN\nselect 1\nWENSHU_SPARK_SQL_RESULT\n+---+\n|1  |\n+---+\n"
+	_, _, _, found, truncated := extractSparkSQLResult(logText)
+	if !found {
+		t.Fatalf("expected found=true")
+	}
+	if !truncated {
+		t.Fatalf("expected truncated=true")
+	}
+}

+ 3 - 0
k8s-orchestrator/internal/util/labels.go

@@ -16,6 +16,9 @@ const (
 	LabelComponent            = "orchestrator.spark.cyrus.io/component"
 	LabelOperationType        = "orchestrator.spark.cyrus.io/operation-type"
 	LabelRequestHash          = "orchestrator.spark.cyrus.io/request-hash"
+	LabelNamespaceAutoDelete  = "orchestrator.spark.cyrus.io/namespace-auto-delete"
+	LabelNamespaceOwnerKind   = "orchestrator.spark.cyrus.io/namespace-owner-kind"
+	LabelNamespaceOwnerName   = "orchestrator.spark.cyrus.io/namespace-owner-name"
 	ManagedByValue            = "spark-orchestrator"
 	TaintKeySparkCluster      = "spark-cluster"
 	TaintKeyStarRocksCluster  = "starrocks-cluster"

+ 8 - 0
python-iceberg/Dockerfile

@@ -0,0 +1,8 @@
+FROM python:3.11-slim
+WORKDIR /app
+COPY requirements.txt .
+# openpyxl 在 requirements.txt 中缺失,手动补充
+RUN pip install --no-cache-dir -r requirements.txt openpyxl
+COPY app.py .
+EXPOSE 8090
+CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8090"]

+ 87 - 0
scripts/install-k8s-control-plane.sh

@@ -0,0 +1,87 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+K8S_MINOR="${K8S_MINOR:-v1.31}"
+POD_CIDR="${POD_CIDR:-192.168.0.0/16}"
+CALICO_VERSION="${CALICO_VERSION:-v3.27.0}"
+
+if [[ "${EUID}" -eq 0 ]]; then
+  SUDO=""
+  TARGET_USER="${SUDO_USER:-root}"
+else
+  SUDO="sudo"
+  TARGET_USER="${SUDO_USER:-$USER}"
+fi
+
+TARGET_HOME="$(getent passwd "${TARGET_USER}" | cut -d: -f6)"
+[[ -n "${TARGET_HOME}" ]] || { echo "无法获取用户 ${TARGET_USER} 的 home"; exit 1; }
+
+echo "[1/7] 关闭 swap"
+${SUDO} swapoff -a || true
+${SUDO} sed -ri '/\sswap\s/s/^#?/#/' /etc/fstab || true
+
+echo "[2/7] 配置内核参数"
+cat <<'EOF' | ${SUDO} tee /etc/modules-load.d/k8s.conf >/dev/null
+overlay
+br_netfilter
+EOF
+${SUDO} modprobe overlay || true
+${SUDO} modprobe br_netfilter || true
+
+cat <<'EOF' | ${SUDO} tee /etc/sysctl.d/k8s.conf >/dev/null
+net.bridge.bridge-nf-call-iptables  = 1
+net.bridge.bridge-nf-call-ip6tables = 1
+net.ipv4.ip_forward                 = 1
+EOF
+${SUDO} sysctl --system >/dev/null || true
+
+echo "[3/7] 安装并配置 containerd"
+${SUDO} apt-get update -y
+if ! command -v containerd >/dev/null 2>&1; then
+  # 优先 containerd.io(避免与 docker-ce 冲突),失败再回退 containerd
+  ${SUDO} apt-get install -y containerd.io || ${SUDO} apt-get install -y containerd
+fi
+${SUDO} mkdir -p /etc/containerd
+containerd config default | ${SUDO} tee /etc/containerd/config.toml >/dev/null
+${SUDO} sed -i 's/SystemdCgroup = false/SystemdCgroup = true/' /etc/containerd/config.toml
+${SUDO} systemctl restart containerd
+${SUDO} systemctl enable containerd >/dev/null
+
+echo "[4/7] 安装 kubeadm/kubelet/kubectl (${K8S_MINOR})"
+${SUDO} mkdir -p /etc/apt/keyrings
+curl -fsSL "https://pkgs.k8s.io/core:/stable:/${K8S_MINOR}/deb/Release.key" \
+  | ${SUDO} gpg --dearmor --batch --yes -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg
+
+echo "deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/${K8S_MINOR}/deb/ /" \
+  | ${SUDO} tee /etc/apt/sources.list.d/kubernetes.list >/dev/null
+
+${SUDO} apt-get update -y
+${SUDO} apt-get install -y kubelet kubeadm kubectl
+${SUDO} apt-mark hold kubelet kubeadm kubectl >/dev/null
+${SUDO} systemctl enable --now kubelet >/dev/null
+
+echo "[5/7] 初始化控制平面"
+if [[ ! -f /etc/kubernetes/admin.conf ]]; then
+  ${SUDO} kubeadm init --pod-network-cidr="${POD_CIDR}"
+else
+  echo "已检测到 /etc/kubernetes/admin.conf,跳过 kubeadm init"
+fi
+
+echo "[6/7] 配置 kubeconfig"
+${SUDO} mkdir -p "${TARGET_HOME}/.kube"
+${SUDO} cp -f /etc/kubernetes/admin.conf "${TARGET_HOME}/.kube/config"
+${SUDO} chown -R "${TARGET_USER}:${TARGET_USER}" "${TARGET_HOME}/.kube"
+export KUBECONFIG="${TARGET_HOME}/.kube/config"
+
+echo "[7/7] 安装 CNI(Calico ${CALICO_VERSION})"
+kubectl apply -f "https://raw.githubusercontent.com/projectcalico/calico/${CALICO_VERSION}/manifests/calico.yaml"
+
+# 单机模式允许业务 Pod 调度到控制平面
+kubectl taint nodes --all node-role.kubernetes.io/control-plane- >/dev/null 2>&1 || true
+kubectl taint nodes --all node-role.kubernetes.io/master- >/dev/null 2>&1 || true
+
+kubectl get nodes -o wide
+
+echo
+echo "K8s 控制平面安装完成。"
+echo "当前 kubeconfig: ${KUBECONFIG}"

+ 37 - 0
scripts/install-k8s-operators.sh

@@ -0,0 +1,37 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+if [[ "${EUID}" -eq 0 ]]; then
+  SUDO=""
+else
+  SUDO="sudo"
+fi
+
+# 若未安装 helm,则自动安装
+if ! command -v helm >/dev/null 2>&1; then
+  echo "未检测到 helm,自动安装..."
+  curl -fsSL https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | ${SUDO} bash
+fi
+
+# 检查集群可用性
+kubectl cluster-info >/dev/null
+
+echo "[1/3] 安装 Spark Operator"
+helm repo add spark-operator https://kubeflow.github.io/spark-operator >/dev/null 2>&1 || true
+helm repo update >/dev/null
+helm upgrade --install spark-operator spark-operator/spark-operator \
+  --namespace spark-operator \
+  --create-namespace
+
+echo "[2/3] 安装 StarRocks Operator"
+kubectl apply -f https://raw.githubusercontent.com/StarRocks/starrocks-kubernetes-operator/main/deploy/starrocks.com_starrocksclusters.yaml
+kubectl apply -f https://raw.githubusercontent.com/StarRocks/starrocks-kubernetes-operator/main/deploy/operator.yaml
+
+echo "[3/3] 验证 Operator"
+kubectl get crd sparkapplications.sparkoperator.k8s.io
+kubectl get crd starrocksclusters.starrocks.com
+kubectl get pods -n spark-operator || true
+kubectl get pods -n starrocks || true
+
+echo
+echo "Spark + StarRocks Operator 安装完成。"

+ 162 - 832
部署文档-从零开始.md

@@ -1,936 +1,266 @@
 # Wenshu Platform 部署文档
 
-> 服务器要求:Ubuntu 22.04,8vCPU,32GB RAM,100GB SSD(不含业务数据)
+> Ubuntu 22.04 + 8vCPU + 32GB RAM + 100GB SSD (不含业务数据)
 
----
 
-## 架构总览
-
-```
-┌─────────────────────────────────────────────────────────┐
-│                   Ubuntu 22.04 服务器                    │
-│                                                         │
-│  ┌──────────┐    ┌──────────┐    ┌──────────────────┐   │
-│  │  Nginx   │    │ Backend  │    │  Python-Iceberg  │   │
-│  │  :80     │───▶│ :8080    │───▶│  :8090           │   │
-│  └──────────┘    └────┬─────┘    └────────┬─────────┘   │
-│                       │                   │             │
-│         ┌─────────────┼───────────────────┘             │
-│         │             │                                 │
-│  ┌──────┴───┐  ┌──────┴───┐  ┌──────────┐  ┌────────┐   │
-│  │  MySQL   │  │  Milvus  │  │ Polaris  │  │  TEI   │   │
-│  │  :3306   │  │  :19530  │  │  :8181   │  │ :7997  │   │
-│  └──────────┘  └──────────┘  └────┬─────┘  └────────┘   │
-│                                   │                     │
-│                            ┌──────┴──────┐              │
-│                            │  Postgres   │              │
-│                            │  (internal) │              │
-│                            └─────────────┘              │
-└─────────────────────────────────────────────────────────┘
-```
-
-### 端口一览
-
-| 服务 | 端口 | 说明 |
-|------|------|------|
-| Nginx(前端入口) | 80 | 前端静态文件 + 反向代理 `/api` `/ws` |
-| Spring Boot 后端 | 8080 | REST API |
-| Python Iceberg 服务 | 8090 | Iceberg 数据读写 |
-| MySQL | 3306 | 主业务数据库 |
-| Milvus | 19530 | 向量数据库 |
-| TEI Embedding | 7997 | 文本向量化(OpenAI 兼容) |
-| Apache Polaris | 8181 | Iceberg REST Catalog |
-
-> **k8s-orchestrator**(Spark/StarRocks 编排器)需要独立 Kubernetes 集群,见[第七章](#7-k8s-orchestrator-可选)。
-
----
-
-## 第一章:系统初始化
-
-### 1.1 更新系统
+## 1. 安装系统依赖
 
 ```bash
 sudo apt update && sudo apt upgrade -y
 sudo apt install -y ca-certificates curl gnupg lsb-release jq unzip tar git wget
 ```
 
-### 1.2 配置服务器变量(后续步骤都会用到
+可选:设置服务器 IP 变量(本机部署可填 `127.0.0.1`)
 
 ```bash
-# 替换为你的服务器公网 IP(本机部署用 127.0.0.1)
-export SERVER_IP=<你的服务器公网IP>
-echo "export SERVER_IP=${SERVER_IP}" >> ~/.bashrc
+export SERVER_IP=127.0.0.1
+echo 'export SERVER_IP=127.0.0.1' >> ~/.bashrc
 ```
 
----
-
-## 第二章:安装 Docker + Docker Compose
+## 2. 安装 Docker 与 Docker Compose
 
 ```bash
-# 添加 Docker 官方 GPG 密钥
 sudo install -m 0755 -d /etc/apt/keyrings
-curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
-  | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
+curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
 sudo chmod a+r /etc/apt/keyrings/docker.gpg
 
-# 添加 apt 源
 echo \
-  "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
-  https://download.docker.com/linux/ubuntu \
-  $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
+  "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
+  $(. /etc/os-release && echo \"$VERSION_CODENAME\") stable" \
   | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
 
-# 安装 Docker Engine
 sudo apt update
 sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
 sudo systemctl enable --now docker
 
-# 当前用户免 sudo 使用 Docker(需重新登录或执行 newgrp)
-sudo usermod -aG docker $USER
-newgrp docker
-
-# 验证
-docker version
+docker --version
 docker compose version
 ```
 
----
-
-## 第三章:获取代码
+## 3. 获取代码
 
 ```bash
-# 假设代码已在此路径,如需从 git 克隆请替换为实际仓库地址
 cd ~
 git clone <你的仓库地址> wenshu-platform
 cd wenshu-platform
 ```
 
----
-
-## 第四章:编写 Dockerfile
-
-在项目根目录下创建各服务的 Dockerfile。
-
-### 4.1 后端 Dockerfile
+## 4. 准备统一配置(`.env`)
 
 ```bash
-cat > backend/Dockerfile <<'EOF'
-# ---- 构建阶段 ----
-FROM maven:3.9-eclipse-temurin-17 AS build
-WORKDIR /app
-COPY pom.xml .
-# 先下载依赖,利用 Docker 缓存层
-RUN mvn dependency:go-offline -q
-COPY src ./src
-RUN mvn package -DskipTests -q
-
-# ---- 运行阶段 ----
-FROM eclipse-temurin:17-jre-jammy
-WORKDIR /app
-COPY --from=build /app/target/*.jar app.jar
-EXPOSE 8080
-ENTRYPOINT ["java", "-jar", "app.jar"]
-EOF
+cp .env.example .env
 ```
 
-### 4.2 Python Iceberg Dockerfile
+编辑 `.env`,只需要维护人工配置项(代码参数统一在 `application.yml` 默认值):
 
-```bash
-cat > python-iceberg/Dockerfile <<'EOF'
-FROM python:3.11-slim
-WORKDIR /app
-COPY requirements.txt .
-# openpyxl 在 requirements.txt 中缺失,手动补充
-RUN pip install --no-cache-dir -r requirements.txt openpyxl
-COPY app.py .
-EXPOSE 8090
-CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8090"]
-EOF
-```
+- `MYSQL_URL`(唯一 MySQL 地址:本地 compose 用 `mysql:3306`,远程数据库填 `<host>:<port>`)
+- `MYSQL_USER`
+- `MYSQL_PASSWORD`
+- `MYSQL_ROOT_PASSWORD`(仅本地启动 MySQL 容器时使用)
+- `POLARIS_POSTGRES_PASSWORD`
+- `POLARIS_CLIENT_SECRET`
+- `OSS_ENDPOINT`
+- `OSS_AK`
+- `OSS_SK`
+- `OSS_BUCKET`
+- `STOCK_SSH_CRYPTO_KEY`(至少 32 位)
+- `K8S_KUBECONFIG_PATH`(宿主机 kubeconfig 绝对路径,例如 `/root/.kube/config`)
 
-### 4.3 前端 Dockerfile(多阶段:Node 构建 → Nginx 服务)
+说明:
 
-```bash
-cat > frontend/Dockerfile <<'EOF'
-# ---- 构建阶段 ----
-FROM node:20-alpine AS build
-WORKDIR /app
-COPY package*.json ./
-RUN npm ci
-COPY . .
-RUN npm run build
-
-# ---- Nginx 服务阶段 ----
-FROM nginx:1.27-alpine
-COPY --from=build /app/dist /usr/share/nginx/html
-COPY nginx.conf /etc/nginx/conf.d/default.conf
-EXPOSE 80
-EOF
-```
+- 后端只认 `MYSQL_URL` 这一个地址配置。
+- 如果你把 `MYSQL_URL` 配成远程地址,后端会连接远程库。
 
-### 4.4 前端 Nginx 配置
+## 5. 一键部署 Kubernetes 控制平面与 Operator(必须先执行)
 
 ```bash
-cat > frontend/nginx.conf <<'EOF'
-server {
-    listen 80;
-    server_name _;
-    client_max_body_size 500M;
-
-    root /usr/share/nginx/html;
-    index index.html;
-
-    # 前端 SPA 路由(刷新不 404)
-    location / {
-        try_files $uri $uri/ /index.html;
-    }
+cd ~/wenshu-platform
+chmod +x scripts/install-k8s-control-plane.sh scripts/install-k8s-operators.sh
 
-    # 代理 REST API 到后端
-    location /api/ {
-        proxy_pass http://backend:8080;
-        proxy_set_header Host $host;
-        proxy_set_header X-Real-IP $remote_addr;
-        proxy_read_timeout 300s;
-    }
+# 写入 kubeconfig 路径(已存在则跳过)
+grep -q '^K8S_KUBECONFIG_PATH=' .env || echo "K8S_KUBECONFIG_PATH=$HOME/.kube/config" >> .env
 
-    # 代理 WebSocket(SSH 终端)
-    location /ws {
-        proxy_pass http://backend:8080;
-        proxy_http_version 1.1;
-        proxy_set_header Upgrade $http_upgrade;
-        proxy_set_header Connection "upgrade";
-        proxy_set_header Host $host;
-        proxy_read_timeout 3600s;
-    }
-}
-EOF
-```
+# 1) 安装单机 K8s 控制平面
+bash scripts/install-k8s-control-plane.sh
 
----
+# 2) 安装 Spark + StarRocks Operator
+bash scripts/install-k8s-operators.sh
 
-## 第五章:修改应用配置
+# 3) 生成永久 join 命令(用于后续“新增机器/纳管”)
+JOIN_CMD="$(kubeadm token create --ttl 0 --print-join-command)"
+sed -i "s|^KUBEADM_JOIN_COMMAND=.*|KUBEADM_JOIN_COMMAND=${JOIN_CMD}|" .env
 
-### 5.1 修改后端 application.yml
-
-编辑 `backend/src/main/resources/application.yml`,将所有外部服务地址改为 Docker 容器名(Docker Compose 内部通信):
-
-```bash
-cat > backend/src/main/resources/application.yml <<'EOF'
-server:
-  port: 8080
-
-spring:
-  datasource:
-    url: jdbc:mysql://mysql:3306/wenshu_platform?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
-    username: wenshu
-    password: wenshu_db_pass
-    driver-class-name: com.mysql.cj.jdbc.Driver
-  sql:
-    init:
-      mode: never
-  jackson:
-    time-zone: Asia/Shanghai
-  servlet:
-    multipart:
-      max-file-size: 500MB
-      max-request-size: 500MB
-
-mybatis:
-  mapper-locations: classpath*:mapper/**/*.xml
-  configuration:
-    map-underscore-to-camel-case: true
-
-audit:
-  log:
-    query-max-page-size: 200
-    export-max-rows: 5000
-    retry-delay-ms: 10000
-
-alert:
-  rule:
-    query-max-page-size: 200
-  event:
-    query-max-page-size: 200
-  notify:
-    retry-times: 2
-    retry-interval-ms: 1000
-  prometheus:
-    rules-file: tmp/prometheus/alert-rules.yml
-
-logging:
-  level:
-    com.wenshu.platform: INFO
-
-stock:
-  ssh:
-    crypto-key: change-me-stock-ssh-key-32chars
-
-# ---- Polaris(Iceberg REST Catalog)----
-polaris:
-  host: http://polaris:8181
-  realm: POLARIS
-  client-id: root
-  client-secret: s3cr3t
-  default-catalog: demo_catalog
-  oss:
-    endpoint: https://oss-cn-hangzhou.aliyuncs.com   # 替换为你的 OSS endpoint
-    access-key-id: <你的OSS AK>                       # 替换
-    access-key-secret: <你的OSS SK>                   # 替换
-    region: cn-hangzhou                               # 替换
-    bucket: <你的OSS Bucket>                          # 替换
-  cache-ttl-minutes: 30
-
-iceberg-service:
-  url: http://python-iceberg:8090
-
-# ---- RAG:Embedding + 向量检索 ----
-rag:
-  embedding:
-    dimension: 768
-    local-url: http://tei:80/v1
-    local-model: intfloat/multilingual-e5-base
-  milvus:
-    uri: http://milvus-standalone:19530
-    collection: knowledge_vectors
-  retrieval:
-    top-k: 8
-    score-threshold: 0.3
-EOF
+# 4) 验证
+kubectl get nodes -o wide
+kubectl get crd sparkapplications.sparkoperator.k8s.io
+kubectl get crd starrocksclusters.starrocks.com
+kubectl get pods -n spark-operator
+kubectl get pods -n starrocks
 ```
 
-> **注意**:`polaris.oss.*` 配置是真实 OSS 凭据,请替换为你自己的值,不要提交到 git。
+说明:
 
----
+- Operator 以 Kubernetes Pod 方式运行(通过 Helm/kubectl 安装),不属于 `docker compose` 容器。
 
-## 第六章:一键 Docker Compose 部署
-
-在项目根目录创建主 `docker-compose.yml`:
-
-```bash
-cat > docker-compose.yml <<'YAML'
-services:
-
-  # ──────────────────────────────────────────hf-
-  # 1. MySQL 主数据库
-  # ──────────────────────────────────────────
-  mysql:
-    image: mysql:8.0
-    container_name: ws-mysql
-    restart: unless-stopped
-    environment:
-      TZ: Asia/Shanghai
-      MYSQL_ROOT_PASSWORD: root_pass_change_me
-      MYSQL_DATABASE: wenshu_platform
-      MYSQL_USER: wenshu
-      MYSQL_PASSWORD: wenshu_db_pass
-    volumes:
-      - ./data/mysql:/var/lib/mysql
-      - ./backend/src/main/resources/db/schema.sql:/docker-entrypoint-initdb.d/01-schema.sql:ro
-    ports:
-      - "3306:3306"
-    healthcheck:
-      test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "wenshu", "-pwenshu_db_pass"]
-      interval: 10s
-      timeout: 5s
-      retries: 10
-
-  # ──────────────────────────────────────────
-  # 2. Milvus 向量数据库(依赖 etcd + minio)
-  # ──────────────────────────────────────────
-  milvus-etcd:
-    image: quay.io/coreos/etcd:v3.5.18
-    container_name: ws-milvus-etcd
-    restart: unless-stopped
-    environment:
-      ETCD_AUTO_COMPACTION_MODE: revision
-      ETCD_AUTO_COMPACTION_RETENTION: "1000"
-      ETCD_QUOTA_BACKEND_BYTES: "4294967296"
-      ETCD_SNAPSHOT_COUNT: "50000"
-    volumes:
-      - ./data/milvus/etcd:/etcd
-    command: >
-      etcd
-      -advertise-client-urls=http://127.0.0.1:2379
-      -listen-client-urls=http://0.0.0.0:2379
-      --data-dir=/etcd
-    healthcheck:
-      test: ["CMD", "etcdctl", "endpoint", "health"]
-      interval: 30s
-      timeout: 20s
-      retries: 3
-
-  milvus-minio:
-    image: minio/minio:RELEASE.2023-03-20T20-16-18Z
-    container_name: ws-milvus-minio
-    restart: unless-stopped
-    environment:
-      MINIO_ACCESS_KEY: minioadmin
-      MINIO_SECRET_KEY: minioadmin
-    volumes:
-      - ./data/milvus/minio:/minio_data
-    command: minio server /minio_data --console-address ":9001"
-    healthcheck:
-      test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
-      interval: 30s
-      timeout: 20s
-      retries: 3
-
-  milvus-standalone:
-    image: milvusdb/milvus:v2.4.15
-    container_name: ws-milvus-standalone
-    restart: unless-stopped
-    depends_on:
-      milvus-etcd:
-        condition: service_healthy
-      milvus-minio:
-        condition: service_healthy
-    environment:
-      ETCD_ENDPOINTS: milvus-etcd:2379
-      MINIO_ADDRESS: milvus-minio:9000
-    volumes:
-      - ./data/milvus/milvus:/var/lib/milvus
-    ports:
-      - "19530:19530"
-      - "9091:9091"
-    command: ["milvus", "run", "standalone"]
-    healthcheck:
-      test: ["CMD", "curl", "-f", "http://localhost:9091/healthz"]
-      interval: 30s
-      timeout: 20s
-      retries: 5
-
-  # ──────────────────────────────────────────
-  # 3. Apache Polaris(Iceberg REST Catalog)
-  #    依赖专属 Postgres
-  # ──────────────────────────────────────────
-  polaris-postgres:
-    image: postgres:16
-    container_name: ws-polaris-postgres
-    restart: unless-stopped
-    environment:
-      POSTGRES_DB: polaris
-      POSTGRES_USER: polaris
-      POSTGRES_PASSWORD: polaris_pg_pass
-    volumes:
-      - ./data/polaris/postgres:/var/lib/postgresql/data
-    healthcheck:
-      test: ["CMD-SHELL", "pg_isready -U polaris -d polaris"]
-      interval: 10s
-      timeout: 5s
-      retries: 10
-
-  polaris:
-    image: apache/polaris:1.3.0-incubating
-    container_name: ws-polaris
-    restart: unless-stopped
-    depends_on:
-      polaris-postgres:
-        condition: service_healthy
-    ports:
-      - "8181:8181"
-    environment:
-      POLARIS_PERSISTENCE_TYPE: relational-jdbc
-      QUARKUS_DATASOURCE_USERNAME: polaris
-      QUARKUS_DATASOURCE_PASSWORD: polaris_pg_pass
-      QUARKUS_DATASOURCE_JDBC_URL: jdbc:postgresql://polaris-postgres:5432/polaris
-      QUARKUS_HTTP_PORT: 8181
-      AWS_ACCESS_KEY_ID: ${OSS_AK:-}
-      AWS_SECRET_ACCESS_KEY: ${OSS_SK:-}
-      AWS_REQUEST_CHECKSUM_CALCULATION: WHEN_REQUIRED
-      AWS_RESPONSE_CHECKSUM_VALIDATION: WHEN_REQUIRED
-    healthcheck:
-      test: ["CMD-SHELL", "bash -c '</dev/tcp/localhost/8181' 2>/dev/null && exit 0 || exit 1"]
-      interval: 15s
-      timeout: 5s
-      retries: 20
-      start_period: 60s
-
-  # ──────────────────────────────────────────
-  # 4. TEI 向量化服务(CPU 模式)
-  # ──────────────────────────────────────────
-  tei:
-    image: ghcr.io/huggingface/text-embeddings-inference:cpu-latest
-    container_name: ws-tei
-    restart: unless-stopped
-    volumes:
-      - ./data/huggingface:/data
-    ports:
-      - "7997:80"
-    command: ["--model-id", "intfloat/multilingual-e5-base"]
-    healthcheck:
-      test: ["CMD", "curl", "-sf", "http://localhost:80/health"]
-      interval: 30s
-      timeout: 10s
-      retries: 10
-      start_period: 120s  # 首次下载模型需要时间
-
-  # ──────────────────────────────────────────
-  # 5. Spring Boot 后端
-  # ──────────────────────────────────────────
-  backend:
-    build:
-      context: ./backend
-      dockerfile: Dockerfile
-    container_name: ws-backend
-    restart: unless-stopped
-    environment:
-      TZ: Asia/Shanghai
-    depends_on:
-      mysql:
-        condition: service_healthy
-      milvus-standalone:
-        condition: service_healthy
-    ports:
-      - "8080:8080"
-    healthcheck:
-      test: ["CMD-SHELL", "bash -c '</dev/tcp/localhost/8080' 2>/dev/null && exit 0 || exit 1"]
-      interval: 15s
-      timeout: 5s
-      retries: 20
-      start_period: 90s
-
-  # ──────────────────────────────────────────
-  # 6. Python Iceberg 服务
-  #    启动时会调用 backend 获取配置,必须在 backend 之后启动
-  # ──────────────────────────────────────────
-  python-iceberg:
-    build:
-      context: ./python-iceberg
-      dockerfile: Dockerfile
-    container_name: ws-python-iceberg
-    restart: unless-stopped
-    depends_on:
-      backend:
-        condition: service_healthy
-      polaris:
-        condition: service_healthy
-    ports:
-      - "8090:8090"
-    environment:
-      BACKEND_URL: http://backend:8080
-
-  # ──────────────────────────────────────────
-  # 7. 前端(Nginx)
-  # ──────────────────────────────────────────
-  frontend:
-    build:
-      context: ./frontend
-      dockerfile: Dockerfile
-    container_name: ws-frontend
-    restart: unless-stopped
-    depends_on:
-      - backend
-    ports:
-      - "80:80"
-
-YAML
-```
-
-### 6.1 创建数据目录
+## 6. 启动平台全部服务(包含 k8s-orchestrator)
 
 ```bash
+cd ~/wenshu-platform
 mkdir -p data/mysql data/milvus/etcd data/milvus/minio data/milvus/milvus \
          data/polaris/postgres data/huggingface
-```
-
-### 6.2 创建 .env 文件(OSS 凭据)
 
-```bash
-cat > .env <<'ENV'
-# OSS 凭据(替换为你的真实值)
-OSS_AK=<你的阿里云OSS AccessKeyId>
-OSS_SK=<你的阿里云OSS AccessKeySecret>
-ENV
-```
-
-### 6.3 一键启动所有服务
-
-```bash
 docker compose up -d --build
-
-# 查看启动状态
 docker compose ps
-
-# 查看日志(如遇问题)
-docker compose logs -f backend
-docker compose logs -f python-iceberg
 ```
 
-> **首次启动说明**:
-> - TEI 会自动下载约 278MB 的模型(`intfloat/multilingual-e5-base`),需等待 1-3 分钟
-> - Backend 镜像构建(Maven 编译)约需 3-5 分钟
-> - Frontend 镜像构建(npm build)约需 1-2 分钟
-
----
+说明:
 
-## 第七章:初始化 Polaris
+- `docker compose up -d --build` 默认会启动 `ws-orchestrator-api` 和 `ws-orchestrator-controller`,不需要单独再起。
 
-Polaris 首次启动后需要初始化 realm 和 root 管理凭据(**只需执行一次**)。
+## 7. 初始化 Polaris(幂等,可重复执行)
 
 ```bash
-# 等待 Polaris 完全启动
-docker compose logs -f polaris | grep -m1 "started"
+cd ~/wenshu-platform
 
-# 加载 OSS 凭据
+# 等待 Polaris healthy
+until [ "$(docker inspect -f '{{.State.Health.Status}}' ws-polaris 2>/dev/null)" = "healthy" ]; do
+  sleep 3
+done
+
+set -a
 source .env
+set +a
 
-# 获取 Docker 网络名(格式:目录名_default)
 NET="$(basename "$PWD")_default"
 
-# 初始化 POLARIS realm 并创建 root 客户端
-docker run --rm --network "$NET" \
-  --env="polaris.persistence.type=relational-jdbc" \
-  --env="quarkus.datasource.username=polaris" \
-  --env="quarkus.datasource.password=polaris_pg_pass" \
-  --env="quarkus.datasource.jdbc.url=jdbc:postgresql://polaris-postgres:5432/polaris" \
-  apache/polaris-admin-tool:1.3.0-incubating \
-  bootstrap -r "POLARIS" -c "POLARIS,root,s3cr3t"
-
-echo "Polaris 初始化完成"
-```
-
-### 7.1 验证 Polaris
-
-```bash
-# 获取 token
-curl -s -X POST http://localhost:8181/api/catalog/v1/oauth/tokens \
-  -H "Polaris-Realm: POLARIS" \
-  -H "Content-Type: application/x-www-form-urlencoded" \
-  --data "grant_type=client_credentials&client_id=root&client_secret=s3cr3t&scope=PRINCIPAL_ROLE:ALL" \
-  | jq .access_token
-```
-
-返回非空 token 字符串即表示 Polaris 初始化成功。
-
-### 7.2 创建默认 Catalog
-
-> 必须创建 catalog 后,平台才能正常创建 database/table。
+get_token() {
+  curl -s -X POST http://localhost:8181/api/catalog/v1/oauth/tokens \
+    -H "Polaris-Realm: ${POLARIS_REALM:-POLARIS}" \
+    -H "Content-Type: application/x-www-form-urlencoded" \
+    --data "grant_type=client_credentials&client_id=${POLARIS_CLIENT_ID:-root}&client_secret=${POLARIS_CLIENT_SECRET}&scope=PRINCIPAL_ROLE:ALL" \
+    | jq -r '.access_token // empty'
+}
 
-```bash
-# 获取 token
-TOKEN=$(curl -s -X POST http://localhost:8181/api/catalog/v1/oauth/tokens \
-  -H "Polaris-Realm: POLARIS" \
-  -H "Content-Type: application/x-www-form-urlencoded" \
-  --data "grant_type=client_credentials&client_id=root&client_secret=s3cr3t&scope=PRINCIPAL_ROLE:ALL" \
-  | jq -r '.access_token')
-
-# 创建 demo_catalog(替换 <你的OSS Bucket> 为实际值,需与 application.yml 中 polaris.oss.bucket 一致)
-curl -s -X POST http://localhost:8181/api/management/v1/catalogs \
-  -H "Authorization: Bearer $TOKEN" \
-  -H "Polaris-Realm: POLARIS" \
-  -H "Content-Type: application/json" \
-  -d '{
-    "catalog": {
-      "type": "INTERNAL",
-      "name": "demo_catalog",
-      "properties": {
-        "default-base-location": "s3://<你的OSS Bucket>/demo_catalog"
-      },
-      "storageConfigInfo": {
-        "storageType": "S3",
-        "allowedLocations": ["s3://<你的OSS Bucket>/demo_catalog"],
-        "region": "cn-hangzhou",
-        "endpoint": "https://oss-cn-hangzhou.aliyuncs.com",
-        "pathStyleAccess": false,
-        "stsUnavailable": true
-      }
+TOKEN="$(get_token)"
+if [ -z "$TOKEN" ]; then
+  docker run --rm --network "$NET" \
+    --env="polaris.persistence.type=relational-jdbc" \
+    --env="quarkus.datasource.username=${POLARIS_POSTGRES_USER:-polaris}" \
+    --env="quarkus.datasource.password=${POLARIS_POSTGRES_PASSWORD}" \
+    --env="quarkus.datasource.jdbc.url=jdbc:postgresql://polaris-postgres:5432/${POLARIS_POSTGRES_DB:-polaris}" \
+    apache/polaris-admin-tool:1.3.0-incubating \
+    bootstrap -r "${POLARIS_REALM:-POLARIS}" -c "${POLARIS_REALM:-POLARIS},${POLARIS_CLIENT_ID:-root},${POLARIS_CLIENT_SECRET}"
+
+  TOKEN="$(get_token)"
+fi
+
+[ -n "$TOKEN" ] || { echo "Polaris 初始化失败"; exit 1; }
+
+CATALOG_NAME="${POLARIS_DEFAULT_CATALOG:-demo_catalog}"
+BASE_LOCATION="s3://${OSS_BUCKET}/${CATALOG_NAME}"
+
+if curl -s http://localhost:8181/api/management/v1/catalogs \
+  -H "Authorization: Bearer ${TOKEN}" \
+  -H "Polaris-Realm: ${POLARIS_REALM:-POLARIS}" \
+  | jq -e --arg n "$CATALOG_NAME" '.catalogs[]? | select(.name == $n)' >/dev/null; then
+  echo "Catalog ${CATALOG_NAME} 已存在,跳过创建"
+else
+  cat <<JSON | curl -s -X POST http://localhost:8181/api/management/v1/catalogs \
+    -H "Authorization: Bearer ${TOKEN}" \
+    -H "Polaris-Realm: ${POLARIS_REALM:-POLARIS}" \
+    -H "Content-Type: application/json" \
+    -d @- | jq .
+{
+  "catalog": {
+    "type": "INTERNAL",
+    "name": "${CATALOG_NAME}",
+    "properties": {
+      "default-base-location": "${BASE_LOCATION}"
+    },
+    "storageConfigInfo": {
+      "storageType": "S3",
+      "allowedLocations": ["${BASE_LOCATION}"],
+      "region": "${OSS_REGION:-cn-hangzhou}",
+      "endpoint": "${OSS_ENDPOINT:-https://oss-cn-hangzhou.aliyuncs.com}",
+      "pathStyleAccess": false,
+      "stsUnavailable": true
     }
-  }' | jq .
+  }
+}
+JSON
+fi
 
-# 验证
 curl -s http://localhost:8181/api/management/v1/catalogs \
-  -H "Authorization: Bearer $TOKEN" \
-  -H "Polaris-Realm: POLARIS" | jq '.catalogs[].name'
-# 应输出 "demo_catalog"
+  -H "Authorization: Bearer ${TOKEN}" \
+  -H "Polaris-Realm: ${POLARIS_REALM:-POLARIS}" | jq '.catalogs[].name'
 ```
 
----
-
-## 第八章:验证所有服务
+## 8. 一键验收(全部通过即部署完成)
 
 ```bash
-echo "=== MySQL ===" && \
-  docker exec ws-mysql mysqladmin ping -h localhost -u wenshu -pwenshu_db_pass --silent && echo "OK"
-
-echo "=== Milvus ===" && \
-  curl -sf http://localhost:9091/healthz && echo "OK"
-
-echo "=== TEI Embedding ===" && \
-  curl -s http://localhost:7997/v1/embeddings \
-    -H "Content-Type: application/json" \
-    -d '{"model":"intfloat/multilingual-e5-base","input":"passage: 测试文本"}' \
-    | jq '.data[0].embedding | length'
-  # 应输出 768
-
-echo "=== Polaris ===" && \
-  curl -sf http://localhost:8181/api/catalog/v1/config && echo "OK"
-
-echo "=== Backend ===" && \
-  curl -sf http://localhost:8080/api/auth/login -X POST -H "Content-Type: application/json" \
-    -d '{}' | jq .  # 返回 400/422 即表示后端已启动(无需登录成功)
-
-echo "=== Python Iceberg ===" && \
-  curl -sf http://localhost:8090/health && echo "OK"
-
-echo "=== Frontend ===" && \
-  curl -sf http://localhost/ | grep -q "wenshu" && echo "OK"
-```
-
----
-
-## 第九章:防火墙开放端口
-
-如果使用云服务器,还需在**云安全组**放行以下端口(根据实际需求选择):
-
-```bash
-# Ubuntu ufw 防火墙
-sudo ufw allow 80/tcp      # 前端 HTTP
-sudo ufw allow 8080/tcp    # 后端 API(如需外部直接访问)
-sudo ufw allow 8181/tcp    # Polaris(如需外部访问)
-sudo ufw allow 19530/tcp   # Milvus(如需外部访问)
-sudo ufw allow 7997/tcp    # TEI(如需外部访问)
-sudo ufw status
-```
-
----
-
-## 第十章:k8s-orchestrator(可选)
-
-> k8s-orchestrator 管理 Spark/StarRocks 集群的编排,需要独立 Kubernetes 集群。如不使用集群编排功能,可跳过本章。
-
-### 10.1 安装 Kubernetes(单控制平面节点)
-
-所有节点执行:
-
-```bash
-# 关闭 swap
-sudo swapoff -a
-sudo sed -ri '/\sswap\s/s/^#?/#/' /etc/fstab
-
-# 内核参数
-cat <<EOF | sudo tee /etc/modules-load.d/k8s.conf
-overlay
-br_netfilter
-EOF
-sudo modprobe overlay && sudo modprobe br_netfilter
-
-cat <<EOF | sudo tee /etc/sysctl.d/k8s.conf
-net.bridge.bridge-nf-call-iptables  = 1
-net.bridge.bridge-nf-call-ip6tables = 1
-net.ipv4.ip_forward                 = 1
-EOF
-sudo sysctl --system
-
-# 安装 containerd
-sudo apt-get install -y containerd
-sudo mkdir -p /etc/containerd
-containerd config default | sudo tee /etc/containerd/config.toml >/dev/null
-sudo sed -i 's/SystemdCgroup = false/SystemdCgroup = true/' /etc/containerd/config.toml
-sudo systemctl restart containerd && sudo systemctl enable containerd
-
-# 安装 kubeadm / kubelet / kubectl
-export K8S_MINOR=v1.30
-sudo mkdir -p /etc/apt/keyrings
-curl -fsSL "https://pkgs.k8s.io/core:/stable:/${K8S_MINOR}/deb/Release.key" \
-  | sudo gpg --dearmor -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg
-echo "deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] \
-  https://pkgs.k8s.io/core:/stable:/${K8S_MINOR}/deb/ /" \
-  | sudo tee /etc/apt/sources.list.d/kubernetes.list
-sudo apt-get update
-sudo apt-get install -y kubelet kubeadm kubectl
-sudo apt-mark hold kubelet kubeadm kubectl
-sudo systemctl enable --now kubelet
-```
-
-控制平面节点执行:
+cd ~/wenshu-platform
+set -a
+source .env
+set +a
 
-```bash
-sudo kubeadm init --pod-network-cidr=192.168.0.0/16
+echo "=== MySQL ==="
+docker exec ws-mysql mysqladmin ping -h localhost -u"${MYSQL_USER}" -p"${MYSQL_PASSWORD}" --silent >/dev/null && echo "OK"
 
-mkdir -p $HOME/.kube
-sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config
-sudo chown $(id -u):$(id -g) $HOME/.kube/config
+echo "=== Milvus ==="
+curl -sf http://localhost:9091/healthz >/dev/null && echo "OK"
 
-# 安装 CNI(Calico)
-kubectl apply -f https://raw.githubusercontent.com/projectcalico/calico/v3.27.0/manifests/calico.yaml
+echo "=== TEI Embedding ==="
+LEN=$(curl -s http://localhost:7997/v1/embeddings \
+  -H 'Content-Type: application/json' \
+  -d "{\"model\":\"${TEI_MODEL_ID:-intfloat/multilingual-e5-base}\",\"input\":\"passage: 测试文本\"}" | jq -r '.data[0].embedding | length')
+echo "embedding_len=${LEN}"
+[ "${LEN}" = "768" ] && echo "OK"
 
-kubectl get nodes
-```
+echo "=== Polaris ==="
+CODE=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:8181/api/catalog/v1/config)
+echo "http=${CODE}"
+([ "${CODE}" = "200" ] || [ "${CODE}" = "401" ]) && echo "OK"
 
-### 10.2 安装 Spark + StarRocks Operator
+echo "=== Backend ==="
+CODE=$(curl -s -o /dev/null -w '%{http_code}' -X POST http://localhost:8080/api/auth/login -H 'Content-Type: application/json' -d '{}')
+echo "http=${CODE}"
+([ "${CODE}" = "200" ] || [ "${CODE}" = "400" ] || [ "${CODE}" = "422" ]) && echo "OK"
 
-```bash
-# Spark Operator
-helm repo add spark-operator https://kubeflow.github.io/spark-operator
-helm repo update
-helm upgrade --install spark-operator spark-operator/spark-operator \
-  --namespace spark-operator --create-namespace
-
-# StarRocks Operator
-kubectl apply -f https://raw.githubusercontent.com/StarRocks/starrocks-kubernetes-operator/main/deploy/starrocks.com_starrocksclusters.yaml
-kubectl apply -f https://raw.githubusercontent.com/StarRocks/starrocks-kubernetes-operator/main/deploy/operator.yaml
-```
+echo "=== Python Iceberg ==="
+curl -sf http://localhost:8090/health >/dev/null && echo "OK"
 
-### 10.3 安装 Go 1.22
+echo "=== Frontend ==="
+CODE=$(curl -s -o /dev/null -w '%{http_code}' http://localhost/)
+echo "http=${CODE}"
+[ "${CODE}" = "200" ] && echo "OK"
 
-```bash
-wget https://go.dev/dl/go1.22.12.linux-amd64.tar.gz
-sudo tar -C /usr/local -xzf go1.22.12.linux-amd64.tar.gz
-echo 'export PATH=$PATH:/usr/local/go/bin' >> ~/.bashrc
-source ~/.bashrc
-go version
+echo "=== Orchestrator API ==="
+curl -sf http://localhost:18080/healthz >/dev/null && echo "OK"
 ```
 
-### 10.4 构建并部署 k8s-orchestrator
+## 9. 常用运维命令
 
 ```bash
-cd ~/wenshu-platform/k8s-orchestrator
-
-# 写 Dockerfile(API 服务)
-cat > Dockerfile.api <<'EOF'
-FROM golang:1.22 AS build
-WORKDIR /src
-COPY go.mod go.sum ./
-RUN go mod download
-COPY . .
-RUN CGO_ENABLED=0 GOOS=linux go build -o /out/orchestrator-api ./cmd/orchestrator-api
-
-FROM gcr.io/distroless/static-debian12:nonroot
-COPY --from=build /out/orchestrator-api /orchestrator-api
-ENTRYPOINT ["/orchestrator-api"]
-EOF
-
-# 写 Dockerfile(Controller)
-cat > Dockerfile.controller <<'EOF'
-FROM golang:1.22 AS build
-WORKDIR /src
-COPY go.mod go.sum ./
-RUN go mod download
-COPY . .
-RUN CGO_ENABLED=0 GOOS=linux go build -o /out/orchestrator-controller ./cmd/orchestrator-controller
-
-FROM gcr.io/distroless/static-debian12:nonroot
-COPY --from=build /out/orchestrator-controller /orchestrator-controller
-ENTRYPOINT ["/orchestrator-controller"]
-EOF
-
-# 替换为你的镜像仓库
-export REGISTRY=<your-registry>
-export TAG=v1.0.0
-
-docker build -f Dockerfile.api -t ${REGISTRY}/ws-orchestrator-api:${TAG} .
-docker build -f Dockerfile.controller -t ${REGISTRY}/ws-orchestrator-controller:${TAG} .
-docker push ${REGISTRY}/ws-orchestrator-api:${TAG}
-docker push ${REGISTRY}/ws-orchestrator-controller:${TAG}
-
-# 部署到 K8s
-kubectl apply -f config/samples/namespace.yaml
-kubectl apply -k config/crd
-kubectl apply -k config/rbac
-
-JOIN_CMD="$(kubeadm token create --print-join-command)"
-kubectl -n orchestrator-system create secret generic kubeadm-join \
-  --from-literal=joinCommand="${JOIN_CMD}" \
-  --dry-run=client -o yaml | kubectl apply -f -
-
-kubectl apply -f config/samples/deployment.yaml
-kubectl -n orchestrator-system set image deploy/spark-orchestrator-api \
-  api=${REGISTRY}/ws-orchestrator-api:${TAG}
-kubectl -n orchestrator-system set image deploy/spark-orchestrator-controller \
-  controller=${REGISTRY}/ws-orchestrator-controller:${TAG}
-
-# 验证
-kubectl -n orchestrator-system get pods
-kubectl -n orchestrator-system rollout status deploy/spark-orchestrator-api
-```
+cd ~/wenshu-platform
 
-然后将 orchestrator 的 API 地址配置到 Spring Boot 的 `application.yml`(如有相关配置项)。
-
----
-
-## 附录 A:常用运维命令
-
-```bash
-# 查看所有服务状态
+# 查看状态
 docker compose ps
 
+# 查看日志
+docker compose logs -f backend
+docker compose logs -f ws-orchestrator-api
+docker compose logs -f ws-orchestrator-controller
+
 # 重启单个服务
 docker compose restart backend
 
-# 查看实时日志
-docker compose logs -f <service-name>
-
-# 停止所有服务(保留数据)
+# 停止(保留数据)
 docker compose down
 
-# 停止并删除所有数据(谨慎
+# 停止并删除数据(谨慎)
 docker compose down -v
 
-# 源代码更新后重新部署
-git pull                                          # 拉取最新代码
-docker compose up -d --build backend              # 后端代码改了
-docker compose up -d --build frontend             # 前端代码改了
-docker compose up -d --build python-iceberg       # Python 代码改了
-docker compose up -d --build backend frontend     # 多个一起重建
-docker compose up -d --build                      # 全部重建
-```
-
----
-
-## 附录 B:配置修改速查
-
-| 需要修改的值 | 文件 | 字段 |
-|---|---|---|
-| OSS AK/SK | `.env` | `OSS_AK`, `OSS_SK` |
-| OSS AK/SK(后端) | `backend/src/main/resources/application.yml` | `polaris.oss.access-key-id/secret` |
-| OSS Bucket | `application.yml` | `polaris.oss.bucket` |
-| OSS Region/Endpoint | `application.yml` | `polaris.oss.region/endpoint` |
-| MySQL 密码 | `docker-compose.yml` + `application.yml` | 两处保持一致 |
-| Polaris root 密码 | `application.yml` + 第七章初始化命令 | `polaris.client-secret` |
-| SSH 加密密钥 | `application.yml` | `stock.ssh.crypto-key` |
-
----
-
-## 附录 C:TEI GPU 加速(可选)
-
-如果服务器有 NVIDIA GPU(驱动 ≥ 525),将 `docker-compose.yml` 中的 `tei` 服务替换为:
-
-```yaml
-  tei:
-    image: ghcr.io/huggingface/text-embeddings-inference:latest  # GPU 版
-    container_name: ws-tei
-    restart: unless-stopped
-    deploy:
-      resources:
-        reservations:
-          devices:
-            - driver: nvidia
-              count: 1
-              capabilities: [gpu]
-    volumes:
-      - ./data/huggingface:/data
-    ports:
-      - "7997:80"
-    command: ["--model-id", "intfloat/multilingual-e5-base"]
-```
-
----
-
-## 附录 D:完整启动顺序参考
-
-当 `docker compose up -d` 执行后,服务按如下顺序健康就绪:
-
-```
-mysql (healthy)
-    └── backend (healthy)
-            └── python-iceberg (started)
-
-milvus-etcd (healthy) ──┐
-milvus-minio (healthy) ─┴── milvus-standalone (healthy)
-
-polaris-postgres (healthy) ── polaris (healthy)
-
-tei (下载模型后 healthy)
-
-frontend (no dependency, starts immediately)
+# 代码更新后重建
+git pull
+docker compose up -d --build
 ```