Skip to content

Commit e30fee8

Browse files
EpicFnEpicFn
andauthored
[Feat/OPS-379] liveblocks 연동 (#124)
* new : Liveblock API 호출을 위한 Client 빈 생성 * feat : 스페이스 생성/삭제 시 liveblocks room 도 함께 생성/삭제 * fix : test 시 mock 빈 사용 * feat : jwt 발급 로직 구현 * fix : SpaceArchiveDataSourceControllerTest에서 liveblocks 빈 mock 처리 * fix : 오타 수정 --------- Co-authored-by: EpicFn <[email protected]>
1 parent 0779bb8 commit e30fee8

File tree

17 files changed

+347
-14
lines changed

17 files changed

+347
-14
lines changed

src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/controller/ApiV1DashboardController.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,6 @@ public ResponseEntity<RsData<BodyForReactFlow>> getGraph(
7171
"ID: " + dashboardId + " 의 React-flow 데이터를 조회했습니다.",
7272
BodyForReactFlow.from(graph)
7373
));
74-
7574
}
75+
7676
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package org.tuna.zoopzoop.backend.domain.dashboard.dto;
2+
3+
import java.util.List;
4+
import java.util.Map;
5+
6+
public record ReqBodyForLiveblocksAuth(
7+
String userId,
8+
UserInfo userInfo,
9+
Map<String, List<String>> permissions
10+
) {
11+
public record UserInfo(
12+
String name,
13+
String avatar
14+
) {}
15+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
package org.tuna.zoopzoop.backend.domain.dashboard.dto;
2+
3+
public record ResBodyForAuthToken(
4+
String token
5+
){ }

src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/service/DashboardService.java

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,24 @@
1010
import org.springframework.transaction.annotation.Transactional;
1111
import org.tuna.zoopzoop.backend.domain.dashboard.dto.BodyForReactFlow;
1212
import org.tuna.zoopzoop.backend.domain.dashboard.dto.GraphUpdateMessage;
13+
import org.tuna.zoopzoop.backend.domain.dashboard.dto.ReqBodyForLiveblocksAuth;
1314
import org.tuna.zoopzoop.backend.domain.dashboard.entity.Dashboard;
1415
import org.tuna.zoopzoop.backend.domain.dashboard.entity.Edge;
1516
import org.tuna.zoopzoop.backend.domain.dashboard.entity.Graph;
1617
import org.tuna.zoopzoop.backend.domain.dashboard.entity.Node;
1718
import org.tuna.zoopzoop.backend.domain.dashboard.repository.DashboardRepository;
1819
import org.tuna.zoopzoop.backend.domain.member.entity.Member;
20+
import org.tuna.zoopzoop.backend.domain.space.membership.entity.Membership;
21+
import org.tuna.zoopzoop.backend.domain.space.membership.enums.Authority;
1922
import org.tuna.zoopzoop.backend.domain.space.membership.service.MembershipService;
23+
import org.tuna.zoopzoop.backend.domain.space.space.entity.Space;
24+
import org.tuna.zoopzoop.backend.domain.space.space.service.SpaceService;
25+
import org.tuna.zoopzoop.backend.global.clients.liveblocks.LiveblocksClient;
2026

2127
import java.nio.file.AccessDeniedException;
28+
import java.util.Collections;
2229
import java.util.List;
30+
import java.util.Map;
2331

2432
@Service
2533
@RequiredArgsConstructor
@@ -30,7 +38,8 @@ public class DashboardService {
3038
private final ObjectMapper objectMapper;
3139
private final SignatureService signatureService;
3240
private final RabbitTemplate rabbitTemplate;
33-
41+
private final SpaceService spaceService;
42+
private final LiveblocksClient liveblocksClient;
3443

3544

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

146+
// =========================== 기타 메서드 ===========================
147+
148+
/**
149+
* 특정 스페이스에 대한 Liveblocks 접속 토큰(JWT)을 발급합니다.
150+
* @param spaceId 스페이스 ID
151+
* @param member 토큰을 요청하는 멤버
152+
* @return 발급된 JWT 문자열
153+
* @throws AccessDeniedException 멤버가 해당 스페이스에 속해있지 않거나 권한이 없는 경우
154+
*/
155+
@Transactional(readOnly = true)
156+
public String getAuthTokenForSpace(Integer spaceId, Member member) throws AccessDeniedException {
157+
Space space = spaceService.findById(spaceId);
158+
159+
// 해당 스페이스에 멤버가 속해있는지, PENDING 상태는 아닌지 확인
160+
Membership membership = membershipService.findByMemberAndSpace(member, space);
161+
if (membership.getAuthority().equals(Authority.PENDING)) {
162+
throw new AccessDeniedException("스페이스에 가입된 멤버가 아닙니다.");
163+
}
137164

165+
// Liveblocks Room ID 생성
166+
String roomId = "space_" + space.getId();
167+
168+
// Liveblocks에 전달할 사용자 정보 생성
169+
String userId = String.valueOf(member.getId());
170+
ReqBodyForLiveblocksAuth.UserInfo userInfo = new ReqBodyForLiveblocksAuth.UserInfo(
171+
member.getName(),
172+
member.getProfileImageUrl()
173+
);
174+
175+
// Liveblocks 권한 설정 (내 서비스의 Authority -> Liveblocks 권한으로 변환)
176+
List<String> permissions;
177+
switch (membership.getAuthority()) {
178+
case ADMIN, READ_WRITE:
179+
permissions = List.of("room:write");
180+
break;
181+
case READ_ONLY:
182+
permissions = Collections.emptyList(); // 빈 리스트는 읽기 전용을 의미
183+
break;
184+
default:
185+
// PENDING 등 다른 상태는 위에서 이미 필터링됨
186+
throw new AccessDeniedException("유효하지 않은 권한입니다.");
187+
}
138188

189+
// Liveblocks Client에 전달할 요청 객체 생성
190+
ReqBodyForLiveblocksAuth authRequest = new ReqBodyForLiveblocksAuth(
191+
userId,
192+
userInfo,
193+
Map.of(roomId, permissions)
194+
);
195+
196+
// LiveblocksClient를 통해 토큰 발급 요청
197+
return liveblocksClient.getAuthToken(authRequest);
198+
}
139199

140200

141201
}

src/main/java/org/tuna/zoopzoop/backend/domain/space/space/controller/ApiV1SpaceController.java

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,13 @@
88
import org.springframework.data.domain.Pageable;
99
import org.springframework.data.domain.Sort;
1010
import org.springframework.data.web.PageableDefault;
11+
import org.springframework.http.HttpStatus;
12+
import org.springframework.http.ResponseEntity;
1113
import org.springframework.security.core.annotation.AuthenticationPrincipal;
1214
import org.springframework.web.bind.annotation.*;
1315
import org.springframework.web.multipart.MultipartFile;
16+
import org.tuna.zoopzoop.backend.domain.dashboard.dto.ResBodyForAuthToken;
17+
import org.tuna.zoopzoop.backend.domain.dashboard.service.DashboardService;
1418
import org.tuna.zoopzoop.backend.domain.member.entity.Member;
1519
import org.tuna.zoopzoop.backend.domain.space.membership.dto.etc.SpaceMemberInfo;
1620
import org.tuna.zoopzoop.backend.domain.space.membership.entity.Membership;
@@ -38,6 +42,7 @@
3842
public class ApiV1SpaceController {
3943
private final SpaceService spaceService;
4044
private final MembershipService membershipService;
45+
private final DashboardService dashboardService;
4146

4247
@PostMapping
4348
@Operation(summary = "스페이스 생성")
@@ -205,5 +210,30 @@ public RsData<ResBodyForSpaceInfo> getSpace(
205210
);
206211
}
207212

213+
/**
214+
* Liveblocks 접속을 위한 인증 토큰(JWT) 발급 API
215+
* @param spaceId 스페이스 ID
216+
* @param userDetails 현재 로그인한 사용자 정보
217+
* @return ResponseEntity<RsData<AuthTokenResponse>>
218+
*/
219+
@PostMapping("/dashboard-auth/{spaceId}")
220+
@Operation(summary = "Liveblocks 접속 토큰 발급")
221+
public ResponseEntity<RsData<ResBodyForAuthToken>> getAuthToken(
222+
@PathVariable Integer spaceId,
223+
@AuthenticationPrincipal CustomUserDetails userDetails) throws AccessDeniedException {
224+
225+
Member member = userDetails.getMember();
226+
String token = dashboardService.getAuthTokenForSpace(spaceId, member);
227+
228+
ResBodyForAuthToken response = new ResBodyForAuthToken(token);
229+
230+
return ResponseEntity
231+
.status(HttpStatus.OK)
232+
.body(new RsData<>(
233+
"200",
234+
"Liveblocks 접속 토큰이 발급되었습니다.",
235+
response
236+
));
237+
}
208238

209239
}

