diff --git a/src/main/java/com/capstone/bszip/Bookstore/BookstoreLikeTrendingScheduler.java b/src/main/java/com/capstone/bszip/Bookstore/BookstoreLikeTrendingScheduler.java new file mode 100644 index 0000000..afc985d --- /dev/null +++ b/src/main/java/com/capstone/bszip/Bookstore/BookstoreLikeTrendingScheduler.java @@ -0,0 +1,21 @@ +package com.capstone.bszip.Bookstore; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class BookstoreLikeTrendingScheduler { + + private final RedisTemplate redisTemplate; + + @Scheduled(cron="0 0 0 * * MON") + public void resetWeeklyTrend(){ + // 백업 + redisTemplate.rename("trending:bookstores:weekly", "trending:bookstores:lastweek"); + // 초기화 + redisTemplate.delete("trending:bookstores:weekly"); + } +} diff --git a/src/main/java/com/capstone/bszip/Bookstore/controller/BookstoreController.java b/src/main/java/com/capstone/bszip/Bookstore/controller/BookstoreController.java index 0d181a5..39e52d4 100644 --- a/src/main/java/com/capstone/bszip/Bookstore/controller/BookstoreController.java +++ b/src/main/java/com/capstone/bszip/Bookstore/controller/BookstoreController.java @@ -22,6 +22,7 @@ import lombok.Getter; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Sort; +import org.springframework.data.redis.RedisConnectionFailureException; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -30,6 +31,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.NoSuchElementException; @RestController @@ -307,4 +309,59 @@ public ResponseEntity getBookstoreDetails(@PathVariable Long bookstoreId, .build()); } } + + @Operation( + summary = "급상승 독립서점 목록 조회", + description = "관심 급상승(weekly 인기순) 독립서점 이름 리스트를 반환합니다. " + + "데이터 10개를 제공하며 부족할 경우 지난 주 랭킹/전체 서점에서 순차적으로 채워집니다." + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "급상승 서점 목록 조회 성공", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = SuccessResponse.class))), + @ApiResponse(responseCode = "404", description = "급상승 서점 정보가 존재하지 않음", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "503", description = "Redis 연결 실패 등 서비스 일시적 장애", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "500", description = "서버 오류 발생", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))) + }) + @GetMapping("/trending") + public ResponseEntity getTrendingBookstores(){ + try { + List trendingBookstores = bookstoreService.getTrendingBookstoresNames(); + return ResponseEntity.ok(SuccessResponse.>builder() + .result(true) + .status(HttpStatus.OK.value()) + .message("급상승 서점 목록 조회 성공") + .data(trendingBookstores) + .build()); + + } catch (NoSuchElementException e) { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(ErrorResponse.builder() + .result(false) + .status(HttpStatus.NOT_FOUND.value()) + .message("급상승 서점 정보가 존재하지 않습니다") + .detail(e.getMessage()) + .build()); + + } catch (RedisConnectionFailureException e) { + return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE) + .body(ErrorResponse.builder() + .result(false) + .status(HttpStatus.SERVICE_UNAVAILABLE.value()) + .message("서비스 일시적으로 이용할 수 없습니다") + .detail("Redis 연결 실패: " + e.getMessage()) + .build()); + } catch (Exception e) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(ErrorResponse.builder() + .result(false) + .status(HttpStatus.INTERNAL_SERVER_ERROR.value()) + .message("서버 오류가 발생했습니다") + .detail(e.getMessage()) + .build()); + } + + } } diff --git a/src/main/java/com/capstone/bszip/Bookstore/repository/BookstoreRepository.java b/src/main/java/com/capstone/bszip/Bookstore/repository/BookstoreRepository.java index a8c7b0b..ef29e07 100644 --- a/src/main/java/com/capstone/bszip/Bookstore/repository/BookstoreRepository.java +++ b/src/main/java/com/capstone/bszip/Bookstore/repository/BookstoreRepository.java @@ -40,4 +40,7 @@ List findAllByIdOrderByDistance(@Param("bookstoreIds") List boo List findAllByNameContaining(String name); List findTop10ByOrderByBookstoreIdDesc(); + + @Query("SELECT b.bookstoreId, b.name FROM Bookstore b WHERE b.bookstoreId IN :ids") + List findIdAndNameByIds(@Param("ids") List ids); } diff --git a/src/main/java/com/capstone/bszip/Bookstore/service/BookstoreService.java b/src/main/java/com/capstone/bszip/Bookstore/service/BookstoreService.java index 59f1d4a..85dded4 100644 --- a/src/main/java/com/capstone/bszip/Bookstore/service/BookstoreService.java +++ b/src/main/java/com/capstone/bszip/Bookstore/service/BookstoreService.java @@ -13,10 +13,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.Collections; -import java.util.List; -import java.util.Optional; -import java.util.Set; +import java.util.*; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Collectors; @@ -89,10 +86,12 @@ public void toggleLikeBookstore(Long memberId, Long bookstoreId){ if(redisTemplate.opsForSet().isMember(memberKey,bookstoreId.toString())){ redisTemplate.opsForSet().remove(memberKey,bookstoreId.toString());//찜 취소 redisTemplate.opsForValue().decrement(bookstoreKey); //찜한 수 -1 + redisTemplate.opsForZSet().incrementScore("trending:bookstores:weekly", String.valueOf(bookstoreId), -1); } else{ redisTemplate.opsForSet().add(memberKey,bookstoreId.toString());//찜 redisTemplate.opsForValue().increment(bookstoreKey); //찜한 수 +1 + redisTemplate.opsForZSet().incrementScore("trending:bookstores:weekly", String.valueOf(bookstoreId), 1); } } @@ -212,5 +211,48 @@ private static String extractMatch(Pattern pattern, String text) { return matcher.find() ? matcher.group(1).trim() : null; } + public List getTrendingBookstoresNames(){ + Set topBookstoreIds = redisTemplate.opsForZSet() + .reverseRange("trending:bookstores:weekly", 0, 9); + List idList = (topBookstoreIds == null) ? new ArrayList<>() + : topBookstoreIds.stream() + .map(Long::valueOf) + .collect(Collectors.toList()); + List nameList = new ArrayList<>(); + System.out.println(idList); + if(idList.size() < 10){ + Set lastWeekIds = redisTemplate.opsForZSet() + .reverseRange("trending:bookstores:lastweek", 0, 9); + if (lastWeekIds != null && !lastWeekIds.isEmpty()) { + List lastWeekIdList = lastWeekIds.stream() + .map(Long::valueOf) + .filter(id -> !idList.contains(id)) + .collect(Collectors.toList()); + int need = 10 - idList.size(); + idList.addAll(lastWeekIdList.stream().limit(need).toList()); + } + } + if (idList.size() < 10) { + int need = 10 - idList.size(); + for (long i = 1; idList.size() < 10; i++) { + System.out.println("i = " + i + ", contains = " + idList.contains(i)); + if (!idList.contains(i)) { + idList.add(i); + } + } + } + List idNamePairs = bookstoreRepository.findIdAndNameByIds(idList); + Map idNameMap = idNamePairs.stream() + .collect(Collectors.toMap( + row -> (Long)row[0], + row -> (String)row[1] + )); + + List orderedNames = idList.stream() + .map(idNameMap::get) + .collect(Collectors.toList()); + return orderedNames; + } + } diff --git a/src/main/java/com/capstone/bszip/Member/controller/MemberController.java b/src/main/java/com/capstone/bszip/Member/controller/MemberController.java index 39bd1b6..bcc29f4 100644 --- a/src/main/java/com/capstone/bszip/Member/controller/MemberController.java +++ b/src/main/java/com/capstone/bszip/Member/controller/MemberController.java @@ -1,5 +1,6 @@ package com.capstone.bszip.Member.controller; +import com.capstone.bszip.Member.service.dto.LoginResponse; import com.capstone.bszip.auth.dto.TokenRequest; import com.capstone.bszip.auth.dto.TokenResponse; import com.capstone.bszip.auth.AuthService; @@ -127,13 +128,13 @@ public ResponseEntity add( public ResponseEntity login(@RequestBody LoginRequest loginRequest, HttpServletResponse response){ try{ - TokenResponse tokens = memberService.loginUser(loginRequest); - authService.login(loginRequest.getEmail(), tokens.getRefreshToken()); + LoginResponse loginResponse = memberService.loginUser(loginRequest); + authService.login(loginRequest.getEmail(), loginResponse.getRefreshToken()); return ResponseEntity.ok(SuccessResponse.builder() .result(true) .status(HttpStatus.OK.value()) .message("로그인 성공") - .data(tokens) + .data(loginResponse) .build()); } catch (BadCredentialsException e) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED) diff --git a/src/main/java/com/capstone/bszip/Member/service/MemberService.java b/src/main/java/com/capstone/bszip/Member/service/MemberService.java index c06b1ee..2d00cd4 100644 --- a/src/main/java/com/capstone/bszip/Member/service/MemberService.java +++ b/src/main/java/com/capstone/bszip/Member/service/MemberService.java @@ -3,6 +3,7 @@ import com.capstone.bszip.Member.domain.Member; import com.capstone.bszip.Member.domain.MemberJoinType; import com.capstone.bszip.Member.repository.MemberRepository; +import com.capstone.bszip.Member.service.dto.LoginResponse; import com.capstone.bszip.auth.dto.TokenResponse; import com.capstone.bszip.auth.security.JwtUtil; import com.capstone.bszip.Member.service.dto.LoginRequest; @@ -82,7 +83,7 @@ public void registerMemberNickname(String email,String nickname){ temporaryStorage.remove(email); } @Transactional - public TokenResponse loginUser(LoginRequest loginRequest){ + public LoginResponse loginUser(LoginRequest loginRequest){ Member member = memberRepository.findByEmail(loginRequest.getEmail()) .orElseThrow(() -> new RuntimeException("존재하지 않는 이메일입니다.")); if(member.getMemberJoinType() != DEFAULT){ @@ -90,10 +91,11 @@ public TokenResponse loginUser(LoginRequest loginRequest){ } if(passwordEncoder.matches(loginRequest.getPassword(), member.getPassword())) { String email = member.getEmail(); + String nickname = member.getNickname(); //토큰 생성 String accessToken = JwtUtil.createAccessToken(email); String refreshToken = JwtUtil.createRefreshToken(email); - return new TokenResponse(accessToken, refreshToken); + return new LoginResponse(nickname,accessToken, refreshToken); } else { throw new RuntimeException("일치하지 않는 비밀번호입니다."); diff --git a/src/main/java/com/capstone/bszip/Member/service/dto/LoginResponse.java b/src/main/java/com/capstone/bszip/Member/service/dto/LoginResponse.java new file mode 100644 index 0000000..bb0f5a8 --- /dev/null +++ b/src/main/java/com/capstone/bszip/Member/service/dto/LoginResponse.java @@ -0,0 +1,12 @@ +package com.capstone.bszip.Member.service.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class LoginResponse { + private String nickname; + private String accessToken; + private String refreshToken; +}