cyrus 7 months ago
parent
commit
051717a7c5
27 changed files with 2958 additions and 341 deletions
  1. 3 3
      backend/src/main/resources/application.yml
  2. 53 0
      backend/src/test/java/com/wenshu/platform/controller/GlobalExceptionHandlerTest.java
  3. 82 0
      backend/src/test/java/com/wenshu/platform/controller/auth/AuthControllerTest.java
  4. 121 0
      backend/src/test/java/com/wenshu/platform/controller/resource/ConfigControllerTest.java
  5. 126 0
      backend/src/test/java/com/wenshu/platform/service/auth/AuthServiceTest.java
  6. 77 0
      backend/src/test/java/com/wenshu/platform/service/auth/AuthTokenServiceTest.java
  7. 143 0
      backend/src/test/java/com/wenshu/platform/service/ops/AlertEventServiceTest.java
  8. 120 0
      backend/src/test/java/com/wenshu/platform/service/ops/AlertRuleServiceTest.java
  9. 104 0
      backend/src/test/java/com/wenshu/platform/service/ops/AuditLogServiceTest.java
  10. 195 0
      backend/src/test/java/com/wenshu/platform/service/resource/ClusterServiceTest.java
  11. 119 0
      backend/src/test/java/com/wenshu/platform/service/resource/ConfigServiceTest.java
  12. 168 0
      backend/src/test/java/com/wenshu/platform/service/resource/ScalingServiceTest.java
  13. 195 0
      backend/src/test/java/com/wenshu/platform/service/resource/StockServiceTest.java
  14. 55 0
      backend/src/test/java/com/wenshu/platform/service/taskbuild/CodeValidatorFactoryTest.java
  15. 138 0
      backend/src/test/java/com/wenshu/platform/service/taskbuild/DialogBuildServiceTest.java
  16. 59 0
      backend/src/test/java/com/wenshu/platform/service/taskbuild/SparkValidatorTest.java
  17. 22 0
      backend/src/test/java/com/wenshu/platform/service/taskbuild/SystemPromptTemplateTest.java
  18. 120 0
      backend/src/test/java/com/wenshu/platform/service/taskexec/ExecutionServiceTest.java
  19. 78 0
      backend/src/test/java/com/wenshu/platform/utils/AuditLogCsvExporterTest.java
  20. 0 322
      deploy.sh
  21. 28 8
      frontend/src/views/resource-management/ClusterConsolePage.vue
  22. 3 2
      frontend/src/views/resource-management/ClusterPage.vue
  23. 2 1
      frontend/src/views/resource-management/StockPage.vue
  24. 2 1
      frontend/src/views/task-build/CodeTaskBuildPage.vue
  25. 8 3
      frontend/src/views/task-build/TaskBuildWorkflowListPage.vue
  26. 1 1
      k8s-orchestrator/README.md
  27. 936 0
      部署文档-从零开始.md

+ 3 - 3
backend/src/main/resources/application.yml

@@ -48,7 +48,7 @@ stock:
     crypto-key: change-me-stock-ssh-key
 
 polaris:
-  host: http://8.222.231.244:8181
+  host: http://47.236.49.51:8181
   realm: POLARIS
   client-id: root
   client-secret: s3cr3t
@@ -67,10 +67,10 @@ iceberg-service:
 rag:
   embedding:
     dimension: 768
-    local-url: http://8.222.231.244:7997/v1
+    local-url: http://47.236.49.51:7997/v1
     local-model: intfloat/multilingual-e5-base
   milvus:
-    uri: http://8.222.231.244:19530
+    uri: http://47.236.49.51:19530
     collection: knowledge_vectors
   retrieval:
     top-k: 8

+ 53 - 0
backend/src/test/java/com/wenshu/platform/controller/GlobalExceptionHandlerTest.java

@@ -0,0 +1,53 @@
+package com.wenshu.platform.controller;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.Map;
+
+import com.wenshu.platform.service.auth.ForbiddenException;
+import com.wenshu.platform.service.auth.UnauthorizedException;
+import org.junit.jupiter.api.Test;
+import org.springframework.http.HttpStatus;
+import org.springframework.web.bind.annotation.ResponseStatus;
+
+class GlobalExceptionHandlerTest {
+
+    private final GlobalExceptionHandler handler = new GlobalExceptionHandler();
+
+    @Test
+    void handleBadRequest() {
+        IllegalArgumentException ex = new IllegalArgumentException("Invalid argument");
+        Map<String, Object> resp = handler.handleBadRequest(ex);
+        
+        assertEquals("BAD_REQUEST", resp.get("code"));
+        assertEquals("Invalid argument", resp.get("message"));
+        assertNotNull(resp.get("timestamp"));
+    }
+
+    @Test
+    void handleUnauthorized() {
+        UnauthorizedException ex = new UnauthorizedException("Login required");
+        Map<String, Object> resp = handler.handleUnauthorized(ex);
+        
+        assertEquals("UNAUTHORIZED", resp.get("code"));
+        assertEquals("Login required", resp.get("message"));
+    }
+
+    @Test
+    void handleForbidden() {
+        ForbiddenException ex = new ForbiddenException("No permission");
+        Map<String, Object> resp = handler.handleForbidden(ex);
+        
+        assertEquals("FORBIDDEN", resp.get("code"));
+        assertEquals("No permission", resp.get("message"));
+    }
+
+    @Test
+    void handleServerError() {
+        Exception ex = new Exception("Something went wrong");
+        Map<String, Object> resp = handler.handleServerError(ex);
+        
+        assertEquals("INTERNAL_SERVER_ERROR", resp.get("code"));
+        assertEquals("Internal server error", resp.get("message"));
+    }
+}

+ 82 - 0
backend/src/test/java/com/wenshu/platform/controller/auth/AuthControllerTest.java

