feat: unify moba workspace and persist session tree layout

This commit is contained in:
liumangmang
2026-04-10 11:04:21 +08:00
parent bba36a2e12
commit f606d20000
27 changed files with 1383 additions and 426 deletions

View File

@@ -0,0 +1,45 @@
package com.sshmanager.controller;
import com.sshmanager.dto.SessionTreeLayoutDto;
import com.sshmanager.entity.User;
import com.sshmanager.repository.UserRepository;
import com.sshmanager.service.SessionTreeLayoutService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/session-tree")
public class SessionTreeLayoutController {
private final SessionTreeLayoutService sessionTreeLayoutService;
private final UserRepository userRepository;
public SessionTreeLayoutController(SessionTreeLayoutService sessionTreeLayoutService,
UserRepository userRepository) {
this.sessionTreeLayoutService = sessionTreeLayoutService;
this.userRepository = userRepository;
}
@GetMapping
public ResponseEntity<SessionTreeLayoutDto> getLayout(Authentication authentication) {
Long userId = getCurrentUserId(authentication);
return ResponseEntity.ok(sessionTreeLayoutService.getLayout(userId));
}
@PutMapping
public ResponseEntity<SessionTreeLayoutDto> saveLayout(@RequestBody SessionTreeLayoutDto request,
Authentication authentication) {
Long userId = getCurrentUserId(authentication);
return ResponseEntity.ok(sessionTreeLayoutService.saveLayout(userId, request));
}
private Long getCurrentUserId(Authentication auth) {
User user = userRepository.findByUsername(auth.getName()).orElseThrow(() -> new IllegalStateException("User not found"));
return user.getId();
}
}

View File

@@ -0,0 +1,16 @@
package com.sshmanager.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.ArrayList;
import java.util.List;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class SessionTreeLayoutDto {
private List<SessionTreeNodeDto> nodes = new ArrayList<SessionTreeNodeDto>();
}

View File

@@ -0,0 +1,21 @@
package com.sshmanager.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class SessionTreeNodeDto {
private String id;
private String type;
private String name;
private String parentId;
private Integer order;
private Long connectionId;
private Boolean expanded;
private Long createdAt;
private Long updatedAt;
}

View File

@@ -0,0 +1,34 @@
package com.sshmanager.entity;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Table;
import java.time.Instant;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "session_tree_layouts")
public class SessionTreeLayout {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private Long userId;
@Column(nullable = false, columnDefinition = "CLOB")
private String layoutJson;
@Column(nullable = false)
private Instant updatedAt = Instant.now();
}

View File

@@ -0,0 +1,11 @@
package com.sshmanager.repository;
import com.sshmanager.entity.SessionTreeLayout;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface SessionTreeLayoutRepository extends JpaRepository<SessionTreeLayout, Long> {
Optional<SessionTreeLayout> findByUserId(Long userId);
}

View File

