Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ feat(api): add post recommendation sentence API #517

Merged
merged 7 commits into from
Oct 21, 2024
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ public enum CommonErrorCode implements ErrorCodeInterface {
*/
INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "COMMON_INTERNAL_SERVER_ERROR", "Internal Server Error", "An unexpected error occurred"),
NOT_IMPLEMENTED(HttpStatus.NOT_IMPLEMENTED, "COMMON_NOT_IMPLEMENTED", "Not Implemented", "The server does not support the functionality required to fulfill the request."),
UNSUPPORTED_TYPE(HttpStatus.BAD_REQUEST, "COMMON_UNSUPPORTED_TYPE", "Unsupported Type", "The type specified is not supported.");
UNSUPPORTED_TYPE(HttpStatus.BAD_REQUEST, "COMMON_UNSUPPORTED_TYPE", "Unsupported Type", "The type specified is not supported."),
SENTENCES_NOT_FOUND(HttpStatus.NOT_FOUND, "COMMON_SENTENCES_NOT_FOUND", "Sentences Not Found", "No sentences available in the database");

private final HttpStatus status;
private final String errorResponseCode;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.depromeet.domains.recommend.controller;

import com.depromeet.common.dto.ResponseDto;
import com.depromeet.domains.recommend.dto.response.PostRecommendSentenceResponseDto;
import com.depromeet.domains.recommend.service.PostRecommendService;
import com.depromeet.security.annotation.ReqUser;
import com.depromeet.user.User;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping
@RequiredArgsConstructor
@Tag(name = "💁‍♀️Post Recommend", description = "Post Recommend API")
public class PostRecommendController {

private final PostRecommendService postRecommendService;

@Operation(summary = "홈 화면 드랍 유도 - 무작위 문장 추천")
@GetMapping("/post-recommend/random-sentence")
public ResponseEntity<PostRecommendSentenceResponseDto> getRandomPhrase(
@ReqUser User user
) {
var response = postRecommendService.getOneRandomSentence(user);
return ResponseDto.ok(response);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.depromeet.domains.recommend.dto.response;

public record PostRecommendSentenceResponseDto(
String sentence
) {
public static PostRecommendSentenceResponseDto empty() {
return new PostRecommendSentenceResponseDto(null);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.depromeet.domains.recommend.provider;

import org.springframework.stereotype.Component;

import java.util.List;
import java.util.Random;

@Component
public final class RandomProvider {
Copy link
Collaborator

Choose a reason for hiding this comment

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

RandomProvider로 제공되는 거 좋은 것 같습니다


private static final Random RANDOM = new Random();

public static <T> T getRandomElement(List<T> list) {
return list.get(RANDOM.nextInt(list.size()));
}

private RandomProvider() {
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.depromeet.domains.recommend.repository;

import org.springframework.stereotype.Repository;

import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;

@Repository
public class MemoryUserReadRepository implements UserReadRepository {

private static Map<Long, LocalDateTime> store = new HashMap<>();

@Override
public void save(Long userId) {
store.put(userId, LocalDateTime.now());
}

@Override
public Boolean isSent(Long userId) {
return Optional.ofNullable(store.get(userId))
.map(readTime -> !readTime.isBefore(LocalDateTime.now().minusDays(1)))
.orElse(false);
}
Copy link
Member Author

Choose a reason for hiding this comment

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

하루에 한 번 응답값을 전달합니다.
예: 1일 23시에 읽으면, 2일 00시부터 새로운 문장을 읽을 수 있어요


}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.depromeet.domains.recommend.repository;

import com.depromeet.recommend.post.PostRecommendSentence;
import org.springframework.data.jpa.repository.JpaRepository;

public interface PostRecommendSentenceRepository extends JpaRepository<PostRecommendSentence, Long> {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.depromeet.domains.recommend.repository;

public interface UserReadRepository {
void save(Long userId);
Boolean isSent(Long userId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package com.depromeet.domains.recommend.service;

import com.depromeet.common.error.dto.CommonErrorCode;
import com.depromeet.common.error.exception.internal.NotFoundException;
import com.depromeet.domains.recommend.dto.response.PostRecommendSentenceResponseDto;
import com.depromeet.domains.recommend.provider.RandomProvider;
import com.depromeet.domains.recommend.repository.PostRecommendSentenceRepository;
import com.depromeet.domains.recommend.repository.UserReadRepository;
import com.depromeet.recommend.post.PostRecommendSentence;
import com.depromeet.user.User;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class PostRecommendService {

private final PostRecommendSentenceRepository postRecommendSentenceRepository;
private final UserReadRepository userReadRepository;

public PostRecommendSentenceResponseDto getOneRandomSentence(User user) {
if (userReadRepository.isSent(user.getId())) {
return PostRecommendSentenceResponseDto.empty();
}
String randomSentence = getRandomSentence();
userReadRepository.save(user.getId());
return new PostRecommendSentenceResponseDto(randomSentence);
}

private String getRandomSentence() {
var postRecommendSentences = postRecommendSentenceRepository.findAll();
if (postRecommendSentences.isEmpty()) {
throw new NotFoundException(CommonErrorCode.SENTENCES_NOT_FOUND);
}
PostRecommendSentence randomPostRecommendSentence = RandomProvider.getRandomElement(postRecommendSentences);
return randomPostRecommendSentence.getSentence();
}
Comment on lines +30 to +37
Copy link
Member Author

Choose a reason for hiding this comment

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

요구사항

  • 문장들은 DB에서 관리한다

다양한 방법들

  1. DB 단위에서 random() 돌리기
    ORDER BY RAND() LIMIT 1 으로 해서 랜덤으로 하나의 문장을 가져올 수 있다.
    단점: DB에 의존적이다. DB 연산을 굳이 거쳐할까?

  2. service에서 random() -> DB에서 하나 조회
    문장의 개수(N)를 모두 확인하고 java 코드로 1~N 사이의 임의의 숫자를 하나 정한다.
    해당 id로 문장 하나를 조회한다.
    단점: 만약에 id가 4, 5, 7 처럼 중간에 하나 사라진다면 Null을 가지게 된다

  3. DB에서 모든 데이터 -> service에서 random()
    모든 findAll()로 가져와서 service 단에서 하나를 임의로 선정한다
    단점: DB의 데이터가 수 천개일 경우, 비효율적일 수 있음

Copy link
Member Author

Choose a reason for hiding this comment

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

DB에는 20개의 문장이 저장된다고 가정했습니다. 규모가 작을 때는 어떤 방법을 하든 비슷할 것이에요.
DB에서 연산을 하는 것보다 서버에서 연산하는 것이 성능상 더 좋다고 생각했어요. 따라서 3번째 방법이 가장 적합하다고 생각했습니다.

Copy link
Collaborator

Choose a reason for hiding this comment

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

문장 추천의 경우 저희가 직접 넣어주는 방식이다보니, findAll 쪽에 캐시를 걸어두어서, 1일에 한번씩 갱신하도록 해두면 매 조회 쿼리가 나가는 것이 아닌 서비스 측에서 가지게 되는 것인 추후에는 캐시가 들어가는 것도 좋아보입니다

Copy link
Member Author

Choose a reason for hiding this comment

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

findAll()에 캐시를 두는 방법 아주 좋은 것 같습니다!


}
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
.requestMatchers("/users/**").authenticated()
.requestMatchers("/items/**").authenticated()
.requestMatchers("/pop-up/**").authenticated()
.requestMatchers("/post-recommend/**").authenticated()
.requestMatchers(HttpMethod.POST, "notifications/tokens").authenticated()
.anyRequest().permitAll()
.and().exceptionHandling()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package unit.domains.recommend.controller;

import com.depromeet.common.error.GlobalExceptionHandler;
import com.depromeet.domains.recommend.controller.PostRecommendController;
import com.depromeet.domains.recommend.dto.response.PostRecommendSentenceResponseDto;
import com.depromeet.domains.recommend.service.PostRecommendService;
import com.depromeet.user.User;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.validation.ValidationAutoConfiguration;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.web.servlet.MockMvc;
import unit.annotation.MockAnonymousUser;

import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@ContextConfiguration(classes = {PostRecommendController.class, ValidationAutoConfiguration.class})
@WebMvcTest(controllers = {PostRecommendController.class})
@Import({PostRecommendController.class, GlobalExceptionHandler.class})
@DisplayName("[API][Controller] PostRecommendController 테스트")
public class PostRecommendControllerTest {

@Autowired
MockMvc mvc;

@MockBean
PostRecommendService postRecommendService;

User user;

@BeforeEach
void setUp() {
user = User.builder()
.idfv("new-idfv")
.build();
}

@DisplayName("[GET] 홈 화면 드랍 유도 - 무작위 문장 추천")
@Nested
@MockAnonymousUser
class GetRandomSentenceTest {
@Nested
@DisplayName("성공")
@MockAnonymousUser
class Success {
@DisplayName("무작위 추천 문장 1개 조회")
@Test
void getOneRandomSentenceSuccess1() throws Exception {

var randomSentence = new PostRecommendSentenceResponseDto("random sentence");
when(postRecommendService.getOneRandomSentence(any(User.class))).thenReturn(randomSentence);

var response = mvc.perform(
get("/post-recommend/random-sentence")
.header("x-sdp-idfv", "new-idfv")
);
response.andExpect(status().isOk())
.andExpect(jsonPath("$.sentence").value("random sentence"));
}

@DisplayName("무작위 추천 문장이 없는 경우")
@Test
void getOneRandomSentenceSuccess2() throws Exception {

var emptySentence = PostRecommendSentenceResponseDto.empty();
when(postRecommendService.getOneRandomSentence(any(User.class))).thenReturn(emptySentence);

var response = mvc.perform(
get("/post-recommend/random-sentence")
.header("x-sdp-idfv", "new-idfv")
);
response.andExpect(status().isOk())
.andExpect(jsonPath("$.sentence").isEmpty());
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package unit.domains.recommend.service;

import com.depromeet.common.error.exception.internal.NotFoundException;
import com.depromeet.domains.recommend.repository.PostRecommendSentenceRepository;
import com.depromeet.domains.recommend.repository.UserReadRepository;
import com.depromeet.domains.recommend.service.PostRecommendService;
import com.depromeet.recommend.post.PostRecommendSentence;
import com.depromeet.user.User;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
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 java.lang.reflect.Field;
import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy;
import static org.mockito.BDDMockito.given;

@ExtendWith(MockitoExtension.class)
@DisplayName("[Service] PostRecommendService 테스트")
public class PostRecommendServiceTest {

@InjectMocks
private PostRecommendService postRecommendService;

@Mock
private PostRecommendSentenceRepository postRecommendSentenceRepository;

@Mock
private UserReadRepository userReadRepository;

User user;

@BeforeEach
void setUp() throws NoSuchFieldException, IllegalAccessException {
user = User.builder().build();
Field userIdField = User.class.getDeclaredField("id");
userIdField.setAccessible(true);
userIdField.set(user, 1L);
}

@DisplayName("무작위 문장 추천")
@Nested
class GetOneRandomSentenceTest {
@Nested
@DisplayName("성공")
class Success {
@DisplayName("이미 추천 문장을 받은 사용자인 경우")
@Test
void getOneRandomSentenceSuccess1() {
given(userReadRepository.isSent(user.getId())).willReturn(true);
var result = postRecommendService.getOneRandomSentence(user);

assertThat(result.sentence()).isNull();
}

@DisplayName("무작위 추천 문장 1개 조회")
@Test
void getOneRandomSentenceSuccess2() {
List<PostRecommendSentence> sentences = List.of(
new PostRecommendSentence("First sentence"),
new PostRecommendSentence("Second sentence"),
new PostRecommendSentence("Third sentence")
);

given(postRecommendSentenceRepository.findAll()).willReturn(sentences);
given(userReadRepository.isSent(user.getId())).willReturn(false);
var result = postRecommendService.getOneRandomSentence(user);

assertThat(result).isNotNull();
assertThat(result.sentence()).isIn(
"First sentence",
"Second sentence",
"Third sentence"
);
}
}

@Nested
@DisplayName("실패")
class Fail {
@DisplayName("저장소에 추천 문장이 없는 경우")
@Test
void getOneRandomSentenceFail() {
given(userReadRepository.isSent(user.getId())).willReturn(false);
given(postRecommendSentenceRepository.findAll()).willReturn(List.of());

assertThatThrownBy(() -> postRecommendService.getOneRandomSentence(user))
.isInstanceOf(NotFoundException.class);
}
}
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.depromeet.recommend.post;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import static jakarta.persistence.GenerationType.IDENTITY;
import static lombok.AccessLevel.PROTECTED;

@Getter
@NoArgsConstructor(access = PROTECTED)
@Entity
public class PostRecommendSentence {

@Id
@GeneratedValue(strategy = IDENTITY)
@Column(name = "post_recommend_sentence_id")
private Long id;

@Column(nullable = false)
private String sentence;

@Builder
public PostRecommendSentence(String sentence) {
this.sentence = sentence;
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
CREATE TABLE IF NOT EXISTS post_recommend_sentence (
post_recommend_sentence_id BIGINT NOT NULL AUTO_INCREMENT,
sentence VARCHAR(255) NOT NULL,
PRIMARY KEY (post_recommend_sentence_id)
)
Loading