diff --git a/src/main/java/com/e2i1/linkeepserver/common/error/ErrorCode.java b/src/main/java/com/e2i1/linkeepserver/common/error/ErrorCode.java index 4014fef..3c522e4 100644 --- a/src/main/java/com/e2i1/linkeepserver/common/error/ErrorCode.java +++ b/src/main/java/com/e2i1/linkeepserver/common/error/ErrorCode.java @@ -52,8 +52,12 @@ public enum ErrorCode{ LIKE_NOT_FOUND(94040,HttpStatus.NOT_FOUND,"사용자가 모음집을 좋아요한 기록이 없습니다."), //친구 관련 에러 코드 - FRINEDS_NOT_FOUND(104040, HttpStatus.NOT_FOUND, "해당 사용자와 친구가 아닙니다."); + FRIENDS_NOT_FOUND(104040, HttpStatus.NOT_FOUND, "해당 사용자와 친구가 아닙니다."), + // 최근 검색 목록 관련 에러 코드 + INDEX_OUT_OF_RANGE(114000, HttpStatus.BAD_REQUEST, "인덱스 범위를 벗어난 요청입니다."), + + ; private final int errorCode; private final HttpStatus httpStatusCode; private final String description; diff --git a/src/main/java/com/e2i1/linkeepserver/config/RedisConfig.java b/src/main/java/com/e2i1/linkeepserver/config/RedisConfig.java new file mode 100644 index 0000000..cacef38 --- /dev/null +++ b/src/main/java/com/e2i1/linkeepserver/config/RedisConfig.java @@ -0,0 +1,36 @@ +package com.e2i1.linkeepserver.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +public class RedisConfig { + + @Value("${spring.data.redis.host}") + private String host; + + @Value("${spring.data.redis.port}") + private int port; + + @Bean + public RedisConnectionFactory redisConnectionFactory(){ + return new LettuceConnectionFactory(host, port); + } + + @Bean + public RedisTemplate redisTemplate() { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(redisConnectionFactory()); + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new StringRedisSerializer()); + redisTemplate.setHashKeySerializer(new StringRedisSerializer()); + redisTemplate.setHashValueSerializer(new StringRedisSerializer()); + return redisTemplate; + } + +} diff --git a/src/main/java/com/e2i1/linkeepserver/domain/friends/service/FriendsService.java b/src/main/java/com/e2i1/linkeepserver/domain/friends/service/FriendsService.java index 8ef0f79..a633034 100644 --- a/src/main/java/com/e2i1/linkeepserver/domain/friends/service/FriendsService.java +++ b/src/main/java/com/e2i1/linkeepserver/domain/friends/service/FriendsService.java @@ -26,7 +26,7 @@ public FriendsEntity insertFriend(FriendsEntity friend) { } public FriendsEntity findByFollowedUserAndFollowingUser(UsersEntity followee, UsersEntity follower) { - return friendsRepository.findByFollowedUserAndFollowingUser(followee,follower).orElseThrow(() -> new ApiException(ErrorCode.FRINEDS_NOT_FOUND)); + return friendsRepository.findByFollowedUserAndFollowingUser(followee,follower).orElseThrow(() -> new ApiException(ErrorCode.FRIENDS_NOT_FOUND)); } diff --git a/src/main/java/com/e2i1/linkeepserver/domain/links/business/LinksBusiness.java b/src/main/java/com/e2i1/linkeepserver/domain/links/business/LinksBusiness.java index 6f71885..3c9bfb7 100644 --- a/src/main/java/com/e2i1/linkeepserver/domain/links/business/LinksBusiness.java +++ b/src/main/java/com/e2i1/linkeepserver/domain/links/business/LinksBusiness.java @@ -21,6 +21,8 @@ import java.util.Optional; import java.util.regex.Pattern; import java.util.stream.Collectors; + +import com.e2i1.linkeepserver.domain.users.service.RecentSearchService; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; @@ -37,6 +39,8 @@ public class LinksBusiness { private final CollaboratorsService collaboratorsService; + private final RecentSearchService recentSearchService; + // 사전에 정규 표현식 컴파일 해놓고 재사용하기 final Pattern BLANK_PATTERN = Pattern.compile("\\s+"); @@ -82,7 +86,10 @@ public LinkResDTO findOneById(Long linkId, Long userId) { /** * 링크 title, description을 조회해 해당 검색어 들어있는 링크 목록 가져오기 */ - public SearchLinkResDTO searchLinks(String keyword, Long view, Long lastId, Integer size) { + public SearchLinkResDTO searchLinks(Long userId, String keyword, Long view, Long lastId, Integer size) { + // redis에 유저의 최근 검색어 목록에 검색어 저장하기 + recentSearchService.addSearchTerm(userId, keyword); + // 검색어를 공백 제외하고 하나의 문자열로 변환 keyword = BLANK_PATTERN.matcher(keyword).replaceAll("").toLowerCase(); diff --git a/src/main/java/com/e2i1/linkeepserver/domain/links/controller/LinksController.java b/src/main/java/com/e2i1/linkeepserver/domain/links/controller/LinksController.java index 25edc4a..8a5fa56 100644 --- a/src/main/java/com/e2i1/linkeepserver/domain/links/controller/LinksController.java +++ b/src/main/java/com/e2i1/linkeepserver/domain/links/controller/LinksController.java @@ -33,10 +33,11 @@ public ResponseEntity searchLink( @RequestParam(value = "search") String keyword, @RequestParam(value = "view", required = false) Long view, @RequestParam(value = "lastId", required = false) Long lastId, - @RequestParam(value = "size", defaultValue = DEFAULT_PAGE_SIZE) Integer size + @RequestParam(value = "size", defaultValue = DEFAULT_PAGE_SIZE) Integer size, + @UserSession UsersEntity user ) { log.info("search keyword = {}", keyword); - SearchLinkResDTO searchLinks = linksBusiness.searchLinks(keyword, view, lastId, size); + SearchLinkResDTO searchLinks = linksBusiness.searchLinks(user.getId(), keyword, view, lastId, size); return ResponseEntity.ok(searchLinks); } diff --git a/src/main/java/com/e2i1/linkeepserver/domain/links/dto/LinkReqDTO.java b/src/main/java/com/e2i1/linkeepserver/domain/links/dto/LinkReqDTO.java index 90b72e7..9d18b98 100644 --- a/src/main/java/com/e2i1/linkeepserver/domain/links/dto/LinkReqDTO.java +++ b/src/main/java/com/e2i1/linkeepserver/domain/links/dto/LinkReqDTO.java @@ -1,6 +1,7 @@ package com.e2i1.linkeepserver.domain.links.dto; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -19,6 +20,6 @@ public class LinkReqDTO { private String description; - @NotBlank(message = "저장할 모음집을 선택하세요.") + @NotNull(message = "저장할 모음집을 선택하세요.") private Long collectionId; } diff --git a/src/main/java/com/e2i1/linkeepserver/domain/users/business/UsersBusiness.java b/src/main/java/com/e2i1/linkeepserver/domain/users/business/UsersBusiness.java index ec81de1..6089722 100644 --- a/src/main/java/com/e2i1/linkeepserver/domain/users/business/UsersBusiness.java +++ b/src/main/java/com/e2i1/linkeepserver/domain/users/business/UsersBusiness.java @@ -15,15 +15,9 @@ import com.e2i1.linkeepserver.domain.token.entity.BlackList; import com.e2i1.linkeepserver.domain.token.service.TokenService; import com.e2i1.linkeepserver.domain.users.converter.UsersConverter; -import com.e2i1.linkeepserver.domain.users.dto.EditProfileReqDTO; -import com.e2i1.linkeepserver.domain.users.dto.LinkHomeResDTO; -import com.e2i1.linkeepserver.domain.users.dto.LoginReqDTO; -import com.e2i1.linkeepserver.domain.users.dto.LoginResDTO; -import com.e2i1.linkeepserver.domain.users.dto.NicknameResDTO; -import com.e2i1.linkeepserver.domain.users.dto.ProfileResDTO; -import com.e2i1.linkeepserver.domain.users.dto.SignupReqDTO; -import com.e2i1.linkeepserver.domain.users.dto.UserHomeResDTO; +import com.e2i1.linkeepserver.domain.users.dto.*; import com.e2i1.linkeepserver.domain.users.entity.UsersEntity; +import com.e2i1.linkeepserver.domain.users.service.RecentSearchService; import com.e2i1.linkeepserver.domain.users.service.UsersService; import java.util.List; import java.util.Random; @@ -45,6 +39,8 @@ public class UsersBusiness { private final S3ImageService s3ImageService; private final TokenService tokenService; + private final RecentSearchService recentSearchService; + @Transactional public LoginResDTO login(LoginReqDTO loginReqDTO) { UsersEntity user = usersService.getUser(loginReqDTO.getEmail()); @@ -207,5 +203,15 @@ private String createRandomNickname() { return nickname; } + public RecentSearchResDTO getRecentSearch(Long userID) { + return RecentSearchResDTO.builder() + .recentSearchList(recentSearchService.getRecentSearch(userID)) + .build(); + } + + public void deleteRecentKeyword(Long userId, int index) { + recentSearchService.deleteRecentSearch(userId, index); + } + } diff --git a/src/main/java/com/e2i1/linkeepserver/domain/users/controller/UsersController.java b/src/main/java/com/e2i1/linkeepserver/domain/users/controller/UsersController.java index 84cd2e2..4a17f25 100644 --- a/src/main/java/com/e2i1/linkeepserver/domain/users/controller/UsersController.java +++ b/src/main/java/com/e2i1/linkeepserver/domain/users/controller/UsersController.java @@ -5,28 +5,14 @@ import com.e2i1.linkeepserver.common.annotation.UserSession; import com.e2i1.linkeepserver.domain.token.dto.TokenResDTO; import com.e2i1.linkeepserver.domain.users.business.UsersBusiness; -import com.e2i1.linkeepserver.domain.users.dto.EditProfileReqDTO; -import com.e2i1.linkeepserver.domain.users.dto.LoginReqDTO; -import com.e2i1.linkeepserver.domain.users.dto.LoginResDTO; -import com.e2i1.linkeepserver.domain.users.dto.NicknameResDTO; -import com.e2i1.linkeepserver.domain.users.dto.ProfileResDTO; -import com.e2i1.linkeepserver.domain.users.dto.SignupReqDTO; -import com.e2i1.linkeepserver.domain.users.dto.UserHomeResDTO; +import com.e2i1.linkeepserver.domain.users.dto.*; import com.e2i1.linkeepserver.domain.users.entity.UsersEntity; import jakarta.validation.Valid; import java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PatchMapping; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestHeader; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RequestPart; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; @Slf4j @@ -101,5 +87,22 @@ public ResponseEntity logout(@RequestHeader("authorization-token") Strin return ResponseEntity.ok("로그아웃 되었습니다."); } + @GetMapping("/recent-search") + public ResponseEntity recentSearch(@UserSession UsersEntity user) { + RecentSearchResDTO response = usersBusiness.getRecentSearch(user.getId()); + + return ResponseEntity.ok(response); + } + + @DeleteMapping("/recent-search") + public ResponseEntity deleteRecentSearch( + @RequestParam int index, + @UserSession UsersEntity user) { + + usersBusiness.deleteRecentKeyword(user.getId(), index); + + return ResponseEntity.ok("success"); + } + } diff --git a/src/main/java/com/e2i1/linkeepserver/domain/users/dto/RecentSearchResDTO.java b/src/main/java/com/e2i1/linkeepserver/domain/users/dto/RecentSearchResDTO.java new file mode 100644 index 0000000..523557b --- /dev/null +++ b/src/main/java/com/e2i1/linkeepserver/domain/users/dto/RecentSearchResDTO.java @@ -0,0 +1,16 @@ +package com.e2i1.linkeepserver.domain.users.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class RecentSearchResDTO { + private List recentSearchList; +} diff --git a/src/main/java/com/e2i1/linkeepserver/domain/users/service/RecentSearchService.java b/src/main/java/com/e2i1/linkeepserver/domain/users/service/RecentSearchService.java new file mode 100644 index 0000000..3f9feb0 --- /dev/null +++ b/src/main/java/com/e2i1/linkeepserver/domain/users/service/RecentSearchService.java @@ -0,0 +1,66 @@ +package com.e2i1.linkeepserver.domain.users.service; + +import com.e2i1.linkeepserver.common.exception.ApiException; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.ListOperations; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.util.List; + +import static com.e2i1.linkeepserver.common.error.ErrorCode.INDEX_OUT_OF_RANGE; +import static com.e2i1.linkeepserver.common.error.ErrorCode.NULL_POINT; + +@Service +@RequiredArgsConstructor +public class RecentSearchService { + + private final RedisTemplate redisTemplate; + private static final String RECENT_SEARCHES_KEY_PREFIX = "recent_searches:"; + private static final int MAX_RECENT_SEARCHES = 10; + private static final String DELETE_MARKER = "__DELETE__"; + + + public void addSearchTerm(Long userId, String keyword) { + String key = RECENT_SEARCHES_KEY_PREFIX + userId; + + List searchList = redisTemplate.opsForList().range(key, 0, -1); + + if (searchList == null) { + throw new ApiException(NULL_POINT, "해당 유저는 최근 검색 기록이 없습니다."); + } + + // keyword가 이미 최근 검색어 목록에 있다면 중복 저장하지 않고 가장 앞으로 가져오기 위해 삭제 + if (searchList.contains(keyword)) { + redisTemplate.opsForList().remove(key, 0, keyword); + } + + redisTemplate.opsForList().leftPush(key, keyword); + + redisTemplate.opsForList().trim(key, 0, MAX_RECENT_SEARCHES - 1); + } + + public List getRecentSearch(Long userId) { + String key = RECENT_SEARCHES_KEY_PREFIX + userId; + return redisTemplate.opsForList().range(key, 0, -1); + + } + + public void deleteRecentSearch(Long userId, int index) { + String key = RECENT_SEARCHES_KEY_PREFIX + userId; + ListOperations listOps = redisTemplate.opsForList(); + Long listSize = listOps.size(key); + + if (listSize == null) { + throw new ApiException(NULL_POINT, "해당 유저는 최근 검색 기록이 없습니다."); + } + if (index >= listSize || index < 0) { + throw new ApiException(INDEX_OUT_OF_RANGE); + } + + listOps.set(key, index, DELETE_MARKER); + listOps.remove(key, 0, DELETE_MARKER); //list 개수 10개 고정이기에 전체 탐색 오버헤드 작음 + } + + +} diff --git a/src/main/java/com/e2i1/linkeepserver/exceptionhandler/ApiExceptionHandler.java b/src/main/java/com/e2i1/linkeepserver/exceptionhandler/ApiExceptionHandler.java index 6a2d6d3..5db8cdc 100644 --- a/src/main/java/com/e2i1/linkeepserver/exceptionhandler/ApiExceptionHandler.java +++ b/src/main/java/com/e2i1/linkeepserver/exceptionhandler/ApiExceptionHandler.java @@ -4,10 +4,11 @@ import com.e2i1.linkeepserver.common.error.ErrorResponse; import com.e2i1.linkeepserver.common.exception.ApiException; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.support.BeanDefinitionValidationException; import org.springframework.core.annotation.Order; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -36,20 +37,26 @@ public ResponseEntity handleApiException(ApiException ex) { * DTO 등에서 validation 실패 시, 해당 예외 처리하는 핸들러 * NotNull, NotBlank 등의 애노테이션 검증 실패 시 해당 예외 처리해줌 */ - @ExceptionHandler(value = BeanDefinitionValidationException.class) - public ResponseEntity handlerBeanValidationException(BeanDefinitionValidationException ex) { - log.error("", ex); + @ExceptionHandler(value = MethodArgumentNotValidException.class) + public ResponseEntity handleMethodArgumentNotValidException( + MethodArgumentNotValidException ex) { + log.error("Method argument not valid exception", ex); + + FieldError fieldError = ex.getBindingResult().getFieldError(); + String errorMessage = (fieldError != null) ? fieldError.getDefaultMessage() : "Validation error"; + return ResponseEntity .status(HttpStatus.BAD_REQUEST) .body( ErrorResponse.builder() .errorCode(40000) - .errorMessage(ex.getMessage()) + .errorMessage(errorMessage) .build() ); } // 예상치 못한 예외에 대응하기 위한 Exception handler + // TODO : 최종 어플리케이션 배포 시, ex.getMessage() 대신 "SERVER ERROR"로 수정 @ExceptionHandler(Exception.class) public ResponseEntity handleException(Exception ex) { return ResponseEntity