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 @@ -15,10 +15,10 @@
import project.flipnote.common.model.response.PagingResponse;
import project.flipnote.common.security.dto.AuthPrinciple;
import project.flipnote.like.controller.docs.LikeControllerDocs;
import project.flipnote.like.model.LikeResponse;
import project.flipnote.like.model.LikeSearchRequest;
import project.flipnote.like.model.LikeTargetResponse;
import project.flipnote.like.model.LikeTargetTypeRequest;
import project.flipnote.like.model.response.LikeResponse;
import project.flipnote.like.model.request.LikeSearchRequest;
import project.flipnote.like.model.response.LikeTargetResponse;
import project.flipnote.like.model.request.LikeTargetTypeRequest;
import project.flipnote.like.service.LikeService;

@RequiredArgsConstructor
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@
import io.swagger.v3.oas.annotations.tags.Tag;
import project.flipnote.common.model.response.PagingResponse;
import project.flipnote.common.security.dto.AuthPrinciple;
import project.flipnote.like.model.LikeResponse;
import project.flipnote.like.model.LikeSearchRequest;
import project.flipnote.like.model.LikeTargetResponse;
import project.flipnote.like.model.LikeTargetTypeRequest;
import project.flipnote.like.model.response.LikeResponse;
import project.flipnote.like.model.request.LikeSearchRequest;
import project.flipnote.like.model.response.LikeTargetResponse;
import project.flipnote.like.model.request.LikeTargetTypeRequest;