src/main/java/org/tuna/zoopzoop/backend/domain/space/space/service/SpaceService.java

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,15 @@
1616
import org.tuna.zoopzoop.backend.domain.space.space.exception.DuplicateSpaceNameException;
1717
import org.tuna.zoopzoop.backend.domain.space.space.repository.SpaceRepository;
1818
import org.tuna.zoopzoop.backend.global.aws.S3Service;
19+
import org.tuna.zoopzoop.backend.global.clients.liveblocks.LiveblocksClient;
1920

2021
@Service
2122
@RequiredArgsConstructor
2223
public class SpaceService {
2324
private final SpaceRepository spaceRepository;
2425
private final S3Service s3Service;
2526
private final MembershipService membershipService;
27+
private final LiveblocksClient liveblocksClient;
2628
private final TagRepository tagRepository;
2729
private final DataSourceRepository dataSourceRepository;
2830

@@ -61,17 +63,7 @@ public Space findByName(String name) {
6163
*/
6264
@Transactional
6365
public Space createSpace(@NotBlank @Length(max = 50) String name) {
64-
Space newSpace = Space.builder()
65-
.name(name)
66-
.build();
67-
68-
try{
69-
return spaceRepository.save(newSpace);
70-
}catch (DataIntegrityViolationException e) {
71-
throw new DuplicateSpaceNameException("이미 존재하는 스페이스 이름입니다.");
72-
} catch (Exception e) {
73-
throw e;
74-
}
66+
return createSpace(name, null);
7567
}
7668

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

82+
Space savedSpace;
9083
try{
91-
return spaceRepository.save(newSpace);
84+
savedSpace = spaceRepository.save(newSpace);
9285
}catch (DataIntegrityViolationException e) {
9386
throw new DuplicateSpaceNameException("이미 존재하는 스페이스 이름입니다.");
9487
} catch (Exception e) {
9588
throw e;
9689
}
90+
91+
// Liveblocks에 방 생성 요청
92+
liveblocksClient.createRoom("space_" + savedSpace.getId());
93+
94+
return savedSpace;
9795
}
9896

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

111109
String spaceName = space.getName();
110+
String roomId = "space_" + space.getId();
111+
112+
// Liveblocks에 방 삭제 요청
113+
liveblocksClient.deleteRoom(roomId);
112114

113115
tagRepository.bulkDeleteTagsBySpaceId(spaceId);
114116
dataSourceRepository.bulkDeleteBySpaceId(spaceId);
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
package org.tuna.zoopzoop.backend.global.clients.liveblocks;
2+
3+
import lombok.RequiredArgsConstructor;
4+
import lombok.extern.slf4j.Slf4j;
5+
import org.springframework.beans.factory.annotation.Value;
6+
import org.springframework.http.*;
7+
import org.springframework.stereotype.Component;
8+
import org.springframework.web.client.RestClientException;
9+
import org.springframework.web.client.RestTemplate;
10+
import org.tuna.zoopzoop.backend.domain.dashboard.dto.ReqBodyForLiveblocksAuth;
11+
import org.tuna.zoopzoop.backend.domain.dashboard.dto.ResBodyForAuthToken;
12+
13+
import java.util.Collections;
14+
import java.util.HashMap;
15+
import java.util.Map;
16+
17+
@Slf4j
18+
@Component
19+
@RequiredArgsConstructor
20+
public class LiveblocksClient {
21+
22+
private final RestTemplate restTemplate;
23+
24+
@Value("${liveblocks.secret-key}")
25+
private String secretKey;
26+
27+
private static final String LIVEBLOCKS_API_URL = "https://api.liveblocks.io/v2/rooms";
28+
private static final String AUTH_API_URL = "https://api.liveblocks.io/v2/authorize-user";
29+
/**
30+
* Liveblocks 서버에 새로운 방을 생성합니다.
31+
* @param roomId 생성할 방의 고유 ID (워크스페이스 ID와 동일하게 사용)
32+
*/
33+
public void createRoom(String roomId) {
34+
// 1. HTTP 헤더 설정 (Authorization: Bearer sk_...)
35+
HttpHeaders headers = new HttpHeaders();
36+
headers.setBearerAuth(secretKey);
37+
headers.setContentType(MediaType.APPLICATION_JSON);
38+
39+
// 2. Request Body 생성 (비공개 방으로 생성)
40+
Map<String, Object> requestBody = new HashMap<>();
41+
requestBody.put("id", roomId);
42+
requestBody.put("defaultAccesses", Collections.emptyList()); // 비공개(private) 방으로 설정
43+
44+
// 3. HTTP 요청 엔티티 생성
45+
HttpEntity<Map<String, Object>> requestEntity = new HttpEntity<>(requestBody, headers);
46+
47+
try {
48+
// 4. Liveblocks API에 POST 요청 전송
49+
ResponseEntity<String> response = restTemplate.postForEntity(LIVEBLOCKS_API_URL, requestEntity, String.class);
50+
51+
if (response.getStatusCode().is2xxSuccessful()) {
52+
log.info("Liveblocks room created successfully. roomId: {}", roomId);
53+
} else {
54+
log.error("Failed to create Liveblocks room. roomId: {}, status: {}, body: {}",
55+
roomId, response.getStatusCode(), response.getBody());
56+
}
57+
} catch (RestClientException e) {
58+
log.error("Error while calling Liveblocks API to create room. roomId: {}", roomId, e);
59+
// 필요하다면 여기서 커스텀 예외를 발생시켜 서비스 레이어에서 처리하도록 할 수 있습니다.
60+
throw new RuntimeException("Liveblocks API call failed", e);
61+
}
62+
}
63+
64+
/**
65+
* Liveblocks 서버의 방을 삭제합니다.
66+
* @param roomId 삭제할 방의 고유 ID
67+
*/
68+
public void deleteRoom(String roomId) {
69+
String deleteUrl = LIVEBLOCKS_API_URL + "/" + roomId;
70+
HttpHeaders headers = new HttpHeaders();
71+
headers.setBearerAuth(secretKey);
72+
HttpEntity<Void> requestEntity = new HttpEntity<>(headers);
73+
74+
try {
75+
restTemplate.exchange(deleteUrl, HttpMethod.DELETE, requestEntity, Void.class);
76+
log.info("Liveblocks room deleted successfully. roomId: {}", roomId);
77+
} catch (RestClientException e) {
78+
log.error("Error while calling Liveblocks API to delete room. roomId: {}", roomId, e);
79+
// 방 삭제 실패가 전체 로직에 큰 영향을 주지 않는다면,
80+
// 예외를 던지는 대신 에러 로그만 남기고 넘어갈 수도 있습니다.
81+
// 여기서는 일단 예외를 던져서 트랜잭션을 롤백하도록 합니다.
82+
throw new RuntimeException("Liveblocks API call failed", e);
83+
}
84+
}
85+
86+
/**
87+
* Liveblocks 사용자 인증 토큰(JWT)을 발급받습니다.
88+
* @param request 인증에 필요한 사용자 정보, 권한 등을 담은 객체
89+
* @return 발급된 JWT 문자열
90+
*/
91+
public String getAuthToken(ReqBodyForLiveblocksAuth request) {
92+
HttpHeaders headers = new HttpHeaders();
93+
headers.setBearerAuth(secretKey);
94+
headers.setContentType(MediaType.APPLICATION_JSON);
95+
96+
HttpEntity<ReqBodyForLiveblocksAuth> requestEntity = new HttpEntity<>(request, headers);
97+
98+
try {
99+
ResponseEntity<String> response = restTemplate.postForEntity(AUTH_API_URL, requestEntity, String.class);
100+
if (response.getStatusCode().is2xxSuccessful()) {
101+
log.info("Liveblocks auth token issued successfully for user: {}", request.userId());
102+
return response.getBody();
103+
} else {
104+
log.error("Failed to issue Liveblocks auth token. user: {}, status: {}, body: {}",
105+
request.userId(), response.getStatusCode(), response.getBody());
106+
throw new RuntimeException("Failed to issue Liveblocks auth token.");
107+
}
108+
} catch (RestClientException e) {
109+
log.error("Error while calling Liveblocks auth API for user: {}", request.userId(), e);
110+
throw new RuntimeException("Liveblocks auth API call failed", e);
111+
}
112+
}
113+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package org.tuna.zoopzoop.backend.global.config.restTemplate;
2+
3+
import org.springframework.context.annotation.Bean;
4+
import org.springframework.context.annotation.Configuration;
5+
import org.springframework.web.client.RestTemplate;
6+
7+
@Configuration
8+
public class RestTemplateConfig {
9+
10+
@Bean
11+
public RestTemplate restTemplate() {
12+
return new RestTemplate();
13+
}
14+
}

0 commit comments

Comments
 (0)