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

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<RecentSearch> 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());
}
}
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,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<RecentSearch>> list(
@RequestParam(defaultValue = "20") @Min(1) @Max(100) final 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/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;
}
}
Loading