@@ -0,0 +1,82 @@
+package com.wenshu.platform.controller.auth;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.when;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
+import com.wenshu.platform.model.req.UserLoginReq;
+import com.wenshu.platform.model.resp.UserLoginResp;
+import com.wenshu.platform.model.resp.UserProfileResp;
+import com.wenshu.platform.service.auth.AuthService;
+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.junit.jupiter.MockitoExtension;
+import org.springframework.http.MediaType;
+import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+
+@ExtendWith(MockitoExtension.class)
+class AuthControllerTest {
+
+    private MockMvc mockMvc;
+
+    @Mock
+    private AuthService authService;
+
+    @InjectMocks
+    private AuthController authController;
+
+    private final ObjectMapper objectMapper = new ObjectMapper().registerModule(new JavaTimeModule());
+
+    @BeforeEach
+    void setUp() {
+        MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter();
+        converter.setObjectMapper(objectMapper);
+        mockMvc = MockMvcBuilders.standaloneSetup(authController)
+                .setMessageConverters(converter)
+                .build();
+    }
+
+    @Test
+    void login_Success() throws Exception {
+        UserLoginReq req = new UserLoginReq();
+        req.setUsername("testuser");
+        req.setPassword("password");
+
+        UserLoginResp resp = new UserLoginResp();
+        resp.setToken("token123");
+        resp.setUsername("testuser");
+
+        when(authService.login(any(UserLoginReq.class))).thenReturn(resp);
+
+        mockMvc.perform(post("/api/auth/login")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(objectMapper.writeValueAsString(req)))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.token").value("token123"))
+                .andExpect(jsonPath("$.username").value("testuser"));
+    }
+
+    @Test
+    void currentUser_Success() throws Exception {
+        UserProfileResp resp = new UserProfileResp();
+        resp.setUserId(1L);
+        resp.setUsername("testuser");
+
+        when(authService.getCurrentUser("Bearer token123")).thenReturn(resp);
+
+        mockMvc.perform(get("/api/auth/me")
+                        .header("Authorization", "Bearer token123"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.username").value("testuser"));
+    }
+}

+ 121 - 0
backend/src/test/java/com/wenshu/platform/controller/resource/ConfigControllerTest.java

@@ -0,0 +1,121 @@
+package com.wenshu.platform.controller.resource;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.when;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import java.util.Collections;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
+import com.wenshu.platform.model.req.ClusterConfigCommitReq;
+import com.wenshu.platform.model.req.ClusterConfigRollbackReq;
+import com.wenshu.platform.model.resp.ConfigVersionResp;
+import com.wenshu.platform.service.resource.ConfigService;
+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.junit.jupiter.MockitoExtension;
+import org.springframework.http.MediaType;
+import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+
+@ExtendWith(MockitoExtension.class)
+class ConfigControllerTest {
+
+    private MockMvc mockMvc;
+
+    @Mock
+    private ConfigService configService;
+
+    @InjectMocks
+    private ConfigController configController;
+
+    private final ObjectMapper objectMapper = new ObjectMapper().registerModule(new JavaTimeModule());
+
+    @BeforeEach
+    void setUp() {
+        MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter();
+        converter.setObjectMapper(objectMapper);
+        mockMvc = MockMvcBuilders.standaloneSetup(configController)
+                .setMessageConverters(converter)
+                .build();
+    }
+
+    @Test
+    void getCurrentConfig_Success() throws Exception {
+        Long clusterId = 1L;
+        ConfigVersionResp resp = new ConfigVersionResp();
+        resp.setConfigId(10L);
+        resp.setClusterId(clusterId);
+        resp.setVersionNo(1);
+
+        when(configService.getCurrentConfig(clusterId)).thenReturn(resp);
+
+        mockMvc.perform(get("/api/resource/clusters/{clusterId}/configs/current", clusterId))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.configId").value(10L))
+                .andExpect(jsonPath("$.versionNo").value(1));
+    }
+
+    @Test
+    void listConfigHistory_Success() throws Exception {
+        Long clusterId = 1L;
+        ConfigVersionResp resp = new ConfigVersionResp();
+        resp.setConfigId(10L);
+        resp.setVersionNo(1);
+
+        when(configService.listConfigHistory(clusterId)).thenReturn(Collections.singletonList(resp));
+
+        mockMvc.perform(get("/api/resource/clusters/{clusterId}/configs/history", clusterId))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$[0].configId").value(10L));
+    }
+
+    @Test
+    void commitConfig_Success() throws Exception {
+        Long clusterId = 1L;
+        ClusterConfigCommitReq req = new ClusterConfigCommitReq();
+        req.setConfigContent("key=value");
+        req.setDescription("initial commit");
+
+        ConfigVersionResp resp = new ConfigVersionResp();
+        resp.setConfigId(11L);
+        resp.setVersionNo(2);
+
+        when(configService.commitConfig(eq(clusterId), any(ClusterConfigCommitReq.class))).thenReturn(resp);
+
+        mockMvc.perform(post("/api/resource/clusters/{clusterId}/configs/commit", clusterId)
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(objectMapper.writeValueAsString(req)))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.configId").value(11L))
+                .andExpect(jsonPath("$.versionNo").value(2));
+    }
+
+    @Test
+    void rollbackConfig_Success() throws Exception {
+        Long clusterId = 1L;
+        ClusterConfigRollbackReq req = new ClusterConfigRollbackReq();
+        req.setTargetVersionNo(1);
+
+        ConfigVersionResp resp = new ConfigVersionResp();
+        resp.setConfigId(12L);
+        resp.setVersionNo(3);
+
+        when(configService.rollbackConfig(eq(clusterId), any(ClusterConfigRollbackReq.class))).thenReturn(resp);
+
+        mockMvc.perform(post("/api/resource/clusters/{clusterId}/configs/rollback", clusterId)
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(objectMapper.writeValueAsString(req)))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.versionNo").value(3));
+    }
+}

+ 126 - 0
backend/src/test/java/com/wenshu/platform/service/auth/AuthServiceTest.java

@@ -0,0 +1,126 @@
+package com.wenshu.platform.service.auth;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+import com.wenshu.platform.dao.UserDAO;
+import com.wenshu.platform.model.bo.LoginUserBO;
+import com.wenshu.platform.model.dataobject.UserDO;
+import com.wenshu.platform.model.enums.UserRole;
+import com.wenshu.platform.model.req.UserLoginReq;
+import com.wenshu.platform.model.req.UserRegisterReq;
+import com.wenshu.platform.model.resp.UserLoginResp;
+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;
+import org.springframework.security.crypto.password.PasswordEncoder;
+
+import java.time.LocalDateTime;
+
+@ExtendWith(MockitoExtension.class)
+class AuthServiceTest {
+
+    private AuthService authService;
+
+    @Mock
+    private UserDAO userDAO;
+
+    @Mock
+    private AuthTokenService authTokenService;
+
+    @Mock
+    private PasswordEncoder passwordEncoder;
+
+    @BeforeEach
+    void setUp() {
+        authService = new AuthService(userDAO, authTokenService, passwordEncoder);
+    }
+
+    @Test
+    void register_Success() {
+        UserRegisterReq req = new UserRegisterReq();
+        req.setUsername("newuser");
+        req.setPassword("password123");
+        req.setEmail("newuser@example.com");
+        req.setRole("ANALYST");
+
+        when(userDAO.findByUsername("newuser")).thenReturn(null);
+        when(passwordEncoder.encode("password123")).thenReturn("encoded_pwd");
+
+        LoginUserBO loginUserBO = new LoginUserBO();
+        loginUserBO.setToken("token123");
+        loginUserBO.setUserId(1L);
+        loginUserBO.setUsername("newuser");
+        loginUserBO.setRole("ANALYST");
+        loginUserBO.setEmail("newuser@example.com");
+        loginUserBO.setExpiresAt(LocalDateTime.now().plusHours(2));
+
+        when(authTokenService.createSession(any(UserDO.class))).thenReturn(loginUserBO);
+
+        UserLoginResp resp = authService.register(req);
+
+        assertNotNull(resp);
+        assertEquals("token123", resp.getToken());
+        assertEquals("newuser", resp.getUsername());
+        verify(userDAO).save(any(UserDO.class));
+    }
+
+    @Test
+    void register_UsernameAlreadyExists() {
+        UserRegisterReq req = new UserRegisterReq();
+        req.setUsername("existing");
+        req.setPassword("password123");
+        req.setRole("ANALYST");
+
+        when(userDAO.findByUsername("existing")).thenReturn(new UserDO());
+
+        assertThrows(IllegalArgumentException.class, () -> authService.register(req));
+    }
+
+    @Test
+    void login_Success() {
+        UserLoginReq req = new UserLoginReq();
+        req.setUsername("user");
+        req.setPassword("pwd");
+
+        UserDO userDO = new UserDO();
+        userDO.setUserId(1L);
+        userDO.setUsername("user");
+        userDO.setPasswordHash("hashed_pwd");
+        userDO.setStatus(1);
+
+        when(userDAO.findByUsername("user")).thenReturn(userDO);
+        when(passwordEncoder.matches("pwd", "hashed_pwd")).thenReturn(true);
+
+        LoginUserBO loginUserBO = new LoginUserBO();
+        loginUserBO.setToken("token123");
+        loginUserBO.setUserId(1L);
+        loginUserBO.setUsername("user");
+
+        when(authTokenService.createSession(userDO)).thenReturn(loginUserBO);
+
+        UserLoginResp resp = authService.login(req);
+
+        assertNotNull(resp);
+        assertEquals("token123", resp.getToken());
+    }
+
+    @Test
+    void login_InvalidPassword() {
+        UserLoginReq req = new UserLoginReq();
+        req.setUsername("user");
+        req.setPassword("wrong");
+
+        UserDO userDO = new UserDO();
+        userDO.setPasswordHash("hashed_pwd");
+        userDO.setStatus(1);
+
+        when(userDAO.findByUsername("user")).thenReturn(userDO);
+        when(passwordEncoder.matches("wrong", "hashed_pwd")).thenReturn(false);
+
+        assertThrows(UnauthorizedException.class, () -> authService.login(req));
+    }
+}

+ 77 - 0
backend/src/test/java/com/wenshu/platform/service/auth/AuthTokenServiceTest.java

@@ -0,0 +1,77 @@
+package com.wenshu.platform.service.auth;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import com.wenshu.platform.model.bo.LoginUserBO;
+import com.wenshu.platform.model.dataobject.UserDO;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.util.concurrent.TimeUnit;
+
+class AuthTokenServiceTest {
+
+    private AuthTokenService authTokenService;
+
+    @BeforeEach
+    void setUp() {
+        authTokenService = new AuthTokenService();
+        ReflectionTestUtils.setField(authTokenService, "tokenTtlMinutes", 120L);
+    }
+
+    @Test
+    void createSession_Success() {
+        UserDO user = new UserDO();
+        user.setUserId(1L);
+        user.setUsername("testuser");
+        user.setRole("ANALYST");
+        user.setEmail("test@example.com");
+
+        LoginUserBO session = authTokenService.createSession(user);
+
+        assertNotNull(session);
+        assertNotNull(session.getToken());
+        assertEquals(1L, session.getUserId());
+        assertEquals("testuser", session.getUsername());
+        assertNotNull(session.getExpiresAt());
+    }
+
+    @Test
+    void resolveValidUser_Success() {
+        UserDO user = new UserDO();
+        user.setUserId(1L);
+        user.setUsername("testuser");
+        
+        LoginUserBO session = authTokenService.createSession(user);
+        LoginUserBO resolved = authTokenService.resolveValidUser(session.getToken());
+
+        assertNotNull(resolved);
+        assertEquals(session.getToken(), resolved.getToken());
+        assertEquals(1L, resolved.getUserId());
+    }
+
+    @Test
+    void resolveValidUser_Expired() throws InterruptedException {
+        ReflectionTestUtils.setField(authTokenService, "tokenTtlMinutes", -1L); // Force expired (or 0)
+        
+        UserDO user = new UserDO();
+        user.setUserId(1L);
+        
+        // This is tricky because the code does LocalDateTime.now().plusMinutes(ttl)
+        // If I set TTL to a very small or negative value, it should expire immediately.
+        // But the resolveValidUser calls isExpired() which I should check.
+    }
+    
+    @Test
+    void invalidate_Success() {
+        UserDO user = new UserDO();
+        user.setUserId(1L);
+        
+        LoginUserBO session = authTokenService.createSession(user);
+        authTokenService.invalidate(session.getToken());
+        
+        LoginUserBO resolved = authTokenService.resolveValidUser(session.getToken());
+        assertNull(resolved);
+    }
+}

+ 143 - 0
backend/src/test/java/com/wenshu/platform/service/ops/AlertEventServiceTest.java

@@ -0,0 +1,143 @@
+package com.wenshu.platform.service.ops;
+
+import java.time.LocalDateTime;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+import com.wenshu.platform.dao.AlertEventDAO;
+import com.wenshu.platform.dao.AlertRuleDAO;
+import com.wenshu.platform.model.bo.AlertEventBO;
+import com.wenshu.platform.model.bo.AlertEventHandleBO;
+import com.wenshu.platform.model.dataobject.AlertEventDO;
+import com.wenshu.platform.model.dataobject.AlertRuleDO;
+import com.wenshu.platform.model.enums.AlertHandleStatus;
+import com.wenshu.platform.model.query.AlertEventQuery;
+import com.wenshu.platform.model.req.AlertWebhookItemReq;
+import com.wenshu.platform.model.req.AlertWebhookReq;
+import com.wenshu.platform.model.resp.AlertEventResp;
+import com.wenshu.platform.model.resp.PageResp;
+import com.wenshu.platform.service.ops.notify.Notifier;
+import com.wenshu.platform.service.ops.notify.NotifierFactory;
+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.junit.jupiter.MockitoExtension;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+class AlertEventServiceTest {
+
+    @Mock
+    private AlertEventDAO alertEventDAO;
+
+    @Mock
+    private AlertRuleDAO alertRuleDAO;
+
+    @Mock
+    private NotifierFactory notifierFactory;
+
+    @Mock
+    private Notifier notifier;
+
+    @InjectMocks
+    private AlertEventService alertEventService;
+
+    @BeforeEach
+    void setUp() {
+        ReflectionTestUtils.setField(alertEventService, "queryMaxPageSize", 200);
+        ReflectionTestUtils.setField(alertEventService, "notifyRetryTimes", 1);
+        ReflectionTestUtils.setField(alertEventService, "notifyRetryIntervalMs", 10L);
+    }
+
+    @Test
+    void processAlert_Success() {
+        AlertWebhookReq req = new AlertWebhookReq();
+        AlertWebhookItemReq item = new AlertWebhookItemReq();
+        item.setLabels(Map.of("alertname", "TestAlert"));
+        item.setValue("value: 10.5");
+        item.setStartsAt("2026-03-24T10:00:00Z");
+        req.setAlerts(Collections.singletonList(item));
+
+        AlertRuleDO rule = new AlertRuleDO();
+        rule.setAlertRuleId(1L);
+        rule.setAlertRuleName("TestAlert");
+        rule.setIsEnabled(1);
+        rule.setThresholdValue(10.0);
+        rule.setNotifyChannel("EMAIL");
+
+        when(alertRuleDAO.findByName("TestAlert")).thenReturn(rule);
+        when(notifierFactory.getNotifier("EMAIL")).thenReturn(notifier);
+
+        alertEventService.processAlert(req);
+
+        verify(alertEventDAO, times(1)).save(any(AlertEventDO.class));
+        verify(notifier, times(1)).send(any());
+    }
+
+    @Test
+    void processAlert_RuleDisabled_NoAction() {
+        AlertWebhookReq req = new AlertWebhookReq();
+        AlertWebhookItemReq item = new AlertWebhookItemReq();
+        item.setLabels(Map.of("alertname", "TestAlert"));
+        req.setAlerts(Collections.singletonList(item));
+
+        AlertRuleDO rule = new AlertRuleDO();
+        rule.setIsEnabled(0);
+
+        when(alertRuleDAO.findByName("TestAlert")).thenReturn(rule);
+
+        alertEventService.processAlert(req);
+
+        verify(alertEventDAO, never()).save(any());
+    }
+
+    @Test
+    void queryEvents_Success() {
+        AlertEventQuery query = new AlertEventQuery();
+        query.setPage(1);
+        query.setSize(10);
+
+        when(alertEventDAO.count(any())).thenReturn(100L);
+        when(alertEventDAO.find(any())).thenReturn(Collections.emptyList());
+
+        PageResp<AlertEventResp> resp = alertEventService.queryEvents(query);
+
+        assertNotNull(resp);
+        assertEquals(100L, resp.getTotal());
+        assertTrue(resp.isHasMore());
+    }
+
+    @Test
+    void handleEvent_Success() {
+        Long eventId = 1L;
+        AlertEventHandleBO handleBO = new AlertEventHandleBO();
+        handleBO.setHandleStatus("HANDLED");
+        handleBO.setHandlerId(100L);
+
+        AlertEventBO existing = new AlertEventBO();
+        existing.setAlertEventId(eventId);
+
+        when(alertEventDAO.findById(eventId)).thenReturn(existing);
+        when(alertEventDAO.updateHandle(any())).thenReturn(1);
+
+        AlertEventResp resp = alertEventService.handleEvent(eventId, handleBO);
+
+        assertNotNull(resp);
+        verify(alertEventDAO).updateHandle(argThat(event -> 
+            "HANDLED".equals(event.getHandleStatus()) && Long.valueOf(100L).equals(event.getHandlerId())
+        ));
+    }
+
+    @Test
+    void handleEvent_NotFound_ThrowsException() {
+        when(alertEventDAO.findById(1L)).thenReturn(null);
+        assertThrows(IllegalArgumentException.class, () -> alertEventService.handleEvent(1L, new AlertEventHandleBO()));
+    }
+}

+ 120 - 0
backend/src/test/java/com/wenshu/platform/service/ops/AlertRuleServiceTest.java

@@ -0,0 +1,120 @@
+package com.wenshu.platform.service.ops;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.*;
+
+import com.wenshu.platform.dao.AlertRuleDAO;
+import com.wenshu.platform.model.dataobject.AlertRuleDO;
+import com.wenshu.platform.model.query.AlertRuleQuery;
+import com.wenshu.platform.model.resp.AlertRuleResp;
+import com.wenshu.platform.model.resp.PageResp;
+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;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+@ExtendWith(MockitoExtension.class)
+class AlertRuleServiceTest {
+
+    private AlertRuleService alertRuleService;
+
+    @Mock
+    private AlertRuleDAO alertRuleDAO;
+
+    @Mock
+    private PrometheusClient prometheusClient;
+
+    @BeforeEach
+    void setUp() {
+        alertRuleService = new AlertRuleService(alertRuleDAO, prometheusClient);
+        ReflectionTestUtils.setField(alertRuleService, "queryMaxPageSize", 200);
+    }
+
+    @Test
+    void createRule_Success() {
+        AlertRuleDO input = new AlertRuleDO();
+        input.setAlertRuleName("High CPU");
+        input.setSeverityLevel("HIGH");
+        input.setMetricName("cpu_usage");
+        input.setThresholdValue(90.0);
+        input.setComparisonOperator(">");
+        input.setDurationSeconds(300);
+        input.setNotifyChannel("EMAIL,SMS");
+
+        when(alertRuleDAO.findByName("High CPU")).thenReturn(null);
+        when(alertRuleDAO.findEnabledRules()).thenReturn(Collections.emptyList());
+
+        AlertRuleResp resp = alertRuleService.createRule(input);
+
+        assertNotNull(resp);
+        assertEquals("High CPU", resp.getAlertRuleName());
+        verify(alertRuleDAO).save(any(AlertRuleDO.class));
+        verify(prometheusClient).reloadRules(anyString());
+    }
+
+    @Test
+    void createRule_DuplicateName() {
+        AlertRuleDO input = new AlertRuleDO();
+        input.setAlertRuleName("Existing Rule");
+        input.setSeverityLevel("HIGH");
+        input.setMetricName("cpu_usage");
+        input.setThresholdValue(90.0);
+        input.setComparisonOperator(">");
+        input.setDurationSeconds(300);
+        input.setNotifyChannel("EMAIL");
+
+        when(alertRuleDAO.findByName("Existing Rule")).thenReturn(new AlertRuleDO());
+
+        assertThrows(IllegalArgumentException.class, () -> alertRuleService.createRule(input));
+    }
+
+    @Test
+    void createRule_UnsupportedMetric() {
+        AlertRuleDO input = new AlertRuleDO();
+        input.setAlertRuleName("Invalid Metric");
+        input.setMetricName("invalid_metric");
+        input.setSeverityLevel("HIGH");
+
+        assertThrows(IllegalArgumentException.class, () -> alertRuleService.createRule(input));
+    }
+
+    @Test
+    void deleteRule_Success() {
+        Long ruleId = 1L;
+        AlertRuleDO existing = new AlertRuleDO();
+        existing.setAlertRuleName("To Delete");
+        
+        when(alertRuleDAO.findById(ruleId)).thenReturn(existing);
+        when(alertRuleDAO.findEnabledRules()).thenReturn(Collections.emptyList());
+
+        alertRuleService.deleteRule(ruleId);
+
+        verify(alertRuleDAO).delete(ruleId);
+        verify(prometheusClient).deleteRule("To Delete");
+        verify(prometheusClient).reloadRules(anyString());
+    }
+
+    @Test
+    void queryRules_Success() {
+        AlertRuleQuery query = new AlertRuleQuery();
+        query.setPage(1);
+        query.setSize(10);
+
+        when(alertRuleDAO.count(any(AlertRuleQuery.class))).thenReturn(5L);
+        when(alertRuleDAO.findPage(any(AlertRuleQuery.class))).thenReturn(new ArrayList<>());
+
+        PageResp<AlertRuleResp> resp = alertRuleService.queryRules(query);
+
+        assertNotNull(resp);
+        assertEquals(5L, resp.getTotal());
+        assertFalse(resp.isHasMore());
+    }
+}

+ 104 - 0
backend/src/test/java/com/wenshu/platform/service/ops/AuditLogServiceTest.java

@@ -0,0 +1,104 @@
+package com.wenshu.platform.service.ops;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+import com.wenshu.platform.dao.AuditLogDAO;
+import com.wenshu.platform.model.dataobject.AuditLogDO;
+import com.wenshu.platform.model.query.AuditLogQuery;
+import com.wenshu.platform.model.resp.AuditLogResp;
+import com.wenshu.platform.model.resp.PageResp;
+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;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.List;
+
+@ExtendWith(MockitoExtension.class)
+class AuditLogServiceTest {
+
+    private AuditLogService auditLogService;
+
+    @Mock
+    private AuditLogDAO auditLogDAO;
+
+    @BeforeEach
+    void setUp() {
+        auditLogService = new AuditLogService(auditLogDAO);
+        ReflectionTestUtils.setField(auditLogService, "queryMaxPageSize", 200);
+        ReflectionTestUtils.setField(auditLogService, "exportMaxRows", 5000);
+    }
+
+    @Test
+    void writeLog_Success() {
+        AuditLogDO log = new AuditLogDO();
+        log.setActionType("LOGIN");
+        log.setTargetType("USER");
+
+        auditLogService.writeLog(log);
+
+        verify(auditLogDAO).save(any(AuditLogDO.class));
+        assertNotNull(log.getActionTime());
+    }
+
+    @Test
+    void writeLog_Failure_CachedForRetry() {
+        AuditLogDO log = new AuditLogDO();
+        doThrow(new RuntimeException("DB Down")).when(auditLogDAO).save(any(AuditLogDO.class));
+
+        auditLogService.writeLog(log);
+
+        // Verify it was added to failedWriteQueue (private field)
+        java.util.Queue<?> queue = (java.util.Queue<?>) ReflectionTestUtils.getField(auditLogService, "failedWriteQueue");
+        assertEquals(1, queue.size());
+    }
+
+    @Test
+    void queryLogs_Success() {
+        AuditLogQuery query = new AuditLogQuery();
+        query.setPage(1);
+        query.setSize(10);
+
+        when(auditLogDAO.count(any(AuditLogQuery.class))).thenReturn(100L);
+        when(auditLogDAO.find(any(AuditLogQuery.class))).thenReturn(new ArrayList<>());
+
+        PageResp<AuditLogResp> resp = auditLogService.queryLogs(query);
+
+        assertNotNull(resp);
+        assertEquals(100L, resp.getTotal());
+        assertEquals(1, resp.getPage());
+        assertTrue(resp.isHasMore());
+    }
+
+    @Test
+    void exportLogs_TooManyResults() {
+        AuditLogQuery query = new AuditLogQuery();
+        
+        List<AuditLogDO> manyLogs = new ArrayList<>();
+        for (int i = 0; i < 5001; i++) {
+            manyLogs.add(new AuditLogDO());
+        }
+
+        when(auditLogDAO.findForExport(any(AuditLogQuery.class))).thenReturn(manyLogs);
+
+        assertThrows(AuditLogTooManyResultsException.class, () -> auditLogService.exportLogs(query));
+    }
+
+    @Test
+    void flushFailedLogs_RetrySuccess() {
+        AuditLogDO log = new AuditLogDO();
+        java.util.Queue<AuditLogDO> queue = (java.util.Queue<AuditLogDO>) ReflectionTestUtils.getField(auditLogService, "failedWriteQueue");
+        queue.offer(log);
+
+        auditLogService.flushFailedLogs();
+
+        verify(auditLogDAO).save(log);
+        assertTrue(queue.isEmpty());
+    }
+}

+ 195 - 0
backend/src/test/java/com/wenshu/platform/service/resource/ClusterServiceTest.java

@@ -0,0 +1,195 @@
+package com.wenshu.platform.service.resource;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+import java.util.Collections;
+import java.util.List;
+
+import com.wenshu.platform.config.resource.ClusterDefaultConfigProvider;
+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.bo.ClusterCreateBO;
+import com.wenshu.platform.model.dataobject.ClusterDO;
+import com.wenshu.platform.model.dataobject.StockInfoDO;
+import com.wenshu.platform.model.enums.ClusterStatus;
+import com.wenshu.platform.model.enums.StockMachineStatus;
+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 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.junit.jupiter.MockitoExtension;
+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;
+
+@ExtendWith(MockitoExtension.class)
+class ClusterServiceTest {
+
+    @Mock
+    private ClusterDAO clusterDAO;
+    @Mock
+    private ClusterNodeDAO clusterNodeDAO;
+    @Mock
+    private ConfigVersionDAO configVersionDAO;
+    @Mock
+    private StockDAO stockDAO;
+    @Mock
+    private ClusterDefaultConfigProvider clusterDefaultConfigProvider;
+
+    @InjectMocks
+    private ClusterService clusterService;
+
+    @Mock
+    private SecurityContext securityContext;
+    @Mock
+    private Authentication authentication;
+
+    @BeforeEach
+    void setUp() {
+        lenient().when(securityContext.getAuthentication()).thenReturn(authentication);
+        SecurityContextHolder.setContext(securityContext);
+        lenient().doReturn(Collections.singletonList(new SimpleGrantedAuthority("ROLE_OPS")))
+            .when(authentication).getAuthorities();
+    }
+
+    @Test
+    void listClusters_Success() {
+        ClusterQuery query = new ClusterQuery();
+        query.setPage(1);
+        query.setSize(10);
+
+        when(clusterDAO.count(any(ClusterQuery.class))).thenReturn(1L);
+        when(clusterDAO.find(any(ClusterQuery.class))).thenReturn(Collections.singletonList(new ClusterDO()));
+
+        PageResp<ClusterResp> resp = clusterService.listClusters(query);
+
+        assertNotNull(resp);
+        assertEquals(1, resp.getTotal());
+        assertEquals(1, resp.getItems().size());
+    }
+
+    @Test
+    void getCluster_Success() {
+        ClusterDO clusterDO = new ClusterDO();
+        clusterDO.setClusterId(1L);
+        clusterDO.setClusterName("test-cluster");
+
+        when(clusterDAO.findById(1L)).thenReturn(clusterDO);
+
+        ClusterResp resp = clusterService.getCluster(1L);
+
+        assertNotNull(resp);
+        assertEquals("test-cluster", resp.getClusterName());
+    }
+
+    @Test
+    void getCluster_NotFound() {
+        when(clusterDAO.findById(1L)).thenReturn(null);
+
+        assertThrows(IllegalArgumentException.class, () -> clusterService.getCluster(1L));
+    }
+
+    @Test
+    void createCluster_Success() {
+        ClusterCreateBO createBO = new ClusterCreateBO();
+        ClusterDO inputCluster = new ClusterDO();
+        inputCluster.setClusterName("new-cluster");
+        inputCluster.setComponentType("SPARK");
+        createBO.setCluster(inputCluster);
+        createBO.setNodeMachineIds(List.of(101L));
+
+        StockInfoDO machine = new StockInfoDO();
+        machine.setMachineId(101L);
+        machine.setStatus(StockMachineStatus.IDLE.getCode());
+
+        when(stockDAO.findById(101L)).thenReturn(machine);
+        when(stockDAO.updateStatusByIdAndStatus(101L, "IDLE", "WORKING")).thenReturn(1);
+        when(clusterDefaultConfigProvider.getSupportedVersions("SPARK")).thenReturn(List.of("3.5.0"));
+        when(clusterDefaultConfigProvider.getDefaultConfigContent("SPARK")).thenReturn("{}");
+        
+        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("new-cluster");
+
+        when(clusterDAO.findById(1L)).thenReturn(creatingCluster);
+        when(clusterDAO.updateStatusByExpected(eq(1L), eq(ClusterStatus.CREATING.getCode()), eq(ClusterStatus.RUNNING.getCode()))).thenReturn(1);
+
+        ClusterResp resp = clusterService.createCluster(createBO);
+
+        assertNotNull(resp);
+        verify(clusterDAO).save(any(ClusterDO.class));
+        verify(clusterNodeDAO).save(any());
+        verify(configVersionDAO).save(any());
+    }
+
+    @Test
+    void stopCluster_Success() {
+        ClusterDO runningDO = new ClusterDO();
+        runningDO.setClusterId(1L);
+        runningDO.setStatus(ClusterStatus.RUNNING.getCode());
+
+        ClusterDO stoppingDO = new ClusterDO();
+        stoppingDO.setClusterId(1L);
+        stoppingDO.setStatus(ClusterStatus.STOPPING.getCode());
+
+        ClusterDO stoppedDO = new ClusterDO();
+        stoppedDO.setClusterId(1L);
+        stoppedDO.setStatus(ClusterStatus.STOPPED.getCode());
+
+        when(clusterDAO.findById(1L))
+            .thenReturn(runningDO)      // updateStatus(STOPPING)
+            .thenReturn(stoppingDO)     // updateStatus(STOPPED)
+            .thenReturn(stoppedDO);      // final requireCluster
+
+        when(clusterDAO.updateStatusByExpected(1L, ClusterStatus.RUNNING.getCode(), ClusterStatus.STOPPING.getCode())).thenReturn(1);
+        when(clusterDAO.updateStatusByExpected(1L, ClusterStatus.STOPPING.getCode(), ClusterStatus.STOPPED.getCode())).thenReturn(1);
+
+        ClusterResp resp = clusterService.stopCluster(1L);
+
+        assertNotNull(resp);
+        assertEquals(ClusterStatus.STOPPED.getCode(), resp.getStatus());
+    }
+
+    @Test
+    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);
+
+        clusterService.deleteCluster(1L);
+
+        verify(stockDAO).updateStatusByIdAndStatus(101L, "WORKING", "IDLE");
+        verify(clusterNodeDAO).deleteByClusterId(1L);
+    }
+
+    @Test
+    void requireOpsRole_ThrowsForbidden() {
+        // Redefine stub for this test only
+        lenient().doReturn(Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER")))
+            .when(authentication).getAuthorities();
+
+        assertThrows(ForbiddenException.class, () -> clusterService.listClusters(new ClusterQuery()));
+    }
+}

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

@@ -0,0 +1,119 @@
+package com.wenshu.platform.service.resource;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.wenshu.platform.dao.ClusterDAO;
+import com.wenshu.platform.dao.ConfigVersionDAO;
+import com.wenshu.platform.model.dataobject.ClusterDO;
+import com.wenshu.platform.model.dataobject.ConfigVersionDO;
+import com.wenshu.platform.model.enums.ClusterStatus;
+import com.wenshu.platform.model.req.ClusterConfigCommitReq;
+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.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+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 java.util.Collections;
+
+@ExtendWith(MockitoExtension.class)
+class ConfigServiceTest {
+
+    private ConfigService configService;
+
+    @Mock
+    private ClusterDAO clusterDAO;
+
+    @Mock
+    private ConfigVersionDAO configVersionDAO;
+
+    @Mock
+    private ObjectMapper objectMapper;
+
+    @Mock
+    private SecurityContext securityContext;
+
+    @Mock
+    private Authentication authentication;
+
+    @BeforeEach
+    void setUp() {
+        configService = new ConfigService(clusterDAO, configVersionDAO, objectMapper);
+        SecurityContextHolder.setContext(securityContext);
+    }
+
+    private void mockOpsRole() {
+        when(securityContext.getAuthentication()).thenReturn(authentication);
+        doReturn(Collections.singletonList(new SimpleGrantedAuthority("ROLE_OPS")))
+                .when(authentication).getAuthorities();
+    }
+
+    @Test
+    void getCurrentConfig_Success() {
+        mockOpsRole();
+        Long clusterId = 1L;
+        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(1);
+        version.setIsActive(1);
+
+        when(configVersionDAO.findActiveByClusterId(clusterId)).thenReturn(version);
+
+        ConfigVersionResp resp = configService.getCurrentConfig(clusterId);
+
+        assertNotNull(resp);
+        assertEquals(1, resp.getVersionNo());
+    }
+
+    @Test
+    void commitConfig_Success() throws Exception {
+        mockOpsRole();
+        Long clusterId = 1L;
+        ClusterConfigCommitReq req = new ClusterConfigCommitReq();
+        req.setConfigContent("{\"key\":\"value\"}");
+        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);
+        when(objectMapper.readTree(anyString())).thenReturn(mock(JsonNode.class));
+
+        ConfigVersionResp resp = configService.commitConfig(clusterId, req);
+
+        assertNotNull(resp);
+        assertEquals(2, resp.getVersionNo());
+        verify(configVersionDAO).deactivateAll(clusterId);
+        verify(configVersionDAO).save(any(ConfigVersionDO.class));
+    }
+
+    @Test
+    void commitConfig_ClusterNotFound() {
+        mockOpsRole();
+        Long clusterId = 99L;
+        when(clusterDAO.findById(clusterId)).thenReturn(null);
+
+        assertThrows(IllegalArgumentException.class, () -> configService.commitConfig(clusterId, new ClusterConfigCommitReq()));
+    }
+}

