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 MemoryUserTimestampRepository implements UserTimestampRepository{

private static Map<Long, LocalDateTime> store = new HashMap<>();
Copy link
Member Author

@siyeonSon siyeonSon Oct 20, 2024

Choose a reason for hiding this comment

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

요구사항

  • 매일매일 갱신되어야 한다
  • 사용자에게 해당 코멘트를 보냈다는 기록이 저장되어야 함

다양한 방법들

  1. redis: redis에 ttl을 24시간으로 설정하여 기록하면 좋을 듯. 그러나 꺼진 redis를 다시 세팅하는 것은 리소스가 듦
  2. DB: DB에 값을 저장하는 방법은 안전하지만 굳이 DB에 저장할 만큼 중요한 정보는 아님. 사라져도 괜찮음
  3. 로컬 메모리: Map 형태로 저장하는 방법. 가장 간단하지만 서버가 꺼지면 데이터가 모두 날아감

Copy link
Member Author

Choose a reason for hiding this comment

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

서버를 재실행하면 로컬에 저장된 Map<Long, LocalDateTime> store가 모두 사라지는 문제가 있습니다. 그러나 유실 되어도 크게 상관 없는 로직이라 판단했습니다.

redis에 저장하는 것이 가장 적합하다고 생각했으나, 빠르게 개발히기 위해 redis 보다는 로컬 메모리를 활용했습니다.

Copy link
Collaborator

Choose a reason for hiding this comment

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

유저에게 코멘트를 보냈다는 것을 확인하는 것에는 방식에 두가지가 있을 것 같은데,

  1. 서버에서 유저가 요청했던 시간을 가지고 있는 것
  2. 클라에서 마지막으로 요청한 시간을 가지고 있는 것
    서버 측 방법으로 구현하게되면 시연님이 해주신 방법이 가장 좋을 것 같고
    클라측에서 구현한다면, 쿼리 파라미터나 헤더쪽에 Last-sent-time 같은 거를 추가해서, 이 시간이 하루를 넘어갈 경우에만 서버에서 내려주는 방식이 있을 것 같습니다. (Notice와 비슷하게 클라측에서 값을 가지고 있을 때)


@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);
}

}
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 UserTimestampRepository {
Copy link
Collaborator

Choose a reason for hiding this comment

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

UserTimestampRepository만 보았을때, 어떤 레포지토리인지 정확하게 이해하는데 다소 한계가 있을 것 같습니다.

Copy link
Member Author

@siyeonSon siyeonSon Oct 21, 2024

Choose a reason for hiding this comment

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

Copy link
Member Author

@siyeonSon siyeonSon Oct 21, 2024

Choose a reason for hiding this comment

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

UserRecommendSendHistoryRepository 로 변경했습니다

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.UserTimestampRepository;
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 UserTimestampRepository userTimestampRepository;

public PostRecommendSentenceResponseDto getOneRandomSentence(User user) {
if (userTimestampRepository.isSent(user.getId())) {
return PostRecommendSentenceResponseDto.empty();
}
String randomSentence = getRandomSentence();
userTimestampRepository.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.UserTimestampRepository;
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 UserTimestampRepository userTimestampRepository;

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(userTimestampRepository.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(userTimestampRepository.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(userTimestampRepository.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