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
32 changes: 32 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import com.google.protobuf.gradle.id

plugins {
java
id("org.springframework.boot") version "3.5.10"
id("io.spring.dependency-management") version "1.1.7"
id("com.google.protobuf") version "0.9.4"
}

group = "flipnote"
Expand All @@ -24,12 +27,23 @@ repositories {
mavenCentral()
}

val grpcVersion = "1.68.0"
val protocVersion = "4.28.2"

dependencies {
implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("org.springframework.boot:spring-boot-starter-amqp")

// gRPC
implementation("io.grpc:grpc-netty-shaded:$grpcVersion")
implementation("io.grpc:grpc-protobuf:$grpcVersion")
implementation("io.grpc:grpc-stub:$grpcVersion")
implementation("com.google.protobuf:protobuf-java:$protocVersion")
implementation("javax.annotation:javax.annotation-api:1.3.2")

compileOnly("org.projectlombok:lombok")
runtimeOnly("com.mysql:mysql-connector-j")
annotationProcessor("org.projectlombok:lombok")
Expand All @@ -38,6 +52,24 @@ dependencies {
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}

protobuf {
protoc {
artifact = "com.google.protobuf:protoc:$protocVersion"
}
plugins {
id("grpc") {
artifact = "io.grpc:protoc-gen-grpc-java:$grpcVersion"
}
}
generateProtoTasks {
all().forEach { task ->
task.plugins {
id("grpc")
}
}
}
}

tasks.withType<Test> {
useJUnitPlatform()
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,20 @@

import java.time.LocalDateTime;

import cardset.Cardset.CardSetSummary;
import flipnote.reaction.bookmark.entity.Bookmark;

public record BookmarkResponse(
String targetType,
Long targetId,
Long targetGroupId,
LocalDateTime bookmarkedAt
) {
public static BookmarkResponse from(Bookmark bookmark) {
public static BookmarkResponse from(Bookmark bookmark, CardSetSummary summary) {
return new BookmarkResponse(
bookmark.getTargetType().name(),
bookmark.getTargetId(),
summary != null ? summary.getGroupId() : null,
bookmark.getCreatedAt()
);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,26 @@
package flipnote.reaction.bookmark.service;

import org.springframework.amqp.rabbit.core.RabbitTemplate;
import java.util.List;
import java.util.Map;

import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.data.domain.Page;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import cardset.Cardset;
import cardset.Cardset.CardSetSummary;
import flipnote.reaction.bookmark.entity.Bookmark;
import flipnote.reaction.bookmark.entity.BookmarkTargetType;
import flipnote.reaction.bookmark.exception.BookmarkErrorCode;
import flipnote.reaction.bookmark.model.request.BookmarkSearchRequest;
import flipnote.reaction.bookmark.model.response.BookmarkResponse;
import flipnote.reaction.bookmark.repository.BookmarkRepository;
import flipnote.reaction.common.config.RabbitMqConfig;
import flipnote.reaction.common.event.ReactionEventPublisher;
import flipnote.reaction.common.exception.BizException;
import flipnote.reaction.common.model.event.ReactionMessage;
import flipnote.reaction.common.exception.CommonErrorCode;
import flipnote.reaction.common.grpc.CardSetGrpcClient;
import flipnote.reaction.common.model.response.IdResponse;
import flipnote.reaction.common.model.response.PagingResponse;
import lombok.RequiredArgsConstructor;
Expand All @@ -28,11 +34,16 @@ public class BookmarkService {

private final BookmarkRepository bookmarkRepository;
private final BookmarkReader bookmarkReader;
private final RabbitTemplate rabbitTemplate;
private final ReactionEventPublisher eventPublisher;
private final CardSetGrpcClient cardSetGrpcClient;

@Transactional
public IdResponse addBookmark(BookmarkTargetType targetType, Long targetId, Long userId) {
// TODO: gRPC로 대상 존재 여부 검증 (CardSet/Group 서비스 호출)
if (targetType == BookmarkTargetType.CARD_SET) {
if (!cardSetGrpcClient.isCardSetViewable(targetId, userId)) {
throw new BizException(CommonErrorCode.TARGET_NOT_VIEWABLE);
}
}

if (bookmarkReader.isBookmarked(targetType, targetId, userId)) {
throw new BizException(BookmarkErrorCode.ALREADY_BOOKMARKED);
Expand All @@ -50,7 +61,8 @@ public IdResponse addBookmark(BookmarkTargetType targetType, Long targetId, Long
throw new BizException(BookmarkErrorCode.ALREADY_BOOKMARKED);
}

publishEvent(RabbitMqConfig.ROUTING_KEY_BOOKMARK_ADDED, "BOOKMARK_ADDED", targetType, targetId, userId);
eventPublisher.publish(RabbitMqConfig.ROUTING_KEY_BOOKMARK_ADDED, "BOOKMARK_ADDED",
targetType.name(), targetId, userId);

return IdResponse.from(bookmark.getId());
}
Expand All @@ -60,7 +72,8 @@ public void removeBookmark(BookmarkTargetType targetType, Long targetId, Long us
Bookmark bookmark = bookmarkReader.findByTargetAndUserId(targetType, targetId, userId);
bookmarkRepository.delete(bookmark);

publishEvent(RabbitMqConfig.ROUTING_KEY_BOOKMARK_REMOVED, "BOOKMARK_REMOVED", targetType, targetId, userId);
eventPublisher.publish(RabbitMqConfig.ROUTING_KEY_BOOKMARK_REMOVED, "BOOKMARK_REMOVED",
targetType.name(), targetId, userId);
}

public PagingResponse<BookmarkResponse> getBookmarks(BookmarkTargetType targetType, Long userId,
Expand All @@ -69,23 +82,17 @@ public PagingResponse<BookmarkResponse> getBookmarks(BookmarkTargetType targetTy
targetType, userId, request.getPageRequest()
);

// TODO: gRPC로 대상 상세 정보 fetch (CardSet 서비스 호출)
List<Long> targetIds = bookmarkPage.getContent().stream()
.map(Bookmark::getTargetId)
.toList();

Page<BookmarkResponse> responsePage = bookmarkPage.map(BookmarkResponse::from);
return PagingResponse.from(responsePage);
}
Map<Long, CardSetSummary> summaryMap = targetIds.isEmpty()
? Map.of()
: cardSetGrpcClient.getCardSetsByIds(targetIds, userId);

private void publishEvent(String routingKey, String eventType,
BookmarkTargetType targetType, Long targetId, Long userId) {
try {
rabbitTemplate.convertAndSend(
RabbitMqConfig.EXCHANGE,
routingKey,
new ReactionMessage(eventType, targetType.name(), targetId, userId)
);
} catch (Exception e) {
log.error("북마크 이벤트 발행 실패: eventType={}, targetType={}, targetId={}, userId={}",
eventType, targetType, targetId, userId, e);
}
Page<BookmarkResponse> responsePage = bookmarkPage.map(
bookmark -> BookmarkResponse.from(bookmark, summaryMap.get(bookmark.getTargetId()))
);
return PagingResponse.from(responsePage);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package flipnote.reaction.common.event;

import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.stereotype.Component;

import flipnote.reaction.common.config.RabbitMqConfig;
import flipnote.reaction.common.model.event.ReactionMessage;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Slf4j
@Component
@RequiredArgsConstructor
public class ReactionEventPublisher {

private final RabbitTemplate rabbitTemplate;

public void publish(String routingKey, String eventType,
String targetType, Long targetId, Long userId) {
try {
rabbitTemplate.convertAndSend(
RabbitMqConfig.EXCHANGE,
routingKey,
new ReactionMessage(eventType, targetType, targetId, userId)
);
} catch (Exception e) {
log.error("이벤트 발행 실패: eventType={}, targetType={}, targetId={}, userId={}",
eventType, targetType, targetId, userId, e);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
@RequiredArgsConstructor
public enum CommonErrorCode implements ErrorCode {
INTERNAL_SERVER_ERROR(500, "COMMON_001", "예기치 않은 오류가 발생했습니다."),
INVALID_INPUT_VALUE(400, "COMMON_002", "입력값이 올바르지 않습니다.");
INVALID_INPUT_VALUE(400, "COMMON_002", "입력값이 올바르지 않습니다."),
TARGET_NOT_VIEWABLE(403, "COMMON_003", "접근할 수 없는 대상입니다."),
GRPC_CALL_FAILED(502, "COMMON_004", "외부 서비스 호출에 실패했습니다.");

private final int status;
private final String code;
Expand Down
65 changes: 65 additions & 0 deletions src/main/java/flipnote/reaction/common/grpc/CardSetGrpcClient.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package flipnote.reaction.common.grpc;

import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;

import org.springframework.stereotype.Component;

import cardset.Cardset.CardSetSummary;
import cardset.Cardset.GetCardSetsByIdsRequest;
import cardset.Cardset.GetCardSetsByIdsResponse;
import cardset.Cardset.IsCardSetViewableRequest;
import cardset.Cardset.IsCardSetViewableResponse;
import cardset.CardsetServiceGrpc;
import flipnote.reaction.common.exception.BizException;
import flipnote.reaction.common.exception.CommonErrorCode;
import io.grpc.ManagedChannel;
import io.grpc.StatusRuntimeException;
import lombok.extern.slf4j.Slf4j;

@Slf4j
@Component
public class CardSetGrpcClient {

private final CardsetServiceGrpc.CardsetServiceBlockingStub stub;

public CardSetGrpcClient(ManagedChannel cardSetChannel) {
this.stub = CardsetServiceGrpc.newBlockingStub(cardSetChannel);
}

public boolean isCardSetViewable(Long cardSetId, Long userId) {
try {
IsCardSetViewableRequest request = IsCardSetViewableRequest.newBuilder()
.setCardSetId(cardSetId.intValue())
.setUserId(userId.intValue())
.build();

IsCardSetViewableResponse response = stub.isCardSetViewable(request);
return response.getViewable();
} catch (StatusRuntimeException e) {
log.error("gRPC call failed: IsCardSetViewable, cardSetId={}, userId={}", cardSetId, userId, e);
throw new BizException(CommonErrorCode.GRPC_CALL_FAILED);
}
}

public Map<Long, CardSetSummary> getCardSetsByIds(List<Long> cardSetIds, Long userId) {
try {
GetCardSetsByIdsRequest request = GetCardSetsByIdsRequest.newBuilder()
.addAllCardSetIds(cardSetIds)
.setUserId(userId.intValue())
.build();

GetCardSetsByIdsResponse response = stub.getCardSetsByIds(request);
return response.getCardSetsList().stream()
.collect(Collectors.toMap(
cs -> (long) cs.getId(),
Function.identity()
));
} catch (StatusRuntimeException e) {
log.error("gRPC call failed: GetCardSetsByIds, cardSetIds={}, userId={}", cardSetIds, userId, e);
throw new BizException(CommonErrorCode.GRPC_CALL_FAILED);
}
}
}
22 changes: 22 additions & 0 deletions src/main/java/flipnote/reaction/common/grpc/GrpcConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package flipnote.reaction.common.grpc;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import io.grpc.ManagedChannel;
import io.grpc.ManagedChannelBuilder;

@Configuration
public class GrpcConfig {

@Bean
public ManagedChannel cardSetChannel(
@Value("${grpc.cardset.host}") String host,
@Value("${grpc.cardset.port}") int port
) {
return ManagedChannelBuilder.forAddress(host, port)
.usePlaintext()
.build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,20 @@

import java.time.LocalDateTime;

import cardset.Cardset.CardSetSummary;
import flipnote.reaction.like.entity.Like;

public record LikeResponse(
String targetType,
Long targetId,
Long targetGroupId,
LocalDateTime likedAt
) {
public static LikeResponse from(Like like) {
public static LikeResponse from(Like like, CardSetSummary summary) {
return new LikeResponse(
like.getTargetType().name(),
like.getTargetId(),
summary != null ? summary.getGroupId() : null,
like.getCreatedAt()
);
}
Expand Down
Loading
Loading