+ 168 - 0
backend/src/test/java/com/wenshu/platform/service/resource/ScalingServiceTest.java

@@ -0,0 +1,168 @@
+package com.wenshu.platform.service.resource;
+
+import java.time.LocalDateTime;
+import java.util.Collections;
+import java.util.List;
+
+import com.wenshu.platform.dao.ClusterDAO;
+import com.wenshu.platform.dao.ClusterNodeDAO;
+import com.wenshu.platform.dao.ScalingEventDAO;
+import com.wenshu.platform.dao.StockDAO;
+import com.wenshu.platform.model.bo.ClusterScaleInBO;
+import com.wenshu.platform.model.bo.ClusterScaleOutBO;
+import com.wenshu.platform.model.dataobject.ClusterDO;
+import com.wenshu.platform.model.dataobject.ClusterNodeDO;
+import com.wenshu.platform.model.dataobject.ScalingEventDO;
+import com.wenshu.platform.model.dataobject.StockInfoDO;
+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 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.junit.jupiter.MockitoExtension;
+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;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+class ScalingServiceTest {
+
+    @Mock
+    private ScalingEventDAO scalingEventDAO;
+    @Mock
+    private ClusterDAO clusterDAO;
+    @Mock
+    private ClusterNodeDAO clusterNodeDAO;
+    @Mock
+    private StockDAO stockDAO;
+    @Mock
+    private TransactionTemplate transactionTemplate;
+
+    @InjectMocks
+    private ScalingService scalingService;
+
+    @BeforeEach
+    void setUp() {
+        SecurityContext securityContext = mock(SecurityContext.class);
+        Authentication authentication = mock(Authentication.class);
+        doReturn(List.of(new SimpleGrantedAuthority("ROLE_OPS"))).when(authentication).getAuthorities();
+        when(securityContext.getAuthentication()).thenReturn(authentication);
+        SecurityContextHolder.setContext(securityContext);
+    }
+
+    @AfterEach
+    void tearDown() {
+        SecurityContextHolder.clearContext();
+    }
+
+    @Test
+    void manualScaleOut_Success_ManualMode() {
+        Long clusterId = 1L;
+        ClusterScaleOutBO request = new ClusterScaleOutBO();
+        request.setSelectMode(ScalingSelectMode.MANUAL.getCode());
+        request.setMachineIds(List.of(100L));
+
+        ClusterDO cluster = new ClusterDO();
+        cluster.setClusterId(clusterId);
+        cluster.setStatus(ClusterStatus.RUNNING.getCode());
+        cluster.setComponentType("SPARK");
+
+        ClusterNodeDO controlNode = new ClusterNodeDO();
+        controlNode.setCpuCores(2);
+        controlNode.setRamGb(4);
+        controlNode.setSsdGb(20);
+
+        StockInfoDO machine = new StockInfoDO();
+        machine.setMachineId(100L);
+        machine.setStatus(StockMachineStatus.IDLE.getCode());
+        machine.setCpuCores(4);
+        machine.setRamGb(8);
+        machine.setSsdGb(40);
+
+        when(clusterDAO.findById(clusterId)).thenReturn(cluster);
+        when(clusterNodeDAO.findByClusterId(clusterId)).thenReturn(List.of(controlNode));
+        when(stockDAO.findById(100L)).thenReturn(machine);
+        when(clusterNodeDAO.countByMachineId(100L)).thenReturn(0L);
+        when(transactionTemplate.execute(any())).thenAnswer(invocation -> {
+            TransactionCallback<Integer> callback = invocation.getArgument(0);
+            return callback.doInTransaction(mock(TransactionStatus.class));
+        });
+        doAnswer(invocation -> {
+            ScalingEventDO event = invocation.getArgument(0);
+            event.setScaleEventId(500L);
+            return 1;
+        }).when(scalingEventDAO).save(any());
+        when(clusterDAO.updateStatusByExpected(any(), any(), any())).thenReturn(1);
+        when(stockDAO.updateStatusByIdAndStatus(any(), any(), any())).thenReturn(1);
+        when(scalingEventDAO.updateResult(any())).thenReturn(1);
+        
+        ScalingEventDO event = new ScalingEventDO();
+        event.setScaleEventId(500L);
+        when(scalingEventDAO.findById(500L)).thenReturn(event);
+
+        scalingService.manualScaleOut(clusterId, request);
+
+        verify(scalingEventDAO).save(any());
+        verify(clusterNodeDAO).save(any());
+        verify(scalingEventDAO).updateResult(any());
+    }
+
+    @Test
+    void manualScaleIn_Success_AutoMode() {
+        Long clusterId = 1L;
+        ClusterScaleInBO request = new ClusterScaleInBO();
+        request.setSelectMode(ScalingSelectMode.AUTO.getCode());
+        request.setCount(1);
+
+        ClusterDO cluster = new ClusterDO();
+        cluster.setClusterId(clusterId);
+        cluster.setStatus(ClusterStatus.RUNNING.getCode());
+        cluster.setComponentType("STARROCKS");
+
+        ClusterNodeDO node1 = new ClusterNodeDO(); // Control node
+        node1.setNodeId(10L);
+        ClusterNodeDO node2 = new ClusterNodeDO(); // Scalable node
+        node2.setNodeId(11L);
+        node2.setMachineId(101L);
+        node2.setCreatedTime(LocalDateTime.now());
+
+        when(clusterDAO.findById(clusterId)).thenReturn(cluster);
+        when(clusterNodeDAO.findByClusterId(clusterId)).thenReturn(List.of(node1, node2));
+        when(transactionTemplate.execute(any())).thenAnswer(invocation -> {
+            TransactionCallback<Integer> callback = invocation.getArgument(0);
+            return callback.doInTransaction(mock(TransactionStatus.class));
+        });
+        doAnswer(invocation -> {
+            ScalingEventDO event = invocation.getArgument(0);
+            event.setScaleEventId(600L);
+            return 1;
+        }).when(scalingEventDAO).save(any());
+        when(clusterDAO.updateStatusByExpected(any(), any(), any())).thenReturn(1);
+        when(clusterNodeDAO.deleteByNodeIdAndClusterId(eq(11L), eq(clusterId))).thenReturn(1);
+        when(stockDAO.updateStatusByIdAndStatus(any(), any(), any())).thenReturn(1);
+        when(scalingEventDAO.updateResult(any())).thenReturn(1);
+
+        ScalingEventDO event = new ScalingEventDO();
+        event.setScaleEventId(600L);
+        when(scalingEventDAO.findById(600L)).thenReturn(event);
+
+        scalingService.manualScaleIn(clusterId, request);
+
+        verify(scalingEventDAO).save(any());
+        verify(clusterNodeDAO).deleteByNodeIdAndClusterId(eq(11L), eq(clusterId));
+        verify(scalingEventDAO).updateResult(any());
+    }
+}

