diff --git a/src/main/java/ku_rum/backend/domain/notice/application/NoticeService.java b/src/main/java/ku_rum/backend/domain/notice/application/NoticeService.java index 203191d0..321d5d75 100644 --- a/src/main/java/ku_rum/backend/domain/notice/application/NoticeService.java +++ b/src/main/java/ku_rum/backend/domain/notice/application/NoticeService.java @@ -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; @@ -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 findByCategory(Long categoryId, Pageable pageable) { return noticeRepository.findByCategoryIdAndPublishStatus(categoryId, PublishStatus.SUCCESS_CRAWLING, pageable) @@ -84,6 +86,8 @@ public List findPrimaryNotices() { // 키워드 검색 로직 public Page searchByKeyword(String keyword, Pageable pageable) { + recentSearchService.save(keyword); + return noticeRepository.searchByKeyword(keyword, PublishStatus.SUCCESS_CRAWLING, pageable) .map(NoticeResponse::from); } diff --git a/src/main/java/ku_rum/backend/domain/notice/presentation/NoticeController.java b/src/main/java/ku_rum/backend/domain/notice/presentation/NoticeController.java index 54c1bb4d..1f4852b6 100644 --- a/src/main/java/ku_rum/backend/domain/notice/presentation/NoticeController.java +++ b/src/main/java/ku_rum/backend/domain/notice/presentation/NoticeController.java @@ -53,4 +53,5 @@ public Page searchNoticesByKeyword( ) { return noticeService.searchByKeyword(keyword, pageable); } + } diff --git a/src/main/java/ku_rum/backend/domain/search/application/RecentSearchService.java b/src/main/java/ku_rum/backend/domain/search/application/RecentSearchService.java new file mode 100644 index 00000000..7868aaac --- /dev/null +++ b/src/main/java/ku_rum/backend/domain/search/application/RecentSearchService.java @@ -0,0 +1,93 @@ +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.dao.DataIntegrityViolationException; +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 = normalizeKeyword(keyword); + if (normalized == null) { + return; + } + + User user = userService.getUser(); + LocalDateTime now = LocalDateTime.now(); + + try { + repository.findByUserIdAndKeyword(user.getId(), normalized) + .ifPresentOrElse( + rs -> rs.touch(now), + () -> repository.save( + new RecentSearch(user.getId(), normalized, now) + ) + ); + } catch (DataIntegrityViolationException e) { + repository.findByUserIdAndKeyword(user.getId(), normalized) + .ifPresent(rs -> rs.touch(now)); + } + + deleteRecentKeywordsOversize(user); + } + + private void deleteRecentKeywordsOversize(User user) { + List 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())); + } + } + + private String normalizeKeyword(String keyword) { + if (keyword == null) { + return null; + } + + String trimmed = keyword.trim(); + if (trimmed.isEmpty()) return null; + return trimmed; + } + + @Transactional(readOnly = true) + public List list(final 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()); + } +} diff --git a/src/main/java/ku_rum/backend/domain/search/domain/RecentSearch.java b/src/main/java/ku_rum/backend/domain/search/domain/RecentSearch.java new file mode 100644 index 00000000..66a8193c --- /dev/null +++ b/src/main/java/ku_rum/backend/domain/search/domain/RecentSearch.java @@ -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; + } +} diff --git a/src/main/java/ku_rum/backend/domain/search/domain/repository/RecentSearchRepository.java b/src/main/java/ku_rum/backend/domain/search/domain/repository/RecentSearchRepository.java new file mode 100644 index 00000000..d3a89b5a --- /dev/null +++ b/src/main/java/ku_rum/backend/domain/search/domain/repository/RecentSearchRepository.java @@ -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 { + + Optional findByUserIdAndKeyword(Long userId, String keyword); + + List findByUserIdOrderByUpdatedAtDesc(Long userId, Pageable pageable); + + long deleteByIdAndUserId(Long id, Long userId); + + long deleteByUserId(Long userId); +} diff --git a/src/main/java/ku_rum/backend/domain/search/presentation/RecentSearchController.java b/src/main/java/ku_rum/backend/domain/search/presentation/RecentSearchController.java new file mode 100644 index 00000000..a6870efe --- /dev/null +++ b/src/main/java/ku_rum/backend/domain/search/presentation/RecentSearchController.java @@ -0,0 +1,39 @@ +package ku_rum.backend.domain.search.presentation; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +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.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/v1/notices/searches/recent") +@RequiredArgsConstructor +@Validated +public class RecentSearchController { + + private final RecentSearchService recentSearchService; + + @GetMapping + public BaseResponse> list( + @RequestParam(defaultValue = "20") @Min(1) @Max(100) final int limit) { + return BaseResponse.ok(recentSearchService.list(limit)); + } + + @DeleteMapping("/{id}") + public BaseResponse delete(@PathVariable Long id) { + recentSearchService.delete(id); + return BaseResponse.ok(null); + } + + @DeleteMapping("/all") + public BaseResponse deleteAll() { + recentSearchService.deleteAll(); + return BaseResponse.ok(null); + } +} diff --git a/src/test/java/ku_rum/backend/domain/search/presentation/RecentSearchControllerTest.java b/src/test/java/ku_rum/backend/domain/search/presentation/RecentSearchControllerTest.java new file mode 100644 index 00000000..cc4d51b9 --- /dev/null +++ b/src/test/java/ku_rum/backend/domain/search/presentation/RecentSearchControllerTest.java @@ -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 response = List.of( + mockRecentSearch(1L, 1L, "장학금"), + mockRecentSearch(2L, 1L, "등록금") + ); + + given(recentSearchService.list(20)).willReturn(response); + + // when & then + mockMvc.perform(get("/api/v1/notices/searches/recent") + .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/notices/searches/recent/{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/notices/searches/recent/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; + } +}