Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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 @@ -9,6 +9,7 @@
import ku_rum.backend.domain.notice.domain.repository.NoticeRepository;
import ku_rum.backend.domain.notice.dto.response.NoticeDetailResponse;
import ku_rum.backend.domain.notice.dto.response.NoticeResponse;
import ku_rum.backend.domain.search.application.RecentSearchService;
import ku_rum.backend.domain.user.application.UserService;
import ku_rum.backend.domain.user.domain.User;
import ku_rum.backend.global.exception.global.GlobalException;
Expand Down Expand Up @@ -36,6 +37,7 @@ public class NoticeService {
private final NoticeDetailRepository noticeDetailRepository;
private final BookmarkRepository bookmarkRepository;
private final UserService userService;
private final RecentSearchService recentSearchService;

public Page<NoticeResponse> findByCategory(Long categoryId, Pageable pageable) {
return noticeRepository.findByCategoryIdAndPublishStatus(categoryId, PublishStatus.SUCCESS_CRAWLING, pageable)
Expand Down Expand Up @@ -84,6 +86,8 @@ public List<NoticeResponse> findPrimaryNotices() {

// 키워드 검색 로직
public Page<NoticeResponse> searchByKeyword(String keyword, Pageable pageable) {
recentSearchService.save(keyword);

return noticeRepository.searchByKeyword(keyword, PublishStatus.SUCCESS_CRAWLING, pageable)
.map(NoticeResponse::from);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,4 +53,5 @@ public Page<NoticeResponse> searchNoticesByKeyword(
) {
return noticeService.searchByKeyword(keyword, pageable);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package ku_rum.backend.domain.search.application;

import ku_rum.backend.domain.search.domain.RecentSearch;
import ku_rum.backend.domain.search.domain.repository.RecentSearchRepository;
import ku_rum.backend.domain.user.application.UserService;
import ku_rum.backend.domain.user.domain.User;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDateTime;
import java.util.List;

@Service
@RequiredArgsConstructor
public class RecentSearchService {

private static final int MAX_RECENT_SEARCH = 20;

private final RecentSearchRepository repository;
private final UserService userService;

@Transactional
public void save(String keyword) {
String normalized = keyword == null ? "" : keyword.trim();
Copy link
Contributor

Choose a reason for hiding this comment

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

? 연산자 지양해봐요

Copy link
Contributor Author

Choose a reason for hiding this comment

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

private으로 뺐습니다!

if (normalized.isEmpty()) return;

User user = userService.getUser();
LocalDateTime now = LocalDateTime.now();

repository.findByUserIdAndKeyword(user.getId(), normalized)
.ifPresentOrElse(
rs -> rs.touch(now),
() -> repository.save(
new RecentSearch(user.getId(), normalized, now)
)
);

// 최대 개수 초과 시 오래된 것 삭제
List<RecentSearch> list =
repository.findByUserIdOrderByUpdatedAtDesc(
user.getId(),
PageRequest.of(0, MAX_RECENT_SEARCH + 1)
);

if (list.size() > MAX_RECENT_SEARCH) {
repository.deleteAll(list.subList(MAX_RECENT_SEARCH, list.size()));
}
}

@Transactional(readOnly = true)
public List<RecentSearch> list(int limit) {
User user = userService.getUser();
return repository.findByUserIdOrderByUpdatedAtDesc(
user.getId(),
PageRequest.of(0, limit)
);
}

@Transactional
public void delete(Long id) {
User user = userService.getUser();
repository.deleteByIdAndUserId(id, user.getId());
}

@Transactional
public void deleteAll() {
User user = userService.getUser();
repository.deleteByUserId(user.getId());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package ku_rum.backend.domain.search.domain;

import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.time.LocalDateTime;

@Getter
@NoArgsConstructor
@Entity
@Table(
name = "recent_search",
uniqueConstraints = @UniqueConstraint(
name = "uk_recent_search_user_keyword",
columnNames = {"user_id", "keyword"}
),
indexes = @Index(
name = "idx_recent_search_user_updated",
columnList = "user_id, updated_at"
)
)
public class RecentSearch {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(name = "user_id", nullable = false)
private Long userId;

@Column(nullable = false, length = 255)
private String keyword;

@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;

@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;

public RecentSearch(Long userId, String keyword, LocalDateTime now) {
this.userId = userId;
this.keyword = keyword;
this.createdAt = now;
this.updatedAt = now;
}

public void touch(LocalDateTime now) {
this.updatedAt = now;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package ku_rum.backend.domain.search.domain.repository;

import ku_rum.backend.domain.search.domain.RecentSearch;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;
import java.util.Optional;

public interface RecentSearchRepository extends JpaRepository<RecentSearch, Long> {

Optional<RecentSearch> findByUserIdAndKeyword(Long userId, String keyword);

List<RecentSearch> findByUserIdOrderByUpdatedAtDesc(Long userId, Pageable pageable);

long deleteByIdAndUserId(Long id, Long userId);

long deleteByUserId(Long userId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package ku_rum.backend.domain.search.presentation;

import ku_rum.backend.domain.search.application.RecentSearchService;
import ku_rum.backend.domain.search.domain.RecentSearch;
import ku_rum.backend.global.support.response.BaseResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping("/api/v1/recent-searches")
Copy link
Contributor

Choose a reason for hiding this comment

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

엔드포인트 prefix가 적합하지 않아보입니다

Copy link
Contributor Author

Choose a reason for hiding this comment

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

/api/v1/notices/searches/recent 로 수정했습니다.

@RequiredArgsConstructor
public class RecentSearchController {

private final RecentSearchService recentSearchService;

@GetMapping
public BaseResponse<List<RecentSearch>> list(
@RequestParam(defaultValue = "20") int limit) {
return BaseResponse.ok(recentSearchService.list(limit));
}

@DeleteMapping("/{id}")
public BaseResponse<Void> delete(@PathVariable Long id) {
recentSearchService.delete(id);
return BaseResponse.ok(null);
}

@DeleteMapping("/all")
public BaseResponse<Void> deleteAll() {
recentSearchService.deleteAll();
return BaseResponse.ok(null);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
package ku_rum.backend.domain.search.presentation;

import com.epages.restdocs.apispec.ResourceSnippetParameters;
import ku_rum.backend.config.RestDocsTestSupport;
import ku_rum.backend.domain.search.application.RecentSearchService;
import ku_rum.backend.domain.search.domain.RecentSearch;
import ku_rum.backend.global.domain.repository.ApiLogRepository;
import ku_rum.backend.global.security.JwtTokenAuthenticationFilter;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.restdocs.payload.JsonFieldType;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.util.ReflectionTestUtils;

import java.time.LocalDateTime;
import java.util.List;

import static com.epages.restdocs.apispec.ResourceDocumentation.resource;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.doNothing;
import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.delete;
import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get;
import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath;
import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@WebMvcTest(RecentSearchController.class)
@ActiveProfiles("test")
class RecentSearchControllerTest extends RestDocsTestSupport {

@MockBean
private RecentSearchService recentSearchService;

@MockBean
private ApiLogRepository apiLogRepository;

@MockBean
private SecurityFilterChain securityFilterChain;

@MockBean
private JwtTokenAuthenticationFilter jwtTokenAuthenticationFilter;


@Test
@DisplayName("최근 검색어 목록을 조회한다")
@WithMockUser
void getRecentSearches() throws Exception {
// given
List<RecentSearch> response = List.of(
mockRecentSearch(1L, 1L, "장학금"),
mockRecentSearch(2L, 1L, "등록금")
);

given(recentSearchService.list(20)).willReturn(response);

// when & then
mockMvc.perform(get("/api/v1/recent-searches")
.param("limit", "20")
.contentType(MediaType.APPLICATION_JSON)
)
.andDo(print())
.andExpect(status().isOk())
.andDo(restDocs.document(
resource(
ResourceSnippetParameters.builder()
.tag("최근 검색어 API")
.description("최근 검색어 목록 조회")
.queryParameters(
parameterWithName("limit")
.description("조회할 최근 검색어 개수 (기본값 20)")
)
.responseFields(
fieldWithPath("code").type(JsonFieldType.NUMBER).description("응답 코드"),
fieldWithPath("status").type(JsonFieldType.STRING).description("응답 상태"),
fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지"),
fieldWithPath("data[].id").type(JsonFieldType.NUMBER).description("최근 검색어 ID"),
fieldWithPath("data[].userId").type(JsonFieldType.NUMBER).description("유저 ID"),
fieldWithPath("data[].keyword").type(JsonFieldType.STRING).description("검색 키워드"),
fieldWithPath("data[].createdAt").type(JsonFieldType.STRING).description("생성 시각"),
fieldWithPath("data[].updatedAt").type(JsonFieldType.STRING).description("최근 검색 시각")
)
.build()
)
));
}

@Test
@DisplayName("최근 검색어를 단건 삭제한다")
@WithMockUser
void deleteRecentSearch() throws Exception {
doNothing().when(recentSearchService).delete(1L);

mockMvc.perform(delete("/api/v1/recent-searches/{id}", 1L)
.contentType(MediaType.APPLICATION_JSON)
)
.andDo(print())
.andExpect(status().isOk())
.andDo(restDocs.document(
resource(
ResourceSnippetParameters.builder()
.tag("최근 검색어 API")
.description("최근 검색어 단건 삭제")
.responseFields(
fieldWithPath("code").type(JsonFieldType.NUMBER).description("응답 코드"),
fieldWithPath("status").type(JsonFieldType.STRING).description("응답 상태"),
fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지")
)
.build()
)
));
}

@Test
@DisplayName("최근 검색어 전체 삭제")
@WithMockUser
void deleteAllRecentSearches() throws Exception {
doNothing().when(recentSearchService).deleteAll();

mockMvc.perform(delete("/api/v1/recent-searches/all")
.contentType(MediaType.APPLICATION_JSON)
)
.andDo(print())
.andExpect(status().isOk())
.andDo(restDocs.document(
resource(
ResourceSnippetParameters.builder()
.tag("최근 검색어 API")
.description("최근 검색어 전체 삭제")
.responseFields(
fieldWithPath("code").type(JsonFieldType.NUMBER).description("응답 코드"),
fieldWithPath("status").type(JsonFieldType.STRING).description("응답 상태"),
fieldWithPath("message").type(JsonFieldType.STRING).description("응답 메시지")
)
.build()
)
));
}

private RecentSearch mockRecentSearch(Long id, Long userId, String keyword) {
RecentSearch rs = new RecentSearch(userId, keyword, LocalDateTime.now());
ReflectionTestUtils.setField(rs, "id", id);
return rs;
}
}
Loading