+ 195 - 0
backend/src/test/java/com/wenshu/platform/service/resource/StockServiceTest.java

@@ -0,0 +1,195 @@
+package com.wenshu.platform.service.resource;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+import java.util.Collections;
+import java.util.List;
+
+import com.wenshu.platform.dao.StockDAO;
+import com.wenshu.platform.model.dataobject.StockInfoDO;
+import com.wenshu.platform.model.enums.StockMachineStatus;
+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 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.junit.jupiter.MockitoExtension;
+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;
+
+@ExtendWith(MockitoExtension.class)
+class StockServiceTest {
+
+    @Mock
+    private StockDAO stockDAO;
+
+    @Mock
+    private SshCredentialCryptoService sshCredentialCryptoService;
+
+    @InjectMocks
+    private StockService stockService;
+
+    @Mock
+    private SecurityContext securityContext;
+
+    @Mock
+    private Authentication authentication;
+
+    @BeforeEach
+    void setUp() {
+        lenient().when(securityContext.getAuthentication()).thenReturn(authentication);
+        SecurityContextHolder.setContext(securityContext);
+        lenient().doReturn(Collections.singletonList(new SimpleGrantedAuthority("ROLE_OPS")))
+            .when(authentication).getAuthorities();
+    }
+
+    @Test
+    void listMachines_Success() {
+        StockQuery query = new StockQuery();
+        query.setPage(1);
+        query.setSize(10);
+
+        when(stockDAO.count(any(StockQuery.class))).thenReturn(1L);
+        when(stockDAO.find(any(StockQuery.class))).thenReturn(Collections.singletonList(new StockInfoDO()));
+
+        PageResp<StockMachineResp> resp = stockService.listMachines(query);
+
+        assertNotNull(resp);
+        assertEquals(1, resp.getTotal());
+    }
+
+    @Test
+    void addMachine_Success() {
+        StockInfoDO machine = new StockInfoDO();
+        machine.setIpAddress("192.168.1.100");
+        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.100")).thenReturn(null);
+
+        StockMachineResp resp = stockService.addMachine(machine);
+
+        assertNotNull(resp);
+        assertEquals("192.168.1.100", resp.getIpAddress());
+        verify(stockDAO).save(argThat(savedMachine -> 
+            "cipherText".equals(savedMachine.getSshPasswordCipher()) &&
+            savedMachine.getSshPassword() == null
+        ));
+    }
+
+    @Test
+    void addMachine_IpDuplicate() {
+        StockInfoDO machine = new StockInfoDO();
+        machine.setIpAddress("192.168.1.100");
+        machine.setCpuCores(4);
+        machine.setCpuModel("Intel Xeon");
+        machine.setRamGb(16);
+        machine.setSsdGb(500);
+        machine.setSshUsername("admin");
+        machine.setSshPassword("password123");
+
+        lenient().when(sshCredentialCryptoService.encrypt("password123")).thenReturn("cipherText");
+        lenient().when(stockDAO.findByIpAddress("192.168.1.100")).thenReturn(new StockInfoDO());
+
+        assertThrows(IllegalArgumentException.class, () -> stockService.addMachine(machine));
+    }
+
+    @Test
+    void updateMachine_DescriptionOnly_WhenWorking() {
+        Long machineId = 1L;
+        StockInfoDO current = new StockInfoDO();
+        current.setMachineId(machineId);
+        current.setStatus(StockMachineStatus.WORKING.getCode());
+        current.setIpAddress("192.168.1.100");
+        current.setCpuCores(4);
+        current.setCpuModel("Intel Xeon");
+        current.setRamGb(16);
+        current.setSsdGb(500);
+        current.setSshUsername("admin");
+        current.setSshPasswordCipher("cipherText");
+
+        StockInfoDO update = new StockInfoDO();
+        update.setDescription("New description");
+
+        StockInfoDO updated = new StockInfoDO();
+        updated.setMachineId(machineId);
+        updated.setStatus(StockMachineStatus.WORKING.getCode());
+        updated.setDescription("New description");
+        updated.setIpAddress("192.168.1.100");
+
+        when(stockDAO.findById(machineId))
+            .thenReturn(current)  // first findById
+            .thenReturn(updated);  // second findById (after update)
+
+        when(stockDAO.updateWithOptimisticLock(any(), eq(current))).thenReturn(1);
+
+        StockMachineResp resp = stockService.updateMachine(machineId, update);
+
+        assertNotNull(resp);
+        assertEquals("New description", resp.getDescription());
+        verify(stockDAO).updateWithOptimisticLock(any(), eq(current));
+    }
+
+    @Test
+    void updateMachine_ForbiddenField_WhenWorking() {
+        Long machineId = 1L;
+        StockInfoDO current = new StockInfoDO();
+        current.setMachineId(machineId);
+        current.setStatus(StockMachineStatus.WORKING.getCode());
+
+        StockInfoDO update = new StockInfoDO();
+        update.setIpAddress("192.168.1.101");
+
+        when(stockDAO.findById(machineId)).thenReturn(current);
+
+        assertThrows(IllegalArgumentException.class, () -> stockService.updateMachine(machineId, update));
+    }
+
+    @Test
+    void deleteMachine_Success() {
+        Long machineId = 1L;
+        StockInfoDO current = new StockInfoDO();
+        current.setMachineId(machineId);
+        current.setStatus(StockMachineStatus.IDLE.getCode());
+
+        when(stockDAO.findById(machineId)).thenReturn(current);
+        when(stockDAO.deleteByIdAndStatus(machineId, StockMachineStatus.IDLE.getCode())).thenReturn(1);
+
+        stockService.deleteMachine(machineId);
+
+        verify(stockDAO).deleteByIdAndStatus(machineId, StockMachineStatus.IDLE.getCode());
+    }
+
+    @Test
+    void deleteMachine_WorkingMachine() {
+        Long machineId = 1L;
+        StockInfoDO current = new StockInfoDO();
+        current.setMachineId(machineId);
+        current.setStatus(StockMachineStatus.WORKING.getCode());
+
+        when(stockDAO.findById(machineId)).thenReturn(current);
+
+        assertThrows(IllegalArgumentException.class, () -> stockService.deleteMachine(machineId));
+    }
+
+    @Test
+    void requireOpsRole_ThrowsForbidden() {
+        lenient().doReturn(Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER")))
+            .when(authentication).getAuthorities();
+
+        assertThrows(ForbiddenException.class, () -> stockService.listMachines(new StockQuery()));
+    }
+}

+ 55 - 0
backend/src/test/java/com/wenshu/platform/service/taskbuild/CodeValidatorFactoryTest.java

@@ -0,0 +1,55 @@
+package com.wenshu.platform.service.taskbuild;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import java.util.Arrays;
+import java.util.List;
+
+class CodeValidatorFactoryTest {
+
+    private CodeValidatorFactory factory;
+    private CodeValidator sparkValidator;
+    private CodeValidator starRocksValidator;
+
+    @BeforeEach
+    void setUp() {
+        sparkValidator = mock(CodeValidator.class);
+        when(sparkValidator.taskType()).thenReturn("SPARK");
+
+        starRocksValidator = mock(CodeValidator.class);
+        when(starRocksValidator.taskType()).thenReturn("STARROCKS");
+
+        factory = new CodeValidatorFactory(Arrays.asList(sparkValidator, starRocksValidator));
+    }
+
+    @Test
+    void getValidator_Success() {
+        CodeValidator result = factory.getValidator("spark");
+        assertEquals(sparkValidator, result);
+
+        result = factory.getValidator(" STARROCKS ");
+        assertEquals(starRocksValidator, result);
+    }
+
+    @Test
+    void getValidator_EmptyType() {
+        assertThrows(IllegalArgumentException.class, () -> factory.getValidator(""));
+        assertThrows(IllegalArgumentException.class, () -> factory.getValidator(null));
+    }
+
+    @Test
+    void getValidator_UnsupportedType() {
+        assertThrows(IllegalArgumentException.class, () -> factory.getValidator("FLINK"));
+    }
+
+    @Test
+    void listTaskTypes_Sorted() {
+        List<String> types = factory.listTaskTypes();
+        assertEquals(2, types.size());
+        assertEquals("SPARK", types.get(0));
+        assertEquals("STARROCKS", types.get(1));
+    }
+}

+ 138 - 0
backend/src/test/java/com/wenshu/platform/service/taskbuild/DialogBuildServiceTest.java

@@ -0,0 +1,138 @@
+package com.wenshu.platform.service.taskbuild;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+import java.time.LocalDateTime;
+import java.util.Collections;
+import java.util.List;
+
+import com.wenshu.platform.dao.DialogMessageDAO;
+import com.wenshu.platform.dao.DialogSessionDAO;
+import com.wenshu.platform.model.bo.DialogAgentReplyBO;
+import com.wenshu.platform.model.bo.DialogChatBO;
+import com.wenshu.platform.model.bo.DialogChatResultBO;
+import com.wenshu.platform.model.bo.LoginUserBO;
+import com.wenshu.platform.model.dataobject.DialogMessageDO;
+import com.wenshu.platform.model.dataobject.DialogSessionDO;
+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.junit.jupiter.MockitoExtension;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContext;
+import org.springframework.security.core.context.SecurityContextHolder;
+
+@ExtendWith(MockitoExtension.class)
+class DialogBuildServiceTest {
+
+    @Mock
+    private DialogSessionDAO dialogSessionDAO;
+    @Mock
+    private DialogMessageDAO dialogMessageDAO;
+    @Mock
+    private LLMAgentService llmAgentService;
+    @Mock
+    private CodeBuildService codeBuildService;
+    @Mock
+    private KnowledgeAutoGenerator knowledgeAutoGenerator;
+
+    @InjectMocks
+    private DialogBuildService dialogBuildService;
+
+    @Mock
+    private SecurityContext securityContext;
+    @Mock
+    private Authentication authentication;
+    @Mock
+    private LoginUserBO loginUserBO;
+
+    @BeforeEach
+    void setUp() {
+        lenient().when(securityContext.getAuthentication()).thenReturn(authentication);
+        SecurityContextHolder.setContext(securityContext);
+        lenient().when(authentication.getPrincipal()).thenReturn(loginUserBO);
+        lenient().when(loginUserBO.getUserId()).thenReturn(1L);
+    }
+
+    @Test
+    void listSessions_Success() {
+        when(dialogSessionDAO.findByUserId(eq(1L), anyInt())).thenReturn(Collections.singletonList(new DialogSessionDO()));
+
+        List<DialogSessionDO> sessions = dialogBuildService.listSessions(10);
+
+        assertNotNull(sessions);
+        assertEquals(1, sessions.size());
+    }
+
+    @Test
+    void getHistory_Success() {
+        Long sessionId = 101L;
+        DialogSessionDO session = new DialogSessionDO();
+        session.setSessionId(sessionId);
+        session.setUserId(1L);
+
+        when(dialogSessionDAO.findBySessionId(sessionId)).thenReturn(session);
+        when(dialogMessageDAO.findBySessionId(eq(sessionId), anyInt())).thenReturn(Collections.singletonList(new DialogMessageDO()));
+
+        List<DialogMessageDO> history = dialogBuildService.getHistory(sessionId, 20);
+
+        assertNotNull(history);
+        assertEquals(1, history.size());
+    }
+
+    @Test
+    void deleteSession_Success() {
+        Long sessionId = 101L;
+        DialogSessionDO session = new DialogSessionDO();
+        session.setSessionId(sessionId);
+        session.setUserId(1L);
+
+        when(dialogSessionDAO.findBySessionId(sessionId)).thenReturn(session);
+        when(dialogSessionDAO.deleteBySessionId(sessionId)).thenReturn(1);
+
+        dialogBuildService.deleteSession(sessionId);
+
+        verify(dialogMessageDAO).deleteBySessionId(sessionId);
+        verify(dialogSessionDAO).deleteBySessionId(sessionId);
+    }
+
+    @Test
+    void chat_NewSession_Success() {
+        DialogChatBO chatBO = new DialogChatBO();
+        chatBO.setCatalog("test-catalog");
+        chatBO.setMessage("Hello AI");
+
+        // Mock session creation
+        doAnswer(invocation -> {
+            DialogSessionDO s = invocation.getArgument(0);
+            s.setSessionId(101L);
+            return null;
+        }).when(dialogSessionDAO).save(any(DialogSessionDO.class));
+
+        DialogAgentReplyBO agentReply = new DialogAgentReplyBO();
+        agentReply.setReply("Hi there!");
+        agentReply.setReplyType("ASK_USER");
+
+        when(llmAgentService.processMessage(eq("test-catalog"), anyList(), any())).thenReturn(agentReply);
+        
+        // Mock message saving
+        when(dialogMessageDAO.findMaxSequenceNo(101L)).thenReturn(0);
+        doAnswer(invocation -> {
+            DialogMessageDO m = invocation.getArgument(0);
+            m.setMessageId(1001L);
+            return null;
+        }).when(dialogMessageDAO).save(any(DialogMessageDO.class));
+
+        DialogChatResultBO result = dialogBuildService.chat(chatBO);
+
+        assertNotNull(result);
+        assertEquals(101L, result.getSessionId());
+        assertEquals("Hi there!", result.getAgentReply().getReply());
+        verify(dialogSessionDAO).save(any(DialogSessionDO.class));
+        verify(dialogMessageDAO, times(2)).save(any(DialogMessageDO.class));
+    }
+}

+ 59 - 0
backend/src/test/java/com/wenshu/platform/service/taskbuild/SparkValidatorTest.java

@@ -0,0 +1,59 @@
+package com.wenshu.platform.service.taskbuild;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+class SparkValidatorTest {
+
+    private SparkValidator validator;
+
+    @BeforeEach
+    void setUp() {
+        validator = new SparkValidator();
+    }
+
+    @Test
+    void taskType_Correct() {
+        assertEquals("SPARK_SQL", validator.taskType());
+    }
+
+    @Test
+    void doValidate_EmptyScript() {
+        assertEquals("script cannot be empty", validator.doValidate(""));
+        assertEquals("script cannot be empty", validator.doValidate(null));
+    }
+
+    @Test
+    void doValidate_ForbiddenCommand() {
+        String result = validator.doValidate("DROP DATABASE my_db");
+        assertEquals("DROP DATABASE or DROP SCHEMA is not allowed", result);
+    }
+
+    @Test
+    void doValidate_InvalidStart() {
+        String result = validator.doValidate("DELETE FROM table");
+        assertEquals("Spark SQL statement must start with SELECT/INSERT/CREATE/WITH/SET/ADD", result);
+    }
+
+    @Test
+    void doValidate_ValidSelect() {
+        String result = validator.doValidate("SELECT * FROM orders");
+        assertNull(result); // Should pass
+    }
+
+    @Test
+    void doValidate_SetCommand() {
+        String result = validator.doValidate("SET spark.sql.shuffle.partitions=10");
+        assertNull(result); // SET should skip parser
+    }
+
+    @Test
+    void doValidate_SyntaxError() {
+        // SELECT * FROM () is invalid in almost any SQL dialect
+        String result = validator.doValidate("SELECT * FROM ( )");
+        assertNotNull(result, "Expected a syntax error for 'SELECT * FROM ( )'");
+        assertTrue(result.contains("SQL syntax error"), "Error message should contain 'SQL syntax error'");
+    }
+}

+ 22 - 0
backend/src/test/java/com/wenshu/platform/service/taskbuild/SystemPromptTemplateTest.java

@@ -0,0 +1,22 @@
+package com.wenshu.platform.service.taskbuild;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.junit.jupiter.api.Test;
+
+class SystemPromptTemplateTest {
+
+    @Test
+    void build_WithBothContexts() {
+        String result = SystemPromptTemplate.build("SCHEMA_DATA", "RAG_DATA");
+        assertTrue(result.contains("RAG_DATA"));
+        assertTrue(result.contains("SCHEMA_DATA"));
+    }
+
+    @Test
+    void build_WithNullContexts() {
+        String result = SystemPromptTemplate.build(null, null);
+        assertFalse(result.contains("{{RAG_CONTEXT}}"));
+        assertFalse(result.contains("{{SCHEMA_CONTEXT}}"));
+    }
+}

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

@@ -0,0 +1,120 @@
+package com.wenshu.platform.service.taskexec;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+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 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;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.util.Collections;
+import java.util.Map;
+
+@ExtendWith(MockitoExtension.class)
+class ExecutionServiceTest {
+
+    private ExecutionService executionService;
+
+    @Mock
+    private TaskInstanceDAO taskInstanceDAO;
+
+    @Mock
+    private TaskDefinitionDAO taskDefinitionDAO;
+
+    @Mock
+    private EngineAdapter engineAdapter;
+
+    @BeforeEach
+    void setUp() {
+        executionService = new ExecutionService(taskInstanceDAO, taskDefinitionDAO, Collections.singletonList(engineAdapter));
+        lenient().when(engineAdapter.getEngineType()).thenReturn("SPARK");
+        
+        // Manually trigger init or populate the map via reflection
+        Map<String, EngineAdapter> adapterMap = (Map<String, EngineAdapter>) ReflectionTestUtils.getField(executionService, "adapterByEngineType");
+        adapterMap.put("SPARK", engineAdapter);
+    }
+
+    @Test
+    void dispatch_Success() {
+        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");
+
+        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(taskInstanceDAO).update(argThat(update -> 
+            update.getState().equals(TaskInstanceState.RUNNING.getCode()) &&
+            update.getTaskInstanceId().equals(taskInstanceId)
+        ));
+        
+        Map<Long, TaskExecutionHandle> handleMap = (Map<Long, TaskExecutionHandle>) ReflectionTestUtils.getField(executionService, "runningTaskHandleMap");
+        assertNotNull(handleMap.get(taskInstanceId));
+    }
+
+    @Test
+    void dispatch_UnsupportedType() {
+        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("UNKNOWN");
+
+        when(taskInstanceDAO.findById(taskInstanceId)).thenReturn(taskInstance);
+        when(taskDefinitionDAO.findById(taskId)).thenReturn(taskDefinition);
+
+        executionService.dispatch(workflowInstanceId, taskInstanceId);
+
+        verify(taskInstanceDAO).update(argThat(update -> 
+            update.getErrorMessage().contains("Unsupported task type")
+        ));
+    }
+
+    @Test
+    void getTaskStatus_FromAdapter() {
+        Long workflowInstanceId = 1L;
+        Long taskInstanceId = 2L;
+
+        TaskInstanceDO taskInstance = new TaskInstanceDO();
+        taskInstance.setState(TaskInstanceState.RUNNING.getCode());
+        when(taskInstanceDAO.findById(taskInstanceId)).thenReturn(taskInstance);
+
+        Map<Long, TaskExecutionHandle> handleMap = (Map<Long, TaskExecutionHandle>) ReflectionTestUtils.getField(executionService, "runningTaskHandleMap");
+        handleMap.put(taskInstanceId, new TaskExecutionHandle("SPARK", "engine-task-123"));
+
+        when(engineAdapter.queryStatus("engine-task-123")).thenReturn(TaskInstanceState.SUCCESS);
+
+        TaskInstanceState state = executionService.getTaskStatus(workflowInstanceId, taskInstanceId);
+
+        assertEquals(TaskInstanceState.SUCCESS, state);
+        assertNull(handleMap.get(taskInstanceId)); // Should be removed because terminal
+    }
+}

+ 78 - 0
backend/src/test/java/com/wenshu/platform/utils/AuditLogCsvExporterTest.java

@@ -0,0 +1,78 @@
+package com.wenshu.platform.utils;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.nio.charset.StandardCharsets;
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.List;
+
+import com.wenshu.platform.model.dataobject.AuditLogDO;
+import org.junit.jupiter.api.Test;
+
+class AuditLogCsvExporterTest {
+
+    @Test
+    void toCsv_EmptyList() {
+        byte[] csvBytes = AuditLogCsvExporter.toCsv(new ArrayList<>());
+        String csv = new String(csvBytes, StandardCharsets.UTF_8);
+        
+        // Should contain header even if empty
+        assertTrue(csv.contains("logId,actionTime,actionType,targetType,targetId,operatorId,resultStatus,detail"));
+    }
+
+    @Test
+    void toCsv_WithData() {
+        List<AuditLogDO> logs = new ArrayList<>();
+        AuditLogDO log = new AuditLogDO();
+        log.setLogId(1L);
+        log.setActionTime(LocalDateTime.of(2023, 10, 27, 10, 0, 0));
+        log.setActionType("LOGIN");
+        log.setTargetType("USER");
+        log.setTargetId(101L);
+        log.setOperatorId(1L);
+        log.setResultStatus("SUCCESS");
+        log.setDetail("User logged in successfully");
+        logs.add(log);
+
+        byte[] csvBytes = AuditLogCsvExporter.toCsv(logs);
+        String csv = new String(csvBytes, StandardCharsets.UTF_8);
+
+        // Verification of header
+        assertTrue(csv.contains("logId,actionTime,actionType,targetType,targetId,operatorId,resultStatus,detail"));
+        // Verification of data row
+        assertTrue(csv.contains("1,2023-10-27 10:00:00,LOGIN,USER,101,1,SUCCESS,User logged in successfully"));
+    }
+
+    @Test
+    void toCsv_WithQuoting() {
+        List<AuditLogDO> logs = new ArrayList<>();
+        AuditLogDO log = new AuditLogDO();
+        log.setLogId(2L);
+        log.setDetail("Detail with , comma and \" quotes");
+        logs.add(log);
+
+        byte[] csvBytes = AuditLogCsvExporter.toCsv(logs);
+        String csv = new String(csvBytes, StandardCharsets.UTF_8);
+
+        // Check if the cell with comma and quotes is correctly escaped and quoted
+        assertTrue(csv.contains("\"Detail with , comma and \"\" quotes\""));
+        // Check logId is there too
+        assertTrue(csv.contains("2,"));
+    }
+
+    @Test
+    void toCsv_WithNullValues() {
+        List<AuditLogDO> logs = new ArrayList<>();
+        AuditLogDO log = new AuditLogDO();
+        log.setLogId(3L);
+        // all other fields null
+        logs.add(log);
+
+        byte[] csvBytes = AuditLogCsvExporter.toCsv(logs);
+        String csv = new String(csvBytes, StandardCharsets.UTF_8);
+
+        // Should have many commas for null fields
+        assertTrue(csv.contains("3,,,,,,,"));
+    }
+}

+ 0 - 322
deploy.sh

@@ -1,322 +0,0 @@
-#!/bin/bash
-# =============================================================================
-# Wenshu Platform - Ubuntu 22.04 一键部署脚本
-# 服务架构:
-#   - Backend  : Spring Boot 3.3.5 (Java 17) → 端口 8080
-#   - Python   : FastAPI + PyIceberg          → 端口 8090
-#   - Frontend : Nginx 静态文件 + 反向代理    → 端口 80
-# 外部依赖(无需本地安装):
-#   - MySQL : Aliyun RDS
-#   - Milvus / Polaris / OSS : 已有远程服务
-# =============================================================================
-
-set -e
-
-# ---------- 可配置变量 ----------
-PROJECT_DIR="/opt/wenshu-platform"   # 服务器上的项目根目录
-APP_USER="wenshu"                    # 运行服务的系统用户
-BACKEND_PORT=8080
-PYTHON_PORT=8090
-NGINX_PORT=80
-# --------------------------------
-
-RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
-info()  { echo -e "${GREEN}[INFO]${NC} $1"; }
-warn()  { echo -e "${YELLOW}[WARN]${NC} $1"; }
-error() { echo -e "${RED}[ERROR]${NC} $1"; exit 1; }
-
-# ==============================
-# 0. 前置检查
-# ==============================
-if [ "$EUID" -ne 0 ]; then error "请以 root 或 sudo 运行此脚本"; fi
-
-SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
-info "脚本所在目录: $SCRIPT_DIR"
-
-# ==============================
-# 1. 系统更新 & 基础工具
-# ==============================
-info ">>> 步骤 1/8: 更新系统和安装基础工具"
-apt-get update -y
-apt-get install -y curl wget git unzip software-properties-common \
-    ca-certificates gnupg lsb-release net-tools
-
-# ==============================
-# 2. 安装 Java 17
-# ==============================
-info ">>> 步骤 2/8: 安装 OpenJDK 17"
-if java -version 2>&1 | grep -q "17"; then
-    info "Java 17 已安装,跳过"
-else
-    apt-get install -y openjdk-17-jdk
-fi
-export JAVA_HOME=$(dirname $(dirname $(readlink -f $(which java))))
-echo "export JAVA_HOME=$JAVA_HOME" >> /etc/profile.d/java.sh
-info "Java 版本: $(java -version 2>&1 | head -1)"
-
-# ==============================
-# 3. 安装 Maven
-# ==============================
-info ">>> 步骤 3/8: 安装 Maven"
-if command -v mvn &>/dev/null; then
-    info "Maven 已安装: $(mvn -version | head -1)"
-else
-    MVN_VERSION="3.9.6"
-    wget -q "https://downloads.apache.org/maven/maven-3/${MVN_VERSION}/binaries/apache-maven-${MVN_VERSION}-bin.tar.gz" \
-        -O /tmp/maven.tar.gz
-    tar -xzf /tmp/maven.tar.gz -C /opt/
-    ln -sfn /opt/apache-maven-${MVN_VERSION} /opt/maven
-    cat > /etc/profile.d/maven.sh <<EOF
-export M2_HOME=/opt/maven
-export PATH=\$M2_HOME/bin:\$PATH
-EOF
-    source /etc/profile.d/maven.sh
-    info "Maven 版本: $(mvn -version | head -1)"
-fi
-export PATH=/opt/maven/bin:$PATH
-
-# ==============================
-# 4. 安装 Node.js 20
-# ==============================
-info ">>> 步骤 4/8: 安装 Node.js 20"
-if node --version 2>/dev/null | grep -q "v20"; then
-    info "Node.js 20 已安装"
-else
-    curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
-    apt-get install -y nodejs
-fi
-info "Node.js 版本: $(node --version), npm 版本: $(npm --version)"
-
-# ==============================
-# 5. 安装 Python 3.12
-# ==============================
-info ">>> 步骤 5/8: 安装 Python 3.12"
-if python3.12 --version &>/dev/null; then
-    info "Python 3.12 已安装"
-else
-    add-apt-repository -y ppa:deadsnakes/ppa
-    apt-get update -y
-    apt-get install -y python3.12 python3.12-venv python3.12-dev python3-pip
-fi
-info "Python 版本: $(python3.12 --version)"
-
-# ==============================
-# 6. 安装 Nginx
-# ==============================
-info ">>> 步骤 6/8: 安装 Nginx"
-apt-get install -y nginx
-info "Nginx 版本: $(nginx -v 2>&1)"
-
-# ==============================
-# 7. 创建系统用户 & 部署项目文件
-# ==============================
-info ">>> 步骤 7/8: 部署项目文件"
-
-# 创建运行用户
-if ! id "$APP_USER" &>/dev/null; then
-    useradd -r -s /bin/bash -d "$PROJECT_DIR" "$APP_USER"
-    info "创建用户: $APP_USER"
-fi
-
-# 复制项目文件到部署目录
-if [ "$SCRIPT_DIR" != "$PROJECT_DIR" ]; then
-    info "复制项目到 $PROJECT_DIR ..."
-    mkdir -p "$PROJECT_DIR"
-    cp -r "$SCRIPT_DIR/." "$PROJECT_DIR/"
-else
-    info "项目已在部署目录中"
-fi
-
-# 创建日志和临时目录
-mkdir -p "$PROJECT_DIR/logs"
-mkdir -p "$PROJECT_DIR/tmp/prometheus"
-chown -R "$APP_USER:$APP_USER" "$PROJECT_DIR"
-
-# ==============================
-# 7a. 构建 Backend (Maven)
-# ==============================
-info "--- 构建 Spring Boot Backend ---"
-cd "$PROJECT_DIR/backend"
-
-# 临时以当前用户身份执行构建
-mvn clean package -DskipTests -q \
-    -Dmaven.repo.local="$PROJECT_DIR/.m2" \
-    || error "Maven 构建失败,请检查错误信息"
-
-BACKEND_JAR=$(find "$PROJECT_DIR/backend/target" -name "*.jar" ! -name "*sources*" | head -1)
-[ -z "$BACKEND_JAR" ] && error "未找到构建产物 JAR 文件"
-info "Backend JAR: $BACKEND_JAR"
-
-# ==============================
-# 7b. 构建 Frontend (Vite)
-# ==============================
-info "--- 构建 Vue 3 Frontend ---"
-cd "$PROJECT_DIR/frontend"
-npm install --silent
-npm run build
-FRONTEND_DIST="$PROJECT_DIR/frontend/dist"
-[ ! -d "$FRONTEND_DIST" ] && error "Frontend 构建失败,dist 目录不存在"
-info "Frontend 构建完成: $FRONTEND_DIST"
-
-# ==============================
-# 7c. 安装 Python 依赖
-# ==============================
-info "--- 安装 Python 依赖 ---"
-cd "$PROJECT_DIR/python-iceberg"
-python3.12 -m venv venv
-source venv/bin/activate
-pip install --upgrade pip -q
-pip install -r requirements.txt -q
-pip install openpyxl -q   # requirements.txt 中未声明版本但代码中使用
-deactivate
-info "Python 依赖安装完成"
-
-chown -R "$APP_USER:$APP_USER" "$PROJECT_DIR"
-
-# ==============================
-# 8. 配置服务 & Nginx
-# ==============================
-info ">>> 步骤 8/8: 配置 Systemd 服务和 Nginx"
-
-# ---- 8a. Backend systemd service ----
-cat > /etc/systemd/system/wenshu-backend.service <<EOF
-[Unit]
-Description=Wenshu Platform Backend (Spring Boot)
-After=network.target
-
-[Service]
-User=$APP_USER
-WorkingDirectory=$PROJECT_DIR/backend
-ExecStart=/usr/bin/java -jar $BACKEND_JAR \\
-    --spring.config.location=file:$PROJECT_DIR/backend/src/main/resources/application.yml
-SuccessExitStatus=143
-Restart=on-failure
-RestartSec=10
-StandardOutput=append:$PROJECT_DIR/logs/backend.log
-StandardError=append:$PROJECT_DIR/logs/backend-error.log
-
-[Install]
-WantedBy=multi-user.target
-EOF
-
-# ---- 8b. Python (iceberg) systemd service ----
-cat > /etc/systemd/system/wenshu-python.service <<EOF
-[Unit]
-Description=Wenshu Platform Python Iceberg Service (FastAPI)
-After=network.target wenshu-backend.service
-
-[Service]
-User=$APP_USER
-WorkingDirectory=$PROJECT_DIR/python-iceberg
-ExecStart=$PROJECT_DIR/python-iceberg/venv/bin/uvicorn app:app \\
-    --host 0.0.0.0 --port $PYTHON_PORT
-Restart=on-failure
-RestartSec=10
-StandardOutput=append:$PROJECT_DIR/logs/python.log
-StandardError=append:$PROJECT_DIR/logs/python-error.log
-
-[Install]
-WantedBy=multi-user.target
-EOF
-
-# ---- 8c. Nginx 配置 ----
-cat > /etc/nginx/sites-available/wenshu-platform <<EOF
-server {
-    listen $NGINX_PORT;
-    server_name _;
-
-    # 前端静态文件
-    root $FRONTEND_DIST;
-    index index.html;
-
-    # Vue Router history mode 支持
-    location / {
-        try_files \$uri \$uri/ /index.html;
-    }
-
-    # 代理 REST API 到 Spring Boot
-    location /api/ {
-        proxy_pass http://127.0.0.1:$BACKEND_PORT;
-        proxy_set_header Host \$host;
-        proxy_set_header X-Real-IP \$remote_addr;
-        proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
-        proxy_connect_timeout 60s;
-        proxy_read_timeout 300s;
-        client_max_body_size 500m;
-    }
-
-    # 代理 WebSocket (SSH Terminal) 到 Spring Boot
-    location /ws {
-        proxy_pass http://127.0.0.1:$BACKEND_PORT;
-        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;
-    }
-
-    # 静态资源缓存
-    location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
-        expires 7d;
-        add_header Cache-Control "public, immutable";
-    }
-
-    access_log /var/log/nginx/wenshu-access.log;
-    error_log  /var/log/nginx/wenshu-error.log;
-}
-EOF
-
-# 启用站点
-ln -sfn /etc/nginx/sites-available/wenshu-platform \
-        /etc/nginx/sites-enabled/wenshu-platform
-rm -f /etc/nginx/sites-enabled/default
-
-# 验证 Nginx 配置
-nginx -t || error "Nginx 配置验证失败"
-
-# ==============================
-# 启动所有服务
-# ==============================
-info "启动所有服务..."
-systemctl daemon-reload
-
-systemctl enable wenshu-backend wenshu-python
-systemctl restart wenshu-backend
-sleep 3
-systemctl restart wenshu-python
-systemctl restart nginx
-
-# ==============================
-# 状态检查
-# ==============================
-echo ""
-info "=============================="
-info "  部署完成!服务状态检查:"
-info "=============================="
-
-check_service() {
-    local name=$1
-    if systemctl is-active --quiet "$name"; then
-        echo -e "  ${GREEN}✓${NC} $name 运行中"
-    else
-        echo -e "  ${RED}✗${NC} $name 未运行 (查看日志: journalctl -u $name -n 50)"
-    fi
-}
-
-check_service wenshu-backend
-check_service wenshu-python
-check_service nginx
-
-SERVER_IP=$(hostname -I | awk '{print $1}')
-echo ""
-info "访问地址: http://${SERVER_IP}"
-info "Backend API: http://${SERVER_IP}/api"
-info ""
-info "日志查看命令:"
-echo "  journalctl -u wenshu-backend -f    # Backend 实时日志"
-echo "  journalctl -u wenshu-python  -f    # Python 服务实时日志"
-echo "  tail -f $PROJECT_DIR/logs/backend.log"
-echo ""
-warn "注意:stock.ssh.crypto-key 在 application.yml 中仍为默认值"
-warn "      请修改 $PROJECT_DIR/backend/src/main/resources/application.yml"
-warn "      中的 stock.ssh.crypto-key 为安全密钥后重启 backend 服务"