@@ -100,15 +100,17 @@ public class ConnectionService {
return ConnectionDto.fromEntity(conn);
}
@Transactional
public void delete(Long id, Long userId) {
Connection conn = connectionRepository.findById(id).orElseThrow(
() -> new RuntimeException("Connection not found: " + id));
if (!conn.getUserId().equals(userId)) {
throw new RuntimeException("Access denied");
}
connectionRepository.delete(conn);
}
@Transactional
public void delete(Long id, Long userId) {
Connection conn = connectionRepository.findById(id).orElse(null);
if (conn == null) {
return;
}
if (!conn.getUserId().equals(userId)) {
throw new RuntimeException("Access denied");
}
connectionRepository.delete(conn);
}
public Connection getConnectionForSsh(Long id, Long userId) {
Connection conn = connectionRepository.findById(id).orElseThrow(

View File

@@ -0,0 +1,69 @@
package com.sshmanager.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sshmanager.dto.SessionTreeLayoutDto;
import com.sshmanager.entity.SessionTreeLayout;
import com.sshmanager.repository.SessionTreeLayoutRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.ArrayList;
@Service
public class SessionTreeLayoutService {
private final SessionTreeLayoutRepository sessionTreeLayoutRepository;
private final ObjectMapper objectMapper;
public SessionTreeLayoutService(SessionTreeLayoutRepository sessionTreeLayoutRepository,
ObjectMapper objectMapper) {
this.sessionTreeLayoutRepository = sessionTreeLayoutRepository;
this.objectMapper = objectMapper;
}
@Transactional(readOnly = true)
public SessionTreeLayoutDto getLayout(Long userId) {
SessionTreeLayout layout = sessionTreeLayoutRepository.findByUserId(userId).orElse(null);
if (layout == null || layout.getLayoutJson() == null || layout.getLayoutJson().trim().isEmpty()) {
return createEmptyLayout();
}
try {
SessionTreeLayoutDto parsed = objectMapper.readValue(layout.getLayoutJson(), SessionTreeLayoutDto.class);
if (parsed.getNodes() == null) {
parsed.setNodes(new ArrayList<>());
}
return parsed;
} catch (Exception e) {
return createEmptyLayout();
}
}
@Transactional
public SessionTreeLayoutDto saveLayout(Long userId, SessionTreeLayoutDto request) {
SessionTreeLayoutDto payload = request == null ? createEmptyLayout() : request;
if (payload.getNodes() == null) {
payload.setNodes(new ArrayList<>());
}
final String layoutJson;
try {
layoutJson = objectMapper.writeValueAsString(payload);
} catch (Exception e) {
throw new RuntimeException("Failed to serialize session tree layout", e);
}
SessionTreeLayout layout = sessionTreeLayoutRepository.findByUserId(userId).orElseGet(SessionTreeLayout::new);
layout.setUserId(userId);
layout.setLayoutJson(layoutJson);
layout.setUpdatedAt(Instant.now());
sessionTreeLayoutRepository.save(layout);
return payload;
}
private SessionTreeLayoutDto createEmptyLayout() {
return new SessionTreeLayoutDto(new ArrayList<>());
}
}

View File

@@ -0,0 +1,70 @@
package com.sshmanager.controller;
import com.sshmanager.dto.SessionTreeLayoutDto;
import com.sshmanager.entity.User;
import com.sshmanager.repository.UserRepository;
import com.sshmanager.service.SessionTreeLayoutService;
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.ResponseEntity;
import org.springframework.security.core.Authentication;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class SessionTreeLayoutControllerTest {
@Mock
private SessionTreeLayoutService sessionTreeLayoutService;
@Mock
private UserRepository userRepository;
@InjectMocks
private SessionTreeLayoutController sessionTreeLayoutController;
private Authentication authentication;
@BeforeEach
void setUp() {
authentication = mock(Authentication.class);
when(authentication.getName()).thenReturn("testuser");
User user = new User();
user.setId(1L);
when(userRepository.findByUsername("testuser")).thenReturn(Optional.of(user));
}
@Test
void getLayoutUsesCurrentUserId() {
SessionTreeLayoutDto expected = new SessionTreeLayoutDto();
when(sessionTreeLayoutService.getLayout(1L)).thenReturn(expected);
ResponseEntity<SessionTreeLayoutDto> response = sessionTreeLayoutController.getLayout(authentication);
assertEquals(200, response.getStatusCode().value());
assertEquals(expected, response.getBody());
verify(sessionTreeLayoutService).getLayout(1L);
}
@Test
void saveLayoutUsesCurrentUserId() {
SessionTreeLayoutDto request = new SessionTreeLayoutDto();
when(sessionTreeLayoutService.saveLayout(1L, request)).thenReturn(request);
ResponseEntity<SessionTreeLayoutDto> response = sessionTreeLayoutController.saveLayout(request, authentication);
assertEquals(200, response.getStatusCode().value());
assertEquals(request, response.getBody());
verify(sessionTreeLayoutService).saveLayout(1L, request);
}
}

View File

@@ -0,0 +1,91 @@
package com.sshmanager.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sshmanager.dto.SessionTreeLayoutDto;
import com.sshmanager.dto.SessionTreeNodeDto;
import com.sshmanager.entity.SessionTreeLayout;
import com.sshmanager.repository.SessionTreeLayoutRepository;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Arrays;
import java.util.Collections;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class SessionTreeLayoutServiceTest {
@Mock
private SessionTreeLayoutRepository sessionTreeLayoutRepository;
@Mock
private ObjectMapper objectMapper;
@InjectMocks
private SessionTreeLayoutService sessionTreeLayoutService;
@Test
void getLayoutReturnsEmptyWhenNoSavedData() {
when(sessionTreeLayoutRepository.findByUserId(1L)).thenReturn(Optional.empty());
SessionTreeLayoutDto result = sessionTreeLayoutService.getLayout(1L);
assertTrue(result.getNodes().isEmpty());
}
@Test
void getLayoutParsesSavedJson() throws Exception {
SessionTreeLayout saved = new SessionTreeLayout();
saved.setUserId(1L);
saved.setLayoutJson("{\"nodes\":[{\"id\":\"n1\"}]}");
SessionTreeLayoutDto parsed = new SessionTreeLayoutDto(Arrays.asList(
new SessionTreeNodeDto("n1", "folder", "我的连接", null, 0, null, true, 1L, 1L)
));
when(sessionTreeLayoutRepository.findByUserId(1L)).thenReturn(Optional.of(saved));
when(objectMapper.readValue(saved.getLayoutJson(), SessionTreeLayoutDto.class)).thenReturn(parsed);
SessionTreeLayoutDto result = sessionTreeLayoutService.getLayout(1L);
assertEquals(1, result.getNodes().size());
assertEquals("n1", result.getNodes().get(0).getId());
}
@Test
void saveLayoutNormalizesNullNodes() throws Exception {
SessionTreeLayoutDto request = new SessionTreeLayoutDto();
request.setNodes(null);
when(sessionTreeLayoutRepository.findByUserId(1L)).thenReturn(Optional.empty());
when(objectMapper.writeValueAsString(any(SessionTreeLayoutDto.class))).thenReturn("{\"nodes\":[]}");
SessionTreeLayoutDto result = sessionTreeLayoutService.saveLayout(1L, request);
assertTrue(result.getNodes().isEmpty());
ArgumentCaptor<SessionTreeLayout> captor = ArgumentCaptor.forClass(SessionTreeLayout.class);
verify(sessionTreeLayoutRepository).save(captor.capture());
assertEquals(1L, captor.getValue().getUserId().longValue());
assertEquals("{\"nodes\":[]}", captor.getValue().getLayoutJson());
}
@Test
void saveLayoutCreatesEmptyPayloadWhenRequestIsNull() throws Exception {
when(sessionTreeLayoutRepository.findByUserId(1L)).thenReturn(Optional.empty());
when(objectMapper.writeValueAsString(any(SessionTreeLayoutDto.class))).thenReturn("{\"nodes\":[]}");
SessionTreeLayoutDto result = sessionTreeLayoutService.saveLayout(1L, null);
assertEquals(Collections.emptyList(), result.getNodes());
}
}