@Tag(name = "Like", description = "Like API")
public interface LikeControllerDocs {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package project.flipnote.like.model;
package project.flipnote.like.model.request;

import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package project.flipnote.like.model;
package project.flipnote.like.model.request;

import project.flipnote.like.entity.LikeTargetType;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package project.flipnote.like.model;
package project.flipnote.like.model.response;

import lombok.AllArgsConstructor;
import lombok.Data;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package project.flipnote.like.model;
package project.flipnote.like.model.response;

import java.time.LocalDateTime;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package project.flipnote.like.model;
package project.flipnote.like.model.response;

public abstract class LikeTargetResponse {
public abstract Long getId();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,21 @@
import org.springframework.stereotype.Service;

import lombok.RequiredArgsConstructor;
import project.flipnote.cardset.service.CardSetService;
import project.flipnote.common.exception.BizException;
import project.flipnote.like.entity.LikeTargetType;
import project.flipnote.like.exception.LikeErrorCode;
import project.flipnote.like.model.response.LikeTargetResponse;
import project.flipnote.like.repository.LikeRepository;

@RequiredArgsConstructor
@Service
public class LikePolicyService {

private final CardSetService cardSetService;
private final LikeRepository likeRepository;
private final LikeTargetFetchService<LikeTargetResponse> likeTargetFetchService;

public void validateTargetExists(LikeTargetType targetType, Long targetId) {
boolean targetExists = false;
switch (targetType) {
case CARD_SET -> targetExists = cardSetService.existsById(targetId);
}

if (!targetExists) {
public void validateTargetExists(LikeTargetType targetType, Long targetId, Long userId) {
if (!likeTargetFetchService.isTargetViewable(targetType, targetId, userId)) {
throw new BizException(LikeErrorCode.LIKE_TARGET_NOT_FOUND);
}
}
Expand Down
22 changes: 22 additions & 0 deletions src/main/java/project/flipnote/like/service/LikeReader.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package project.flipnote.like.service;

import org.springframework.stereotype.Service;

import lombok.RequiredArgsConstructor;
import project.flipnote.common.exception.BizException;
import project.flipnote.like.entity.Like;
import project.flipnote.like.entity.LikeTargetType;
import project.flipnote.like.exception.LikeErrorCode;
import project.flipnote.like.repository.LikeRepository;

@RequiredArgsConstructor
@Service
public class LikeReader {

private final LikeRepository likeRepository;

public Like findByTargetAndUserId(LikeTargetType targetType, Long targetId, Long userId) {
return likeRepository.findByTargetTypeAndTargetIdAndUserId(targetType, targetId, userId)
.orElseThrow(() -> new BizException(LikeErrorCode.LIKE_NOT_FOUND));
}
}
40 changes: 11 additions & 29 deletions src/main/java/project/flipnote/like/service/LikeService.java
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
package project.flipnote.like.service;

import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;

import org.springframework.context.ApplicationEventPublisher;
Expand All @@ -13,7 +11,6 @@
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import project.flipnote.common.exception.BizException;
Expand All @@ -23,11 +20,10 @@
import project.flipnote.like.entity.Like;
import project.flipnote.like.entity.LikeTargetType;
import project.flipnote.like.exception.LikeErrorCode;
import project.flipnote.like.model.LikeResponse;
import project.flipnote.like.model.LikeSearchRequest;
import project.flipnote.like.model.LikeTargetResponse;
import project.flipnote.like.model.response.LikeResponse;
import project.flipnote.like.model.request.LikeSearchRequest;
import project.flipnote.like.model.response.LikeTargetResponse;
import project.flipnote.like.repository.LikeRepository;
import project.flipnote.like.service.fetcher.LikeTargetFetcher;

@Slf4j
@RequiredArgsConstructor
Expand All @@ -38,15 +34,8 @@ public class LikeService {
private final LikeRepository likeRepository;
private final ApplicationEventPublisher eventPublisher;
private final LikePolicyService likePolicyService;
private final List<LikeTargetFetcher<?>> fetchers;

private Map<LikeTargetType, LikeTargetFetcher<?>> fetcherMap;

@PostConstruct
public void init() {
this.fetcherMap = this.fetchers.stream()
.collect(Collectors.toMap(LikeTargetFetcher::getTargetType, Function.identity()));
}
private final LikeTargetFetchService<LikeTargetResponse> likeTargetFetchService;
private final LikeReader likeReader;

/**
* 좋아요 추가
Expand All @@ -58,7 +47,7 @@ public void init() {
*/
@Transactional
public void addLike(Long userId, LikeTargetType targetType, Long targetId) {
likePolicyService.validateTargetExists(targetType, targetId);
likePolicyService.validateTargetExists(targetType, targetId, userId);
likePolicyService.validateNotAlreadyLiked(targetType, targetId, userId);

Like like = Like.builder()
Expand Down Expand Up @@ -86,8 +75,7 @@ public void addLike(Long userId, LikeTargetType targetType, Long targetId) {
*/
@Transactional
public void removeLike(Long userId, LikeTargetType targetType, Long targetId) {
Like like = likeRepository.findByTargetTypeAndTargetIdAndUserId(targetType, targetId, userId)
.orElseThrow(() -> new BizException(LikeErrorCode.LIKE_NOT_FOUND));
Like like = likeReader.findByTargetAndUserId(targetType, targetId, userId);

likeRepository.delete(like);

Expand All @@ -100,11 +88,10 @@ public void removeLike(Long userId, LikeTargetType targetType, Long targetId) {
* @param userId 좋아요 누른 목록을 조회하는 회원의 ID
* @param targetType 조회할 좋아요 대상 타입
* @param req 페이징 및 검색 조건이 포함된 요청 정보
* @param <T> 좋아요 대상의 상세 정보를 담은 DTO 타입 (LikeTargetResponse 하위 타입)
* @return 페이징된 좋아요 누른 목록
* @author 윤정환
*/
public <T extends LikeTargetResponse> PagingResponse<LikeResponse<T>> getLikes(
public PagingResponse<LikeResponse<LikeTargetResponse>> getLikes(
Long userId,
LikeTargetType targetType,
LikeSearchRequest req
Expand All @@ -114,14 +101,9 @@ public <T extends LikeTargetResponse> PagingResponse<LikeResponse<T>> getLikes(
.collect(Collectors.toMap(Like::getTargetId, Like::getCreatedAt));
Set<Long> targetIds = likedAtMap.keySet();

// TODO: 제네릭이 아닌 타입 별로 엔드포인트를 따로 만드는게 좋으려나 고민중, 현재 방법을 유지하면서 더 나은 구조 알고싶음...
LikeTargetFetcher<T> fetcher = (LikeTargetFetcher<T>)fetcherMap.get(targetType);
if (fetcher == null) {
throw new BizException(LikeErrorCode.INVALID_LIKE_TYPE);
}

Map<Long, T> targetMap = fetcher.fetchByIds(targetIds);
Page<LikeResponse<T>> content = likePage
Map<Long, LikeTargetResponse> targetMap
= likeTargetFetchService.fetchByTypeAndIds(targetType, targetIds, userId);
Page<LikeResponse<LikeTargetResponse>> content = likePage
.map(like -> new LikeResponse<>(targetMap.get(like.getTargetId()), likedAtMap.get(like.getTargetId())));

return PagingResponse.from(content);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package project.flipnote.like.service;

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

import org.springframework.stereotype.Service;

import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import project.flipnote.common.exception.BizException;
import project.flipnote.like.entity.LikeTargetType;
import project.flipnote.like.exception.LikeErrorCode;
import project.flipnote.like.model.response.LikeTargetResponse;
import project.flipnote.like.service.fetcher.LikeTargetFetcher;

@RequiredArgsConstructor
@Service
public class LikeTargetFetchService<T extends LikeTargetResponse> {

private final List<LikeTargetFetcher<T>> fetchers;

Comment on lines +21 to +24
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

제네릭 불변성으로 인한 Spring 주입 실패 가능성 (치명적).

List<LikeTargetFetcher>는 제네릭 불변(invariant)이라 LikeTargetFetcher가 LikeTargetFetcher에 주입되지 않습니다. 현재 LikeService/LikePolicyService에서 LikeTargetFetchService를 주입하고 있어, 실제로는 컨텍스트 로딩 시점에 주입 실패가 발생할 확률이 높습니다.

아래처럼 서비스의 제네릭을 제거하고, 내부에서 안전한(경고 억제) 캐스팅을 사용하면 주입 문제가 해소됩니다.

-@RequiredArgsConstructor
-@Service
-public class LikeTargetFetchService<T extends LikeTargetResponse> {
+@RequiredArgsConstructor
+@Service
+public class LikeTargetFetchService {

-    private final List<LikeTargetFetcher<T>> fetchers;
+    private final List<LikeTargetFetcher<?>> fetchers;

-    private Map<LikeTargetType, LikeTargetFetcher<T>> fetcherMap;
+    private Map<LikeTargetType, LikeTargetFetcher<?>> fetcherMap;

     @PostConstruct
     public void init() {
-        this.fetcherMap = this.fetchers.stream()
-            .collect(Collectors.toMap(LikeTargetFetcher::getTargetType, Function.identity()));
+        this.fetcherMap = this.fetchers.stream()
+            .collect(Collectors.toMap(LikeTargetFetcher::getTargetType, Function.identity()));
     }

     public boolean isTargetViewable(LikeTargetType targetType, Long targetId, Long userId) {
-        LikeTargetFetcher<T> targetFetcher = getFetcher(targetType);
+        LikeTargetFetcher<?> targetFetcher = getFetcher(targetType);
         return targetFetcher.isTargetViewable(targetId, userId);
     }

-    public Map<Long, T> fetchByTypeAndIds(
+    public <T extends LikeTargetResponse> Map<Long, T> fetchByTypeAndIds(
         LikeTargetType targetType,
         Set<Long> targetIds,
         Long userId
     ) {
-        LikeTargetFetcher<T> targetFetcher = getFetcher(targetType);
-        return targetFetcher.fetchByIds(targetIds, userId);
+        @SuppressWarnings("unchecked")
+        LikeTargetFetcher<T> targetFetcher = (LikeTargetFetcher<T>) getFetcher(targetType);
+        return targetFetcher.fetchByIds(targetIds, userId);
     }

-    private LikeTargetFetcher<T> getFetcher(LikeTargetType targetType) {
-        LikeTargetFetcher<T> fetcher = fetcherMap.get(targetType);
+    private LikeTargetFetcher<?> getFetcher(LikeTargetType targetType) {
+        LikeTargetFetcher<?> fetcher = fetcherMap.get(targetType);
         if (fetcher == null) {
             throw new BizException(LikeErrorCode.INVALID_LIKE_TYPE);
         }
         return fetcher;
     }
 }

이 변경에 맞춰 LikeService/LikePolicyService의 필드 타입도 LikeTargetFetchService (비제네릭)로 조정해야 합니다.

Also applies to: 39-47, 49-56

🤖 Prompt for AI Agents
In src/main/java/project/flipnote/like/service/LikeTargetFetchService.java
around lines 21-24 (and similarly at 39-47, 49-56), the class is declared with a
generic parameter T which makes the injected List<LikeTargetFetcher<T>>
invariant and prevents Spring from injecting implementations like
LikeTargetFetcher<CardSetLikeResponse>; remove the generic from the service
class declaration, change the internal fetchers field to use a wildcard or raw
type (e.g. List<LikeTargetFetcher<?>> or List) and perform any necessary
unchecked casts inside methods with @SuppressWarnings("unchecked") where you
return or operate on a specific LikeTargetResponse subtype, and then update
callers (LikeService and LikePolicyService) to inject LikeTargetFetchService
(non-generic) instead of LikeTargetFetchService<LikeTargetResponse>.

private Map<LikeTargetType, LikeTargetFetcher<T>> fetcherMap;

@PostConstruct
public void init() {
this.fetcherMap = this.fetchers.stream()
.collect(Collectors.toMap(LikeTargetFetcher::getTargetType, Function.identity()));
}

public boolean isTargetViewable(LikeTargetType targetType, Long targetId, Long userId) {
LikeTargetFetcher<T> targetFetcher = getFetcher(targetType);

return targetFetcher.isTargetViewable(targetId, userId);
}

public Map<Long, T> fetchByTypeAndIds(
LikeTargetType targetType,
Set<Long> targetIds,
Long userId
) {
LikeTargetFetcher<T> targetFetcher = getFetcher(targetType);

return targetFetcher.fetchByIds(targetIds, userId);
}

private LikeTargetFetcher<T> getFetcher(LikeTargetType targetType) {
LikeTargetFetcher<T> fetcher = fetcherMap.get(targetType);
if (fetcher == null) {
throw new BizException(LikeErrorCode.INVALID_LIKE_TYPE);
}

return fetcher;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@
import lombok.RequiredArgsConstructor;
import project.flipnote.cardset.service.CardSetService;
import project.flipnote.like.entity.LikeTargetType;
import project.flipnote.like.model.CardSetLikeResponse;
import project.flipnote.like.model.LikeTargetResponse;
import project.flipnote.like.model.response.CardSetLikeResponse;
import project.flipnote.like.model.response.LikeTargetResponse;

@RequiredArgsConstructor
@Component
Expand All @@ -25,7 +25,12 @@ public LikeTargetType getTargetType() {
}

@Override
public Map<Long, CardSetLikeResponse> fetchByIds(Set<Long> ids) {
public boolean isTargetViewable(Long targetId, Long userId) {
return cardSetService.isCardSetViewable(targetId, userId);
}

@Override
public Map<Long, CardSetLikeResponse> fetchByIds(Set<Long> ids, Long userId) {
return cardSetService.getCardSetsByIds(ids).stream()
.map(CardSetLikeResponse::from)
.collect(Collectors.toMap(LikeTargetResponse::getId, Function.identity()));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@
import java.util.Set;

import project.flipnote.like.entity.LikeTargetType;
import project.flipnote.like.model.LikeTargetResponse;
import project.flipnote.like.model.response.LikeTargetResponse;

public interface LikeTargetFetcher<T extends LikeTargetResponse> {
LikeTargetType getTargetType();

Map<Long, T> fetchByIds(Set<Long> ids);
boolean isTargetViewable(Long targetId, Long userId);

Map<Long, T> fetchByIds(Set<Long> targetIds, Long userId);
}
Loading