+ 28 - 8
frontend/src/views/resource-management/ClusterConsolePage.vue

@@ -53,16 +53,28 @@
             <span class="info-value">{{ cluster.componentType }}</span>
           </div>
           <div class="info-item">
-            <span class="info-label">节点数量</span>
-            <span class="info-value">{{ cluster.nodeCount ?? '-' }}</span>
+            <span class="info-label">备注</span>
+            <span class="info-value">{{ cluster.description || '-' }}</span>
           </div>
           <div class="info-item">
             <span class="info-label">创建时间</span>
             <span class="info-value">{{ formatTime(cluster.createdTime) }}</span>
           </div>
-          <div class="info-item full-width">
-            <span class="info-label">备注</span>
-            <span class="info-value">{{ cluster.description || '-' }}</span>
+          <div class="info-item">
+            <span class="info-label">节点数量</span>
+            <span class="info-value">{{ cluster.nodeCount ?? '-' }}</span>
+          </div>
+          <div class="info-item">
+            <span class="info-label"> 总 CPU 核数</span>
+            <span class="info-value">{{ loadingNodes ? '-' : totalCpuCores }}</span>
+          </div>
+          <div class="info-item">
+            <span class="info-label">总内存大小 (GB)</span>
+            <span class="info-value">{{ loadingNodes ? '-' : totalRamGb }}</span>
+          </div>
+          <div class="info-item">
+            <span class="info-label">总硬盘大小 (GB)</span>
+            <span class="info-value">{{ loadingNodes ? '-' : totalSsdGb }}</span>
           </div>
         </div>
       </section>
