Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,6 @@ public ResponseEntity<RsData<BodyForReactFlow>> getGraph(
"ID: " + dashboardId + " 의 React-flow 데이터를 조회했습니다.",
BodyForReactFlow.from(graph)
));

}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package org.tuna.zoopzoop.backend.domain.dashboard.dto;

import java.util.List;
import java.util.Map;

public record ReqBodyForLiveblocksAuth(
String userId,
UserInfo userInfo,
Map<String, List<String>> permissions
) {
public record UserInfo(
String name,
String avatar
) {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package org.tuna.zoopzoop.backend.domain.dashboard.dto;

public record ResBodyForAuthToken(
String token
){ }
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,24 @@
import org.springframework.transaction.annotation.Transactional;
import org.tuna.zoopzoop.backend.domain.dashboard.dto.BodyForReactFlow;
import org.tuna.zoopzoop.backend.domain.dashboard.dto.GraphUpdateMessage;
import org.tuna.zoopzoop.backend.domain.dashboard.dto.ReqBodyForLiveblocksAuth;
import org.tuna.zoopzoop.backend.domain.dashboard.entity.Dashboard;
import org.tuna.zoopzoop.backend.domain.dashboard.entity.Edge;
import org.tuna.zoopzoop.backend.domain.dashboard.entity.Graph;
import org.tuna.zoopzoop.backend.domain.dashboard.entity.Node;
import org.tuna.zoopzoop.backend.domain.dashboard.repository.DashboardRepository;
import org.tuna.zoopzoop.backend.domain.member.entity.Member;
import org.tuna.zoopzoop.backend.domain.space.membership.entity.Membership;
import org.tuna.zoopzoop.backend.domain.space.membership.enums.Authority;
import org.tuna.zoopzoop.backend.domain.space.membership.service.MembershipService;
import org.tuna.zoopzoop.backend.domain.space.space.entity.Space;
import org.tuna.zoopzoop.backend.domain.space.space.service.SpaceService;
import org.tuna.zoopzoop.backend.global.clients.liveblocks.LiveblocksClient;

import java.nio.file.AccessDeniedException;
import java.util.Collections;
import java.util.List;
import java.util.Map;

@Service
@RequiredArgsConstructor
Expand All @@ -30,7 +38,8 @@ public class DashboardService {
private final ObjectMapper objectMapper;
private final SignatureService signatureService;
private final RabbitTemplate rabbitTemplate;

private final SpaceService spaceService;
private final LiveblocksClient liveblocksClient;


// =========================== Graph 관련 메서드 ===========================
Expand Down Expand Up @@ -134,8 +143,59 @@ public void queueGraphUpdate(Integer dashboardId, String requestBody, String sig
rabbitTemplate.convertAndSend("zoopzoop.exchange", "graph.update.rk", message);
}

// =========================== 기타 메서드 ===========================

/**
* 특정 스페이스에 대한 Liveblocks 접속 토큰(JWT)을 발급합니다.
* @param spaceId 스페이스 ID
* @param member 토큰을 요청하는 멤버
* @return 발급된 JWT 문자열
* @throws AccessDeniedException 멤버가 해당 스페이스에 속해있지 않거나 권한이 없는 경우
*/
@Transactional(readOnly = true)
public String getAuthTokenForSpace(Integer spaceId, Member member) throws AccessDeniedException {
Space space = spaceService.findById(spaceId);

// 해당 스페이스에 멤버가 속해있는지, PENDING 상태는 아닌지 확인
Membership membership = membershipService.findByMemberAndSpace(member, space);
if (membership.getAuthority().equals(Authority.PENDING)) {
throw new AccessDeniedException("스페이스에 가입된 멤버가 아닙니다.");
}

// Liveblocks Room ID 생성
String roomId = "space_" + space.getId();

// Liveblocks에 전달할 사용자 정보 생성
String userId = String.valueOf(member.getId());
ReqBodyForLiveblocksAuth.UserInfo userInfo = new ReqBodyForLiveblocksAuth.UserInfo(
member.getName(),
member.getProfileImageUrl()
);

// Liveblocks 권한 설정 (내 서비스의 Authority -> Liveblocks 권한으로 변환)
List<String> permissions;
switch (membership.getAuthority()) {
case ADMIN, READ_WRITE:
permissions = List.of("room:write");
break;
case READ_ONLY:
permissions = Collections.emptyList(); // 빈 리스트는 읽기 전용을 의미
break;
default:
// PENDING 등 다른 상태는 위에서 이미 필터링됨
throw new AccessDeniedException("유효하지 않은 권한입니다.");
}
Comment on lines +177 to +187
Copy link

Copilot AI Oct 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The switch statement could be simplified using a more explicit mapping. Consider extracting the authority-to-permissions mapping into a separate method or using a Map for better maintainability.

Copilot uses AI. Check for mistakes.

// Liveblocks Client에 전달할 요청 객체 생성
ReqBodyForLiveblocksAuth authRequest = new ReqBodyForLiveblocksAuth(
userId,
userInfo,
Map.of(roomId, permissions)
);

// LiveblocksClient를 통해 토큰 발급 요청
return liveblocksClient.getAuthToken(authRequest);
}


}
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,13 @@
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.web.PageableDefault;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.tuna.zoopzoop.backend.domain.dashboard.dto.ResBodyForAuthToken;
import org.tuna.zoopzoop.backend.domain.dashboard.service.DashboardService;
import org.tuna.zoopzoop.backend.domain.member.entity.Member;
import org.tuna.zoopzoop.backend.domain.space.membership.dto.etc.SpaceMemberInfo;
import org.tuna.zoopzoop.backend.domain.space.membership.entity.Membership;
Expand Down Expand Up @@ -38,6 +42,7 @@
public class ApiV1SpaceController {
private final SpaceService spaceService;
private final MembershipService membershipService;
private final DashboardService dashboardService;

@PostMapping
@Operation(summary = "스페이스 생성")
Expand Down Expand Up @@ -205,5 +210,30 @@ public RsData<ResBodyForSpaceInfo> getSpace(
);
}

/**
* Liveblocks 접속을 위한 인증 토큰(JWT) 발급 API
* @param spaceId 스페이스 ID
* @param userDetails 현재 로그인한 사용자 정보
* @return ResponseEntity<RsData<AuthTokenResponse>>
*/
@PostMapping("/dashboard-auth/{spaceId}")
@Operation(summary = "Liveblocks 접속 토큰 발급")
public ResponseEntity<RsData<ResBodyForAuthToken>> getAuthToken(
@PathVariable Integer spaceId,
@AuthenticationPrincipal CustomUserDetails userDetails) throws AccessDeniedException {

Member member = userDetails.getMember();
String token = dashboardService.getAuthTokenForSpace(spaceId, member);

ResBodyForAuthToken response = new ResBodyForAuthToken(token);

return ResponseEntity
.status(HttpStatus.OK)
.body(new RsData<>(
"200",
"Liveblocks 접속 토큰이 발급되었습니다.",
response
));
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,15 @@
import org.tuna.zoopzoop.backend.domain.space.space.exception.DuplicateSpaceNameException;
import org.tuna.zoopzoop.backend.domain.space.space.repository.SpaceRepository;
import org.tuna.zoopzoop.backend.global.aws.S3Service;
import org.tuna.zoopzoop.backend.global.clients.liveblocks.LiveblocksClient;

@Service
@RequiredArgsConstructor
public class SpaceService {
private final SpaceRepository spaceRepository;
private final S3Service s3Service;
private final MembershipService membershipService;
private final LiveblocksClient liveblocksClient;
private final TagRepository tagRepository;
private final DataSourceRepository dataSourceRepository;

Expand Down Expand Up @@ -61,17 +63,7 @@ public Space findByName(String name) {
*/
@Transactional
public Space createSpace(@NotBlank @Length(max = 50) String name) {
Space newSpace = Space.builder()
.name(name)
.build();

try{
return spaceRepository.save(newSpace);
}catch (DataIntegrityViolationException e) {
throw new DuplicateSpaceNameException("이미 존재하는 스페이스 이름입니다.");
} catch (Exception e) {
throw e;
}
return createSpace(name, null);
}

/**
Expand All @@ -87,13 +79,19 @@ public Space createSpace(@NotBlank @Length(max = 50) String name, String thumbna
.thumbnailUrl(thumbnailUrl)
.build();

Space savedSpace;
try{
return spaceRepository.save(newSpace);
savedSpace = spaceRepository.save(newSpace);
}catch (DataIntegrityViolationException e) {
throw new DuplicateSpaceNameException("이미 존재하는 스페이스 이름입니다.");
} catch (Exception e) {
throw e;
}

// Liveblocks에 방 생성 요청
liveblocksClient.createRoom("space_" + savedSpace.getId());

return savedSpace;
}

/**
Expand All @@ -109,6 +107,10 @@ public String deleteSpace(Integer spaceId) {
.orElseThrow(() -> new NoResultException("존재하지 않는 스페이스입니다."));

String spaceName = space.getName();
String roomId = "space_" + space.getId();

// Liveblocks에 방 삭제 요청
liveblocksClient.deleteRoom(roomId);

tagRepository.bulkDeleteTagsBySpaceId(spaceId);
dataSourceRepository.bulkDeleteBySpaceId(spaceId);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package org.tuna.zoopzoop.backend.global.clients.liveblocks;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.*;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestTemplate;
import org.tuna.zoopzoop.backend.domain.dashboard.dto.ReqBodyForLiveblocksAuth;
import org.tuna.zoopzoop.backend.domain.dashboard.dto.ResBodyForAuthToken;

import java.util.Collections;
import java.util.HashMap;
import java.util.Map;

@Slf4j
@Component
@RequiredArgsConstructor
public class LiveblocksClient {

private final RestTemplate restTemplate;

@Value("${liveblocks.secret-key}")
private String secretKey;

private static final String LIVEBLOCKS_API_URL = "https://api.liveblocks.io/v2/rooms";
private static final String AUTH_API_URL = "https://api.liveblocks.io/v2/authorize-user";
/**
* Liveblocks 서버에 새로운 방을 생성합니다.
* @param roomId 생성할 방의 고유 ID (워크스페이스 ID와 동일하게 사용)
*/
public void createRoom(String roomId) {
// 1. HTTP 헤더 설정 (Authorization: Bearer sk_...)
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(secretKey);
headers.setContentType(MediaType.APPLICATION_JSON);

// 2. Request Body 생성 (비공개 방으로 생성)
Map<String, Object> requestBody = new HashMap<>();
requestBody.put("id", roomId);
requestBody.put("defaultAccesses", Collections.emptyList()); // 비공개(private) 방으로 설정

// 3. HTTP 요청 엔티티 생성
HttpEntity<Map<String, Object>> requestEntity = new HttpEntity<>(requestBody, headers);

try {
// 4. Liveblocks API에 POST 요청 전송
ResponseEntity<String> response = restTemplate.postForEntity(LIVEBLOCKS_API_URL, requestEntity, String.class);

if (response.getStatusCode().is2xxSuccessful()) {
log.info("Liveblocks room created successfully. roomId: {}", roomId);
} else {
log.error("Failed to create Liveblocks room. roomId: {}, status: {}, body: {}",
roomId, response.getStatusCode(), response.getBody());
}
} catch (RestClientException e) {
log.error("Error while calling Liveblocks API to create room. roomId: {}", roomId, e);
// 필요하다면 여기서 커스텀 예외를 발생시켜 서비스 레이어에서 처리하도록 할 수 있습니다.
throw new RuntimeException("Liveblocks API call failed", e);
}
}

/**
* Liveblocks 서버의 방을 삭제합니다.
* @param roomId 삭제할 방의 고유 ID
*/
public void deleteRoom(String roomId) {
String deleteUrl = LIVEBLOCKS_API_URL + "/" + roomId;
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(secretKey);
HttpEntity<Void> requestEntity = new HttpEntity<>(headers);

try {
restTemplate.exchange(deleteUrl, HttpMethod.DELETE, requestEntity, Void.class);
log.info("Liveblocks room deleted successfully. roomId: {}", roomId);
} catch (RestClientException e) {
log.error("Error while calling Liveblocks API to delete room. roomId: {}", roomId, e);
// 방 삭제 실패가 전체 로직에 큰 영향을 주지 않는다면,
// 예외를 던지는 대신 에러 로그만 남기고 넘어갈 수도 있습니다.
// 여기서는 일단 예외를 던져서 트랜잭션을 롤백하도록 합니다.
throw new RuntimeException("Liveblocks API call failed", e);
}
}

/**
* Liveblocks 사용자 인증 토큰(JWT)을 발급받습니다.
* @param request 인증에 필요한 사용자 정보, 권한 등을 담은 객체
* @return 발급된 JWT 문자열
*/
public String getAuthToken(ReqBodyForLiveblocksAuth request) {
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(secretKey);
headers.setContentType(MediaType.APPLICATION_JSON);

HttpEntity<ReqBodyForLiveblocksAuth> requestEntity = new HttpEntity<>(request, headers);

try {
ResponseEntity<String> response = restTemplate.postForEntity(AUTH_API_URL, requestEntity, String.class);
if (response.getStatusCode().is2xxSuccessful()) {
log.info("Liveblocks auth token issued successfully for user: {}", request.userId());
return response.getBody();
Comment on lines +99 to +102
Copy link

Copilot AI Oct 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The getAuthToken method returns ResponseEntity but should return the parsed token. Consider using ResBodyForAuthToken or parsing the JSON response to extract just the token field.

Suggested change
ResponseEntity<String> response = restTemplate.postForEntity(AUTH_API_URL, requestEntity, String.class);
if (response.getStatusCode().is2xxSuccessful()) {
log.info("Liveblocks auth token issued successfully for user: {}", request.userId());
return response.getBody();
ResponseEntity<ResBodyForAuthToken> response = restTemplate.postForEntity(AUTH_API_URL, requestEntity, ResBodyForAuthToken.class);
if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
log.info("Liveblocks auth token issued successfully for user: {}", request.userId());
return response.getBody().token();

Copilot uses AI. Check for mistakes.
} else {
log.error("Failed to issue Liveblocks auth token. user: {}, status: {}, body: {}",
request.userId(), response.getStatusCode(), response.getBody());
throw new RuntimeException("Failed to issue Liveblocks auth token.");
}
} catch (RestClientException e) {
log.error("Error while calling Liveblocks auth API for user: {}", request.userId(), e);
throw new RuntimeException("Liveblocks auth API call failed", e);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package org.tuna.zoopzoop.backend.global.config.restTemplate;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate;

@Configuration
public class RestTemplateConfig {

@Bean
public RestTemplate restTemplate() {
return new RestTemplate();
}
}
Loading