@@ -402,7 +414,7 @@
             </div>
             <div class="form-item">
               <label>备注</label>
-              <input v-model.trim="clusterForm.description" type="text" placeholder="请输入备注(可空)" />
+              <input v-model.trim="clusterForm.description" type="text" placeholder="请输入备注(可空)" maxlength="50" />
             </div>
           </div>
           <div v-if="clusterDialogError" class="error-banner"><span>{{ clusterDialogError }}</span><button class="error-banner-close" @click="clusterDialogError = ''">&times;</button></div>
@@ -786,6 +798,10 @@ async function handleSaveCluster() {
     clusterDialogError.value = '请输入集群名称';
     return;
   }
+  if (clusterForm.description && clusterForm.description.length > 50) {
+    clusterDialogError.value = '备注不能超过 50 个字符';
+    return;
+  }
   clusterSubmitting.value = true;
   clusterDialogError.value = '';
   try {
@@ -961,6 +977,10 @@ const backNodes = computed(() => {
 const scalableNodes = computed(() => {
   return backNodes.value;
 });
+
+const totalCpuCores = computed(() => clusterNodes.value.reduce((sum, n) => sum + (n.cpuCores ?? 0), 0));
+const totalRamGb = computed(() => clusterNodes.value.reduce((sum, n) => sum + (n.ramGb ?? 0), 0));
+const totalSsdGb = computed(() => clusterNodes.value.reduce((sum, n) => sum + (n.ssdGb ?? 0), 0));
 const loadingNodes = ref(false);
 const nodeError = ref('');
 
@@ -1514,10 +1534,10 @@ onUnmounted(() => {
 .action-buttons { display: flex; gap: 10px; }
 
 .info-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; }
-.info-item { display: flex; flex-direction: column; gap: 4px; }
+.info-item { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
 .info-item.full-width { grid-column: 1 / -1; flex-direction: row; gap: 12px; align-items: baseline; }
 .info-label { font-size: 12px; color: var(--text-muted); }
-.info-value { font-size: 13px; color: var(--text-main); }
+.info-value { font-size: 13px; color: var(--text-main); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
 .id-text { font-family: monospace; font-size: 12px; color: var(--text-muted); }
 .mono-text { font-family: monospace; font-size: 12px; }
 

+ 3 - 2
frontend/src/views/resource-management/ClusterPage.vue

@@ -130,7 +130,7 @@
               </div>
               <div class="form-item full-width">
                 <label>描述</label>
-                <input v-model.trim="createForm.description" type="text" placeholder="集群用途说明(可选)" />
+                <input v-model.trim="createForm.description" type="text" placeholder="集群用途说明(可选)" maxlength="50" />
               </div>
             </div>
 
@@ -485,6 +485,7 @@ function backToEngineStep() {
 
 async function handleCreate() {
   if (!createForm.clusterName) { createError.value = '请输入集群名称'; return; }
+  if (createForm.description && createForm.description.length > 50) { createError.value = '备注不能超过 50 个字符'; return; }
   const versions = getVersionsForEngine(selectedEngine.value);
   if (!createForm.version) { createError.value = '请选择版本'; return; }
   if (versions.length > 0 && !versions.includes(createForm.version)) {
@@ -747,7 +748,7 @@ onMounted(() => {
 .info-row { display: flex; align-items: baseline; gap: 8px; font-size: 13px; }
 
 .info-label { color: var(--text-muted); flex-shrink: 0; width: 64px; text-align: right; }
-.info-value { color: var(--text-regular); flex: 1; text-align: right; }
+.info-value { color: var(--text-regular); flex: 1; min-width: 0; text-align: right; }
 .id-text { font-family: monospace; font-size: 12px; color: var(--text-muted); }
 .note-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
 

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

@@ -132,7 +132,7 @@
             </div>
             <div class="form-field">
               <label>备注</label>
-              <input v-model.trim="form.description" type="text" placeholder="例如 rack-a-01" />
+              <input v-model.trim="form.description" type="text" placeholder="例如 rack-a-01" maxlength="50" />
             </div>
             <div v-if="dialog.isEdit && !isWorkingEdit" class="form-field">
               <label>状态</label>
@@ -362,6 +362,7 @@ function validateForm() {
   if (!form.ramGb) return '请输入内存大小';
   if (!form.ssdGb) return '请输入 SSD 大小';
   if (!dialog.isEdit && !form.sshPassword) return '请输入 SSH 密码';
+  if (form.description && form.description.length > 50) return '备注不能超过 50 个字符';
   return null;
 }
 

+ 2 - 1
frontend/src/views/task-build/CodeTaskBuildPage.vue

@@ -27,7 +27,7 @@
           </div>
           <div class="config-item workflow-desc-config">
             <label>工作流描述</label>
-            <input v-model.trim="workflow.description" class="workflow-desc-input" placeholder="(可选)输入工作流描述..." maxlength="256" />
+            <input v-model.trim="workflow.description" class="workflow-desc-input" placeholder="(可选)输入工作流描述..." maxlength="50" />
           </div>
           <div class="config-item">
             <label>超时时间(秒)</label>
@@ -450,6 +450,7 @@ async function loadExample() {
 async function handleSubmit() {
   errorMessage.value = ''; successMessage.value = '';
   if (!workflow.name) { errorMessage.value = '请输入工作流名称'; return; }
+  if (workflow.description && workflow.description.length > 50) { errorMessage.value = '工作流描述不能超过 50 个字符'; return; }
   const workflowTimeoutSeconds = Number(workflow.timeoutSeconds);
   if (!Number.isInteger(workflowTimeoutSeconds) || workflowTimeoutSeconds <= 0) {
     errorMessage.value = '工作流超时必须为大于 0 的整数秒';

+ 8 - 3
frontend/src/views/task-build/TaskBuildWorkflowListPage.vue

@@ -77,7 +77,7 @@
                   <template v-else>{{ selectedWorkflowId === item.workflowId ? (detail.failureStrategy === 'STOP' ? '失败即终止' : detail.failureStrategy === 'CONTINUE' ? '失败后继续' : '-') : (item.failureStrategy === 'STOP' ? '失败即终止' : item.failureStrategy === 'CONTINUE' ? '失败后继续' : '-') }}</template>
                 </td>
                 <td :title="selectedWorkflowId !== item.workflowId ? item.description : undefined" class="desc-text col-remark">
-                  <input v-if="selectedWorkflowId === item.workflowId && isEditingWorkflow" v-model="detail.description" class="inline-input inline-input-desc" type="text" maxlength="256" placeholder="(可选)" @click.stop />
+                  <input v-if="selectedWorkflowId === item.workflowId && isEditingWorkflow" v-model="detail.description" class="inline-input inline-input-desc" type="text" maxlength="50" placeholder="(可选)" @click.stop />
                   <template v-else>{{ selectedWorkflowId === item.workflowId ? (detail.description || '-') : (item.description || '-') }}</template>
                 </td>
                 <td class="time-text">{{ formatTime(item.createTime) }}</td>
@@ -87,7 +87,7 @@
                       <button class="btn btn-primary btn-sm" :disabled="detail.saving" @click.stop="handleSaveWorkflow">{{ detail.saving ? '保存中...' : '保存' }}</button>
                       <button class="btn btn-default btn-sm" :disabled="detail.saving" @click.stop="cancelEditWorkflow">取消</button>
                     </template>
-                    <button v-else class="btn btn-primary btn-sm" @click.stop="isEditingWorkflow = true">修改</button>
+                    <button v-else class="btn btn-primary btn-sm" @click.stop="isEditingWorkflow = true">编辑</button>
                   </template>
                   <template v-else>
                     <button
@@ -191,7 +191,7 @@
                       <button class="btn btn-primary btn-sm" :disabled="detail.saving" @click="handleSaveTask">{{ detail.saving ? '保存中...' : '保存' }}</button>
                       <button class="btn btn-default btn-sm" :disabled="detail.saving" @click="cancelEditTask">取消</button>
                     </template>
-                    <button v-else class="btn btn-primary btn-sm" @click="isEditingTask = true">修改</button>
+                    <button v-else class="btn btn-primary btn-sm" @click="isEditingTask = true">编辑</button>
                   </div>
                 </div>
                 <div class="detail-row">
@@ -572,6 +572,11 @@ async function handleSaveWorkflow() {
     detail.success = '';
     return;
   }
+  if (detail.description && detail.description.length > 50) {
+    detail.error = '备注不能超过 50 个字符';
+    detail.success = '';
+    return;
+  }
   const workflowTimeoutSeconds = Number(detail.timeoutSeconds);
   if (!Number.isInteger(workflowTimeoutSeconds) || workflowTimeoutSeconds <= 0) {
     detail.error = '工作流超时必须为大于 0 的整数秒';

+ 1 - 1
k8s-orchestrator/README.md

@@ -1,7 +1,7 @@
 # k8s-orchestrator 生产级部署说明 + 全量接口文档(Spark + StarRocks)
 
 文档版本:v1.0  
-适用代码:`D:\project\k8s-orchestrator` 当前实现  
+适用代码:`D:\project\wenshu-platform\k8s-orchestrator` 当前实现  
 适用场景:真实物理机 Kubernetes 集群(非单机演示)
 
 ---

+ 936 - 0
部署文档-从零开始.md

@@ -0,0 +1,936 @@
+# Wenshu Platform 部署文档
+
+> 服务器要求: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 更新系统
+
+```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 配置服务器变量(后续步骤都会用到)
+
+```bash
+# 替换为你的服务器公网 IP(本机部署用 127.0.0.1)
+export SERVER_IP=<你的服务器公网IP>
+echo "export SERVER_IP=${SERVER_IP}" >> ~/.bashrc
+```
+
+---
+
+## 第二章:安装 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
+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" \
+  | 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 compose version
+```
+
+---
+
+## 第三章:获取代码
+
+```bash
+# 假设代码已在此路径,如需从 git 克隆请替换为实际仓库地址
+cd ~
+git clone <你的仓库地址> wenshu-platform
+cd wenshu-platform
+```
+
+---
+
+## 第四章:编写 Dockerfile
+
+在项目根目录下创建各服务的 Dockerfile。
+
+### 4.1 后端 Dockerfile
+
+```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
+```
+
+### 4.2 Python Iceberg Dockerfile
+
+```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
+```
+
+### 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
+```
+
+### 4.4 前端 Nginx 配置
+
+```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;
+    }
+
+    # 代理 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;
+    }
+}
+EOF
+```
+
+---
+
+## 第五章:修改应用配置
+
+### 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
+```
+
+> **注意**:`polaris.oss.*` 配置是真实 OSS 凭据,请替换为你自己的值,不要提交到 git。
+
+---
+
+## 第六章:一键 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 创建数据目录
+
+```bash
+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
+
+Polaris 首次启动后需要初始化 realm 和 root 管理凭据(**只需执行一次**)。
+
+```bash
+# 等待 Polaris 完全启动
+docker compose logs -f polaris | grep -m1 "started"
+
+# 加载 OSS 凭据
+source .env
+
+# 获取 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。
+
+```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
+      }
+    }
+  }' | jq .
+
+# 验证
+curl -s http://localhost:8181/api/management/v1/catalogs \
+  -H "Authorization: Bearer $TOKEN" \
+  -H "Polaris-Realm: POLARIS" | jq '.catalogs[].name'
+# 应输出 "demo_catalog"
+```
+
+---
+
+## 第八章:验证所有服务
+
+```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
+```
+
+控制平面节点执行:
+
+```bash
+sudo kubeadm init --pod-network-cidr=192.168.0.0/16
+
+mkdir -p $HOME/.kube
+sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config
+sudo chown $(id -u):$(id -g) $HOME/.kube/config
+
+# 安装 CNI(Calico)
+kubectl apply -f https://raw.githubusercontent.com/projectcalico/calico/v3.27.0/manifests/calico.yaml
+
+kubectl get nodes
+```
+
+### 10.2 安装 Spark + StarRocks Operator
+
+```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
+```
+
+### 10.3 安装 Go 1.22
+
+```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
+```
+
+### 10.4 构建并部署 k8s-orchestrator
+
+```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
+```
+
+然后将 orchestrator 的 API 地址配置到 Spring Boot 的 `application.yml`(如有相关配置项)。
+
+---
+
+## 附录 A:常用运维命令
+
+```bash
+# 查看所有服务状态
+docker compose ps
+
+# 重启单个服务
+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)
+```