diff --git a/.github/workflows/github-actions.yml b/.github/workflows/github-actions.yml index e20e2aae..1ca5a619 100644 --- a/.github/workflows/github-actions.yml +++ b/.github/workflows/github-actions.yml @@ -29,7 +29,7 @@ jobs: echo "${{ secrets.APPLICATION_PROD }}" > ./application.yml # 환경별 yml 파일 생성(2) - dev - - name: Bake application-dev.yml + - name: Make application-dev.yml if: contains(github.ref, 'deploy') run: | cd ./src/main/resources diff --git a/README.md b/README.md index 354f4603..7e289d12 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ EFUB 4기 SWS 3팀 "SongPin" 프로젝트 백엔드 레포지토리입니다. Instagram
- + SongPin WebSite @@ -82,7 +82,7 @@ EFUB 4기 SWS 3팀 "SongPin" 프로젝트 백엔드 레포지토리입니다. | :---: | :---: | :---: | :---: | | | | | | | [@seohyun-lee](https://github.com/seohyun-lee) | [@jud1thDev](https://github.com/jud1thDev) | [@crHwang0822](https://github.com/crHwang0822) | [@gkdudans](https://github.com/gkdudans) | -| [배포] 서버 배포 및 CI/CD 구축
[Place] 장소 검색, 장소 상세정보 조회 기능
[Map] 지도 마커 표시 목적 장소 좌표들 가져오기 기능 (기간&장르 필터링, 유저별, 플레이리스트별)
[Follow] 유저 검색, 유저의 팔로잉/팔로워 목록 조회, 타 유저를 팔로우, 팔로잉 취소/팔로워 삭제 기능
[Alarm] 알림 목록 조회, 구독 기능
[Statistics] 서비스의 종합 통계, 장르별 통계 조회 기능 | [Pin] 핀 생성, 조회, 수정, 삭제 기능
[Spotify] 핀 생성 시 Spotify API를 활용한 노래 검색 기능
[Song] 노래 상세정보 조회, 해당 노래에 대한 전체 핀 목록/내 핀 목록 조회, 노래 검색 기능
[Feed] 타 유저/내 핀 피드 조회, 내 핀 피드 검색, 내 핀피드 캘린더 기능 | [Member] 회원가입, 회원 탈퇴, 프로필 조회, 프로필 편집, Redis를 활용한 비밀번호 재설정 메일 전송 및 비밀번호 변경 기능
[Auth] 스프링 시큐리티, JWT, Redis를 활용한 토큰 (재)발급/인증/인가 구현, 로그인/로그아웃 기능 | [Playlist, PlaylistPin] 플레이리스트 생성, 핀 담기, 메인, 검색, 상세정보 조회, 편집, 삭제, 내 플레이리스트 목록/타 유저 플레이리스트 목록 조회 기능
[Bookmark] 북마크 생성, 취소, 내 북마크 목록 조회 기능
[Home] 최근 생성된 핀 & 장소 조회 기능 | +| [배포] 서버 배포 및 CI/CD 구축
[Place] 장소 검색, 장소 상세정보 조회 기능
[Map] 지도 마커 표시 목적 장소 좌표들 가져오기 기능 (기간&장르 필터링, 유저별, 플레이리스트별)
[Follow] 유저 검색, 유저의 팔로잉/팔로워 목록 조회, 타 유저를 팔로우, 팔로잉 취소/팔로워 삭제 기능
[Alarm] SSE 알림 구독, 알림 목록 조회 기능
[Statistics] 서비스의 종합 통계, 장르별 통계 조회 기능 | [Pin] 핀 생성, 조회, 수정, 삭제 기능
[Spotify] 핀 생성 시 Spotify API를 활용한 노래 검색 기능
[Song] 노래 상세정보 조회, 해당 노래에 대한 전체 핀 목록/내 핀 목록 조회, 노래 검색 기능
[Feed] 타 유저/내 핀 피드 조회, 내 핀 피드 검색, 내 핀피드 캘린더 기능 | [Member] 회원가입, 회원 탈퇴, 프로필 조회, 프로필 편집, Redis를 활용한 비밀번호 재설정 메일 전송 및 비밀번호 변경 기능
[Auth] 스프링 시큐리티, JWT, Redis를 활용한 토큰 (재)발급/인증/인가 구현, 로그인/로그아웃 기능 | [Playlist, PlaylistPin] 플레이리스트 생성, 핀 담기, 메인, 검색, 상세정보 조회, 편집, 삭제, 내 플레이리스트 목록/타 유저 플레이리스트 목록 조회 기능
[Bookmark] 북마크 생성, 취소, 내 북마크 목록 조회 기능
[Home] 최근 생성된 핀 & 장소 조회 기능 |
diff --git a/src/main/java/sws/songpin/domain/alarm/service/EmitterService.java b/src/main/java/sws/songpin/domain/alarm/service/EmitterService.java index 71323700..eff68ad5 100644 --- a/src/main/java/sws/songpin/domain/alarm/service/EmitterService.java +++ b/src/main/java/sws/songpin/domain/alarm/service/EmitterService.java @@ -3,7 +3,6 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import sws.songpin.domain.alarm.dto.ssedata.AlarmDefaultDataDto; import sws.songpin.domain.alarm.repository.AlarmRepository; @@ -12,10 +11,10 @@ import sws.songpin.domain.member.service.MemberService; import java.io.IOException; +import java.util.Optional; @Slf4j @Service -@Transactional @RequiredArgsConstructor public class EmitterService { private final EmitterRepository emitterRepository; @@ -26,7 +25,12 @@ public class EmitterService { public SseEmitter subscribe() { Member member = memberService.getCurrentMember(); - SseEmitter emitter = registerEmitter(member.getMemberId()); + Long memberId = member.getMemberId(); + + // 이미 존재하는 Emitter가 있는지 확인 + SseEmitter emitter = Optional.ofNullable(emitterRepository.get(memberId)) + .orElseGet(() -> registerEmitter(memberId)); + sendToClientIfNewAlarmExists(member); return emitter; } diff --git a/src/main/java/sws/songpin/domain/bookmark/repository/BookmarkRepository.java b/src/main/java/sws/songpin/domain/bookmark/repository/BookmarkRepository.java index fa0c78f9..b857480f 100644 --- a/src/main/java/sws/songpin/domain/bookmark/repository/BookmarkRepository.java +++ b/src/main/java/sws/songpin/domain/bookmark/repository/BookmarkRepository.java @@ -11,7 +11,8 @@ import java.util.Optional; public interface BookmarkRepository extends JpaRepository { - Optional findByPlaylistAndMember(Playlist playlist, Member member); + boolean existsByPlaylistAndMember(Playlist playlist, Member member); + List findAllByPlaylistAndMember(Playlist playlist, Member member); @Query("SELECT b FROM Bookmark b WHERE b.member = :member ORDER BY b.bookmarkId DESC") List findAllByMember(Member member); void deleteAllByMember(Member member); diff --git a/src/main/java/sws/songpin/domain/bookmark/service/BookmarkService.java b/src/main/java/sws/songpin/domain/bookmark/service/BookmarkService.java index baea3efd..c59e4275 100644 --- a/src/main/java/sws/songpin/domain/bookmark/service/BookmarkService.java +++ b/src/main/java/sws/songpin/domain/bookmark/service/BookmarkService.java @@ -37,18 +37,17 @@ public boolean changeBookmark(BookmarkRequestDto requestDto) { if (!member.equals(playlist.getCreator()) && playlist.getVisibility() == Visibility.PRIVATE) { throw new CustomException(ErrorCode.UNAUTHORIZED_REQUEST); } - Optional bookmark = getBookmarkByPlaylistAndMember(playlist, member); - if(bookmark.isPresent()){ - bookmark.ifPresent(bookmarkRepository::delete); - return false; - } - else{ + List bookmarks = bookmarkRepository.findAllByPlaylistAndMember(playlist, member); + if (bookmarks.isEmpty()) { Bookmark newBookmark = Bookmark.builder() .member(member) .playlist(playlist) .build(); bookmarkRepository.save(newBookmark); return true; + } else { // 북마크가 존재하면 삭제 + bookmarkRepository.deleteAll(bookmarks); + return false; } } @@ -65,11 +64,6 @@ public BookmarkListResponseDto getAllBookmarks() { return BookmarkListResponseDto.from(bookmarkList); } - @Transactional(readOnly = true) - public Optional getBookmarkByPlaylistAndMember(Playlist playlist, Member member) { - return bookmarkRepository.findByPlaylistAndMember(playlist, member); - } - public void deleteAllBookmarksOfMember(Member member){ bookmarkRepository.deleteAllByMember(member); } diff --git a/src/main/java/sws/songpin/domain/follow/repository/FollowRepository.java b/src/main/java/sws/songpin/domain/follow/repository/FollowRepository.java index d7d34b0a..98356fc1 100644 --- a/src/main/java/sws/songpin/domain/follow/repository/FollowRepository.java +++ b/src/main/java/sws/songpin/domain/follow/repository/FollowRepository.java @@ -12,7 +12,7 @@ public interface FollowRepository extends JpaRepository { List findAllByFollower(Member follower); Long countByFollowing(Member following); Long countByFollower(Member follower); - Optional findByFollowerAndFollowing(Member follower, Member following); + List findAllByFollowerAndFollowing(Member follower, Member following); void deleteAllByFollower(Member member); void deleteAllByFollowing(Member member); } \ No newline at end of file diff --git a/src/main/java/sws/songpin/domain/follow/service/FollowService.java b/src/main/java/sws/songpin/domain/follow/service/FollowService.java index efda66bf..9a59b2b6 100644 --- a/src/main/java/sws/songpin/domain/follow/service/FollowService.java +++ b/src/main/java/sws/songpin/domain/follow/service/FollowService.java @@ -1,6 +1,7 @@ package sws.songpin.domain.follow.service; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import sws.songpin.domain.alarm.service.AlarmService; @@ -20,6 +21,7 @@ import java.util.Optional; import java.util.stream.Collectors; +@Slf4j @Service @Transactional @RequiredArgsConstructor @@ -36,14 +38,15 @@ public boolean createOrDeleteFollow(FollowRequestDto requestDto) { throw new CustomException(ErrorCode.FOLLOW_BAD_REQUEST); } - Optional followOptional = followRepository.findByFollowerAndFollowing(currentMember, targetMember); - if (followOptional.isPresent()) { // 팔로우가 존재하면 삭제 - followRepository.delete(followOptional.get()); - return false; - } else { // 팔로우 추가 + // 1개가 존재하기를 기대하지만, 멀티 스레드 이슈로 여러개가 입력될 수 있음 + List follows = followRepository.findAllByFollowerAndFollowing(currentMember, targetMember); + if (follows.isEmpty()) { // 팔로우 추가 followRepository.save(FollowRequestDto.toEntity(currentMember, targetMember)); alarmService.createFollowAlarm(currentMember, targetMember); return true; + } else { // 팔로우가 존재하면 삭제 + followRepository.deleteAll(follows); + return false; } } @@ -55,33 +58,38 @@ public void deleteFollower(FollowRequestDto requestDto){ throw new CustomException(ErrorCode.FOLLOW_BAD_REQUEST); } - Optional followOptional = followRepository.findByFollowerAndFollowing(targetMember, currentMember); - if (followOptional.isPresent()) { // 팔로우가 존재하면 삭제 - followRepository.delete(followOptional.get()); - } else { + // 1개가 존재하기를 기대하지만, 멀티 스레드 이슈로 여러개가 입력될 수 있음 + List follows = followRepository.findAllByFollowerAndFollowing(currentMember, targetMember); + if (follows.isEmpty()) { throw new CustomException(ErrorCode.FOLLOW_NOT_FOUND); + } else { // 팔로우가 존재하면 삭제 + followRepository.deleteAll(follows); } } + @Transactional(readOnly = true) public Boolean checkIfFollowing(Member targetMember){ Member currentMember = memberService.getCurrentMember(); return checkIfFollowExists(currentMember, targetMember); } + @Transactional(readOnly = true) public Boolean checkIfFollower(Member targetMember) { Member currentMember = memberService.getCurrentMember(); return checkIfFollowExists(targetMember, currentMember); } + @Transactional(readOnly = true) public Boolean checkIfFollowExists(Member follower, Member following) { if (follower.equals(following)) { return null; } - Optional followOptional = followRepository.findByFollowerAndFollowing(follower, following); - if (followOptional.isPresent()) { - return true; - } else { + // 1개가 존재하기를 기대하지만, 멀티 스레드 이슈로 여러개가 입력될 수 있음 + List follows = followRepository.findAllByFollowerAndFollowing(follower, following); + if (follows.isEmpty()) { return false; + } else { + return true; } } diff --git a/src/main/java/sws/songpin/domain/genre/service/GenreService.java b/src/main/java/sws/songpin/domain/genre/service/GenreService.java index 586a6ac8..af812634 100644 --- a/src/main/java/sws/songpin/domain/genre/service/GenreService.java +++ b/src/main/java/sws/songpin/domain/genre/service/GenreService.java @@ -11,7 +11,6 @@ import sws.songpin.global.exception.ErrorCode; @Service -@Transactional @RequiredArgsConstructor public class GenreService { private final GenreRepository genreRepository; @@ -29,6 +28,7 @@ public void initGenres() { } } + @Transactional(readOnly = true) public Genre getGenreByGenreName(GenreName genreName) { return genreRepository.findByGenreName(genreName) .orElseThrow(() -> new CustomException(ErrorCode.GENRE_NOT_FOUND)); diff --git a/src/main/java/sws/songpin/domain/member/service/HomeService.java b/src/main/java/sws/songpin/domain/member/service/HomeService.java index c4dbe8a1..338b9784 100644 --- a/src/main/java/sws/songpin/domain/member/service/HomeService.java +++ b/src/main/java/sws/songpin/domain/member/service/HomeService.java @@ -16,7 +16,6 @@ import java.util.stream.Collectors; @Service -@Transactional @RequiredArgsConstructor public class HomeService { private final MemberService memberService; @@ -30,7 +29,7 @@ public HomeResponseDto getHome() { List places = placeRepository.findTop3ByOrderByPlaceIdDesc(); // pinList List pinList = pins.stream() - .map(pin -> PinBasicUnitDto.from(pin, pin.getCreator().equals(currentMember))) + .map(pin -> PinBasicUnitDto.from(pin, pin.getSong(), pin.getPlace(), pin.getGenre().getGenreName(), pin.getCreator().equals(currentMember))) .collect(Collectors.toList()); // placeList List placeList = places.stream() diff --git a/src/main/java/sws/songpin/domain/member/service/MemberService.java b/src/main/java/sws/songpin/domain/member/service/MemberService.java index ed7550bc..63051c07 100644 --- a/src/main/java/sws/songpin/domain/member/service/MemberService.java +++ b/src/main/java/sws/songpin/domain/member/service/MemberService.java @@ -1,7 +1,6 @@ package sws.songpin.domain.member.service; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.security.core.Authentication; @@ -20,19 +19,16 @@ import static sws.songpin.global.common.EscapeSpecialCharactersService.escapeSpecialCharacters; -@Slf4j @Service -@Transactional @RequiredArgsConstructor public class MemberService { private final MemberRepository memberRepository; // 유저 검색 - @Transactional(readOnly = true) public MemberSearchResponseDto searchMembers(String keyword, Pageable pageable) { // 키워드의 이스케이프 처리 String escapedWord = escapeSpecialCharacters(keyword); - Page memberPage = memberRepository.findAllByHandleContainingOrNicknameContaining(escapedWord, pageable); + Page memberPage = getSearchedMemberPage(escapedWord, pageable); Long currentMemberId = getCurrentMember().getMemberId(); // Page를 Page로 변환 @@ -43,6 +39,11 @@ public MemberSearchResponseDto searchMembers(String keyword, Pageable pageable) return MemberSearchResponseDto.from(memberUnitDtoPage); } + @Transactional(readOnly = true) + public Page getSearchedMemberPage(String escapedWord, Pageable pageable) { + return memberRepository.findAllByHandleContainingOrNicknameContaining(escapedWord, pageable); + } + @Transactional(readOnly = true) public Member getCurrentMember() throws CustomException { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); @@ -103,6 +104,7 @@ public boolean checkMemberExistsByHandle(String handle){ return memberRepository.existsByHandle(handle); } + @Transactional public Member saveMember(Member member){ return memberRepository.save(member); } diff --git a/src/main/java/sws/songpin/domain/pin/dto/response/PinBasicUnitDto.java b/src/main/java/sws/songpin/domain/pin/dto/response/PinBasicUnitDto.java index a3fc150b..d4e4e7cb 100644 --- a/src/main/java/sws/songpin/domain/pin/dto/response/PinBasicUnitDto.java +++ b/src/main/java/sws/songpin/domain/pin/dto/response/PinBasicUnitDto.java @@ -2,7 +2,9 @@ import sws.songpin.domain.genre.entity.GenreName; import sws.songpin.domain.pin.entity.Pin; +import sws.songpin.domain.place.entity.Place; import sws.songpin.domain.song.dto.response.SongInfoDto; +import sws.songpin.domain.song.entity.Song; import java.time.LocalDate; @@ -18,15 +20,15 @@ public record PinBasicUnitDto( Boolean isMine ) { - public static PinBasicUnitDto from(Pin pin, Boolean isMine) { + public static PinBasicUnitDto from(Pin pin, Song song, Place place, GenreName genreName, boolean isMine) { return new PinBasicUnitDto( pin.getPinId(), - SongInfoDto.from(pin.getSong()), + SongInfoDto.from(song), pin.getListenedDate(), - pin.getPlace().getPlaceName(), - pin.getPlace().getLatitude( ), - pin.getPlace().getLongitude(), - pin.getGenre().getGenreName(), + place.getPlaceName(), + place.getLatitude(), + place.getLongitude(), + genreName, isMine ); } diff --git a/src/main/java/sws/songpin/domain/pin/dto/response/PinExistingInfoResponseDto.java b/src/main/java/sws/songpin/domain/pin/dto/response/PinExistingInfoResponseDto.java index 38f15e65..ad36440b 100644 --- a/src/main/java/sws/songpin/domain/pin/dto/response/PinExistingInfoResponseDto.java +++ b/src/main/java/sws/songpin/domain/pin/dto/response/PinExistingInfoResponseDto.java @@ -16,14 +16,14 @@ public record PinExistingInfoResponseDto( String memo, Visibility visibility ) { - public static PinExistingInfoResponseDto from(Pin pin) { + public static PinExistingInfoResponseDto from(Pin pin, GenreName genreName) { return new PinExistingInfoResponseDto( pin.getSong().getImgPath(), pin.getSong().getTitle(), pin.getSong().getArtist(), pin.getListenedDate(), pin.getPlace().getPlaceName(), - pin.getGenre().getGenreName(), + genreName, pin.getMemo(), pin.getVisibility() ); diff --git a/src/main/java/sws/songpin/domain/pin/dto/response/PinFeedUnitDto.java b/src/main/java/sws/songpin/domain/pin/dto/response/PinFeedUnitDto.java index 69811682..2a28d3c6 100644 --- a/src/main/java/sws/songpin/domain/pin/dto/response/PinFeedUnitDto.java +++ b/src/main/java/sws/songpin/domain/pin/dto/response/PinFeedUnitDto.java @@ -19,7 +19,7 @@ public record PinFeedUnitDto( Boolean isMine ) { - public static PinFeedUnitDto from(Pin pin, Boolean isMine) { + public static PinFeedUnitDto from(Pin pin, GenreName genreName, Boolean isMine) { return new PinFeedUnitDto( pin.getPinId(), SongInfoDto.from(pin.getSong()), @@ -27,7 +27,7 @@ public static PinFeedUnitDto from(Pin pin, Boolean isMine) { pin.getPlace().getPlaceName(), pin.getPlace().getLatitude(), pin.getPlace().getLongitude(), - pin.getGenre().getGenreName(), + genreName, pin.getMemo(), pin.getVisibility(), isMine diff --git a/src/main/java/sws/songpin/domain/pin/repository/PinRepository.java b/src/main/java/sws/songpin/domain/pin/repository/PinRepository.java index ce9d84ce..f5acdeb3 100644 --- a/src/main/java/sws/songpin/domain/pin/repository/PinRepository.java +++ b/src/main/java/sws/songpin/domain/pin/repository/PinRepository.java @@ -36,8 +36,8 @@ public interface PinRepository extends JpaRepository { @Query("SELECT p FROM Pin p WHERE p.creator = :creator AND YEAR(p.listenedDate) = :year AND MONTH(p.listenedDate) = :month") List findAllByCreatorAndDate(@Param("creator") Member creator, @Param("year") int year, @Param("month") int month); - @Query("SELECT COUNT(p) FROM Pin p WHERE YEAR(p.listenedDate) = :currentYear") - long countByListenedDateYear(@Param("currentYear") int currentYear); + @Query("SELECT COUNT(p) FROM Pin p WHERE YEAR(p.createdTime) = :currentYear") + long countByCreatedTimeYear(@Param("currentYear") int currentYear); @Query("SELECT p.genre.genreName, COUNT(p) FROM Pin p GROUP BY p.genre ORDER BY COUNT(p) DESC") List findMostPopularGenreName(); diff --git a/src/main/java/sws/songpin/domain/pin/service/PinService.java b/src/main/java/sws/songpin/domain/pin/service/PinService.java index dce82a9d..e7d8754e 100644 --- a/src/main/java/sws/songpin/domain/pin/service/PinService.java +++ b/src/main/java/sws/songpin/domain/pin/service/PinService.java @@ -32,6 +32,7 @@ import java.time.LocalDate; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.stream.Collectors; @@ -104,11 +105,22 @@ public void updateSongAvgGenreName(Song song) { List genres = pinRepository.findBySong(song).stream() .map(Pin::getGenre) .collect(Collectors.toList()); - Optional avgGenreName = songService.calculateAvgGenreName(genres); + Optional avgGenreName = calculateAvgGenreNameOfSong(genres); avgGenreName.ifPresent(song::setAvgGenreName); songRepository.save(song); } + public Optional calculateAvgGenreNameOfSong(List genres) { + Map genreCount = genres.stream() + .collect(Collectors.groupingBy(Genre::getGenreName, Collectors.counting())); + + return genreCount.entrySet().stream() + .sorted(Map.Entry.comparingByValue().reversed() + .thenComparing(Map.Entry::getKey)) + .map(Map.Entry::getKey) + .findFirst(); + } + // 현재 로그인된 사용자가 핀의 생성자인지 확인 @Transactional(readOnly = true) public Pin validatePinCreator(Long pinId) { @@ -138,7 +150,7 @@ public SongDetailsPinListResponseDto getPinsForSong(Long songId, boolean onlyMyP Page songDetailsPinPage = pinPage.map(pin -> { Boolean isMine = pin.getCreator().getMemberId().equals(currentMemberId); String memo = getMemoContent(pin.getMemo(), pin.getVisibility(), isMine); - return SongDetailsPinDto.from(pin, memo, isMine); + return SongDetailsPinDto.from(pin, pin.getCreator(), pin.getPlace(), memo, isMine); }); return SongDetailsPinListResponseDto.from(songDetailsPinPage); } @@ -206,7 +218,7 @@ public PinBasicListResponseDto getMyPinFeedForMonth(int year, int month) { Member currentMember = memberService.getCurrentMember(); List pins = pinRepository.findAllByCreatorAndDate(currentMember,year, month); List pinList = pins.stream() - .map(pin -> PinBasicUnitDto.from(pin, true)) + .map(pin -> PinBasicUnitDto.from(pin, pin.getSong(), pin.getPlace(), pin.getGenre().getGenreName(), true)) .collect(Collectors.toList()); return new PinBasicListResponseDto(pinList); } @@ -254,7 +266,7 @@ public Pin getPinById(Long pinId) { @Transactional(readOnly = true) public PinExistingInfoResponseDto getPinInfo(Long pinId) { Pin pin = getPinById(pinId); - return PinExistingInfoResponseDto.from(pin); + return PinExistingInfoResponseDto.from(pin, pin.getGenre().getGenreName()); } } diff --git a/src/main/java/sws/songpin/domain/place/service/MapService.java b/src/main/java/sws/songpin/domain/place/service/MapService.java index 8aefebe9..2896ab58 100644 --- a/src/main/java/sws/songpin/domain/place/service/MapService.java +++ b/src/main/java/sws/songpin/domain/place/service/MapService.java @@ -25,16 +25,18 @@ @Slf4j @Service -@Transactional(readOnly = true) // Transaction 모두 읽기 전용 @RequiredArgsConstructor public class MapService { private final MapPlaceRepository mapPlaceRepository; private final MemberService memberService; + // 지도에 장소 좌표를 최대 300개 띄우도록 함 + private static final Pageable pageable = PageRequest.of(0, 300); + // 장소 좌표들 가져오기-전체 기간 & 장르 필터링 public MapPlaceFetchResponseDto getMapPlacesWithinBoundsByEntirePeriod(MapFetchEntirePeriodRequestDto requestDto) { - List genreNameList = getSelectedGenreNames(requestDto.genreNameFilters()); + List genreNameList = getSelectedGenreNames(requestDto.genreNameFilters()); Slice dtoSlice = getMapPlaceSlicesWithinBounds(requestDto.boundCoords(), genreNameList); return MapPlaceFetchResponseDto.from(dtoSlice); } @@ -62,14 +64,14 @@ public MapPlaceFetchResponseDto getMapPlacesWithinBoundsByCustomPeriod(MapFetchC } // 날짜 범위 조건 걸지 않는 경우 + @Transactional(readOnly = true) public Slice getMapPlaceSlicesWithinBounds(MapBoundCoordsDto dto, List genreNameList) { - Pageable pageable = getCustomPageableForMap(); return mapPlaceRepository.findPlacesWithLatestPinsByGenre(dto.swLat(), dto.neLat(), dto.swLng(), dto.neLng(), genreNameList, pageable); } // 날짜 범위 조건 거는 경우 + @Transactional(readOnly = true) public Slice getMapPlaceSlicesWithinBoundsAndDateRange(MapBoundCoordsDto dto, List genreNameList, LocalDate startDate, LocalDate endDate) { - Pageable pageable = getCustomPageableForMap(); return mapPlaceRepository.findPlacesWithLatestPinsByGenreAndDateRange(dto.swLat(), dto.neLat(), dto.swLng(), dto.neLng(), genreNameList, startDate, endDate, pageable); } @@ -84,23 +86,24 @@ private List getSelectedGenreNames(List genreNameFilters) //// 유저로 필터링 // 유저가 핀을 등록한 장소 좌표들 가져오기 public MapPlaceFetchResponseDto getMapPlacesOfMember(String handle) { + return MapPlaceFetchResponseDto.from(getMapPlaceSlicesOfMember(handle)); + } + + @Transactional(readOnly = true) + public Slice getMapPlaceSlicesOfMember(String handle) { Long memberId = memberService.getActiveMemberByHandle(handle).getMemberId(); - Pageable pageable = getCustomPageableForMap(); - Slice dtoSlice = mapPlaceRepository.findPlacesWithLatestPinsByCreator(memberId, pageable); - return MapPlaceFetchResponseDto.from(dtoSlice); + return mapPlaceRepository.findPlacesWithLatestPinsByCreator(memberId, pageable); } + //// 플레이리스트 필터링 // 플레이리스트의 핀들 장소 좌표들 가져오기 public MapPlaceFetchResponseDto getMapPlacesOfPlaylist(Long playlistId) { - Pageable pageable = getCustomPageableForMap(); - Slice dtoSlice = mapPlaceRepository.findPlacesWithHighPinIndexPlaylistPinsByPlaylist(playlistId, pageable); - return MapPlaceFetchResponseDto.from(dtoSlice); + return MapPlaceFetchResponseDto.from(getMapPlaceSlicesOfPlaylist(playlistId)); } - - // 지도에 장소 좌표를 최대 300개 띄우도록 함 - private Pageable getCustomPageableForMap() { - return PageRequest.of(0, 300); + @Transactional(readOnly = true) // Transaction 모두 읽기 전용 + public Slice getMapPlaceSlicesOfPlaylist(Long playlistId) { + return mapPlaceRepository.findPlacesWithHighPinIndexPlaylistPinsByPlaylist(playlistId, pageable); } } \ No newline at end of file diff --git a/src/main/java/sws/songpin/domain/place/service/PlaceService.java b/src/main/java/sws/songpin/domain/place/service/PlaceService.java index 6fb12b60..6577138b 100644 --- a/src/main/java/sws/songpin/domain/place/service/PlaceService.java +++ b/src/main/java/sws/songpin/domain/place/service/PlaceService.java @@ -30,13 +30,13 @@ @Slf4j @Service -@Transactional @RequiredArgsConstructor public class PlaceService { private final PlaceRepository placeRepository; // 장소 상세보기 + @Transactional(readOnly = true) public PlaceDetailsResponseDto getPlaceDetails(Long placeId) { // 해당 Place의 Pin들을 가져와 Song끼리 grouping Place place = getPlaceById(placeId); @@ -61,14 +61,7 @@ public PlaceSearchResponseDto searchPlaces(String keyword, SortBy sortBy, Pageab // 키워드의 이스케이프 처리 및 띄어쓰기 제거 String escapedWord = escapeSpecialCharacters(keyword); String keywordNoSpaces = escapedWord.replace(" ", ""); - - Page placePage; - switch (sortBy) { - case COUNT -> placePage = placeRepository.findAllByPlaceNameContainingIgnoreSpacesOrderByCount(keywordNoSpaces, pageable); - case NEWEST -> placePage = placeRepository.findAllByPlaceNameContainingIgnoreSpacesOrderByNewest(keywordNoSpaces, pageable); - case ACCURACY -> placePage = placeRepository.findAllByPlaceNameContainingIgnoreSpacesOrderByAccuracy(keywordNoSpaces, pageable); - default -> throw new CustomException(ErrorCode.INVALID_ENUM_VALUE); - } + Page placePage = getSearchedPlacePage(sortBy, keywordNoSpaces, pageable); // Page를 Page로 변환 Page placeUnitPage = placePage.map(objects -> { @@ -82,12 +75,26 @@ public PlaceSearchResponseDto searchPlaces(String keyword, SortBy sortBy, Pageab return PlaceSearchResponseDto.from(placeUnitPage); } + @Transactional(readOnly = true) + public Page getSearchedPlacePage(SortBy sortBy, String keywordNoSpaces, Pageable pageable) { + Page placePage; + switch (sortBy) { + case COUNT -> placePage = placeRepository.findAllByPlaceNameContainingIgnoreSpacesOrderByCount(keywordNoSpaces, pageable); + case NEWEST -> placePage = placeRepository.findAllByPlaceNameContainingIgnoreSpacesOrderByNewest(keywordNoSpaces, pageable); + case ACCURACY -> placePage = placeRepository.findAllByPlaceNameContainingIgnoreSpacesOrderByAccuracy(keywordNoSpaces, pageable); + default -> throw new CustomException(ErrorCode.INVALID_ENUM_VALUE); + } + return placePage; + } + // Place를 providerAddressId로 찾아 가져오거나 생성 + @Transactional public Place getOrCreatePlace(PlaceAddRequestDto placeRequestDto) { return getPlaceByProviderAddressId(placeRequestDto.providerAddressId()) .orElseGet(() -> createPlace(placeRequestDto)); } + @Transactional public Place createPlace(PlaceAddRequestDto placeRequestDto) { Place place = placeRequestDto.toEntity(); return placeRepository.save(place); diff --git a/src/main/java/sws/songpin/domain/playlist/dto/response/PlaylistDetailsResponseDto.java b/src/main/java/sws/songpin/domain/playlist/dto/response/PlaylistDetailsResponseDto.java index 54fab24c..0d699cc5 100644 --- a/src/main/java/sws/songpin/domain/playlist/dto/response/PlaylistDetailsResponseDto.java +++ b/src/main/java/sws/songpin/domain/playlist/dto/response/PlaylistDetailsResponseDto.java @@ -1,5 +1,6 @@ package sws.songpin.domain.playlist.dto.response; +import sws.songpin.domain.member.entity.Member; import sws.songpin.domain.model.Visibility; import sws.songpin.domain.playlist.entity.Playlist; @@ -18,12 +19,12 @@ public record PlaylistDetailsResponseDto( Boolean isBookmarked, List pinList) { - public static PlaylistDetailsResponseDto from(Playlist playlist, List imgPathList, List pinList, Boolean isMine, Boolean isBookmarked) { + public static PlaylistDetailsResponseDto from(Playlist playlist, Member creator, List imgPathList, List pinList, Boolean isMine, Boolean isBookmarked) { return new PlaylistDetailsResponseDto( isMine, playlist.getPlaylistName(), - playlist.getCreator().getHandle(), - playlist.getCreator().getNickname(), + creator.getHandle(), + creator.getNickname(), pinList.size(), playlist.getModifiedTime().toLocalDate(), playlist.getVisibility(), diff --git a/src/main/java/sws/songpin/domain/playlist/dto/response/PlaylistPinUnitDto.java b/src/main/java/sws/songpin/domain/playlist/dto/response/PlaylistPinUnitDto.java index 9d0fbed8..c8ff6478 100644 --- a/src/main/java/sws/songpin/domain/playlist/dto/response/PlaylistPinUnitDto.java +++ b/src/main/java/sws/songpin/domain/playlist/dto/response/PlaylistPinUnitDto.java @@ -1,8 +1,12 @@ package sws.songpin.domain.playlist.dto.response; import sws.songpin.domain.genre.entity.GenreName; +import sws.songpin.domain.pin.entity.Pin; +import sws.songpin.domain.place.entity.Place; import sws.songpin.domain.playlistpin.entity.PlaylistPin; import sws.songpin.domain.song.dto.response.SongInfoDto; +import sws.songpin.domain.song.entity.Song; + import java.time.LocalDate; public record PlaylistPinUnitDto( @@ -16,18 +20,18 @@ public record PlaylistPinUnitDto( GenreName genreName, int pinIndex ) { - public static PlaylistPinUnitDto from (PlaylistPin playlistPin) { - SongInfoDto songInfo = SongInfoDto.from(playlistPin.getPin().getSong()); + public static PlaylistPinUnitDto from (PlaylistPin playlistPin, Pin pin, Song song, Place place, GenreName genreName) { + SongInfoDto songInfo = SongInfoDto.from(song); return new PlaylistPinUnitDto( - playlistPin.getPlaylistPinId(), - playlistPin.getPin().getPinId(), - songInfo, - playlistPin.getPin().getListenedDate(), - playlistPin.getPin().getPlace().getPlaceName(), - playlistPin.getPin().getPlace().getLatitude(), - playlistPin.getPin().getPlace().getLongitude(), - playlistPin.getPin().getGenre().getGenreName(), - playlistPin.getPinIndex() + playlistPin.getPlaylistPinId(), + pin.getPinId(), + songInfo, + pin.getListenedDate(), + place.getPlaceName(), + place.getLatitude(), + place.getLongitude(), + genreName, + playlistPin.getPinIndex() ); } } diff --git a/src/main/java/sws/songpin/domain/playlist/dto/response/PlaylistUnitDto.java b/src/main/java/sws/songpin/domain/playlist/dto/response/PlaylistUnitDto.java index 25ea94c9..ab000837 100644 --- a/src/main/java/sws/songpin/domain/playlist/dto/response/PlaylistUnitDto.java +++ b/src/main/java/sws/songpin/domain/playlist/dto/response/PlaylistUnitDto.java @@ -1,5 +1,6 @@ package sws.songpin.domain.playlist.dto.response; +import sws.songpin.domain.member.entity.Member; import sws.songpin.domain.model.Visibility; import sws.songpin.domain.playlist.entity.Playlist; @@ -17,12 +18,12 @@ public record PlaylistUnitDto( List imgPathList, Boolean isBookmarked ) { - public static PlaylistUnitDto from (Playlist playlist, List imgPathList, boolean isBookmarked) { + public static PlaylistUnitDto from (Playlist playlist, Member creator, int pinCount, List imgPathList, boolean isBookmarked) { return new PlaylistUnitDto( playlist.getPlaylistId(), playlist.getPlaylistName(), - playlist.getCreator().getNickname(), - playlist.getPlaylistPins().size(), + creator.getNickname(), + pinCount, playlist.getModifiedTime(), playlist.getVisibility(), imgPathList, diff --git a/src/main/java/sws/songpin/domain/playlist/service/PlaylistService.java b/src/main/java/sws/songpin/domain/playlist/service/PlaylistService.java index 7c98b73b..0649667a 100644 --- a/src/main/java/sws/songpin/domain/playlist/service/PlaylistService.java +++ b/src/main/java/sws/songpin/domain/playlist/service/PlaylistService.java @@ -13,6 +13,7 @@ import sws.songpin.domain.member.service.MemberService; import sws.songpin.domain.model.SortBy; import sws.songpin.domain.model.Visibility; +import sws.songpin.domain.pin.entity.Pin; import sws.songpin.domain.playlist.dto.response.*; import sws.songpin.domain.playlist.dto.request.PlaylistAddRequestDto; import sws.songpin.domain.playlist.dto.request.PlaylistUpdateRequestDto; @@ -72,14 +73,15 @@ public PlaylistDetailsResponseDto getPlaylist(Long playlistId) { playlistPins.stream() .sorted(Comparator.comparingInt(PlaylistPin::getPinIndex).reversed()) .forEach(playlistPin -> { - pinList.add(PlaylistPinUnitDto.from(playlistPin)); + Pin pin = playlistPin.getPin(); + pinList.add(PlaylistPinUnitDto.from(playlistPin, pin, pin.getSong(), pin.getPlace(), pin.getGenre().getGenreName())); if (imgPathList.size() < 3) { imgPathList.add(playlistPin.getPin().getSong().getImgPath()); } }); // isBookmarked boolean isBookmarked = isPlaylistBookmarkedByMember(playlist, currentMember); - return PlaylistDetailsResponseDto.from(playlist, imgPathList, pinList, isMine, isBookmarked); + return PlaylistDetailsResponseDto.from(playlist, playlist.getCreator(), imgPathList, pinList, isMine, isBookmarked); } // 플레이리스트 편집 @@ -228,12 +230,12 @@ public PlaylistUnitDto convertToPlaylistUnitDto(Playlist playlist, Member curren .limit(3) .collect(Collectors.toList()); boolean isBookmarked = isPlaylistBookmarkedByMember(playlist, currentMember); - return PlaylistUnitDto.from(playlist, imgPathList, isBookmarked); + return PlaylistUnitDto.from(playlist, playlist.getCreator(), playlist.getPlaylistPins().size(), imgPathList, isBookmarked); } @Transactional(readOnly = true) public boolean isPlaylistBookmarkedByMember(Playlist playlist, Member member) { - return bookmarkRepository.findByPlaylistAndMember(playlist, member).isPresent(); + return bookmarkRepository.existsByPlaylistAndMember(playlist, member); } public void deleteAllPlaylistsOfMember(Member member) { diff --git a/src/main/java/sws/songpin/domain/song/dto/response/SongDetailsPinDto.java b/src/main/java/sws/songpin/domain/song/dto/response/SongDetailsPinDto.java index 1cdfb70e..cd21a1e9 100644 --- a/src/main/java/sws/songpin/domain/song/dto/response/SongDetailsPinDto.java +++ b/src/main/java/sws/songpin/domain/song/dto/response/SongDetailsPinDto.java @@ -1,8 +1,13 @@ package sws.songpin.domain.song.dto.response; +import sws.songpin.domain.genre.entity.GenreName; +import sws.songpin.domain.member.entity.Member; import sws.songpin.domain.member.entity.Status; +import sws.songpin.domain.pin.dto.response.PinBasicUnitDto; import sws.songpin.domain.pin.entity.Pin; import sws.songpin.domain.model.Visibility; +import sws.songpin.domain.place.entity.Place; +import sws.songpin.domain.song.entity.Song; import java.time.LocalDate; @@ -17,20 +22,20 @@ public record SongDetailsPinDto( String placeName, double latitude, double longitude, - Boolean isMine) { - - public static SongDetailsPinDto from(Pin pin, String memo, Boolean isMine) { + Boolean isMine +) { + public static SongDetailsPinDto from(Pin pin, Member creator, Place place, String memo, Boolean isMine) { return new SongDetailsPinDto( pin.getPinId(), - pin.getCreator().getHandle(), - pin.getCreator().getNickname(), - pin.getCreator().getStatus(), + creator.getHandle(), + creator.getNickname(), + creator.getStatus(), pin.getListenedDate(), memo, pin.getVisibility(), - pin.getPlace().getPlaceName(), - pin.getPlace().getLatitude( ), - pin.getPlace().getLongitude(), + place.getPlaceName(), + place.getLatitude( ), + place.getLongitude(), isMine ); } diff --git a/src/main/java/sws/songpin/domain/song/service/SongService.java b/src/main/java/sws/songpin/domain/song/service/SongService.java index 2cfd4142..80fd677f 100644 --- a/src/main/java/sws/songpin/domain/song/service/SongService.java +++ b/src/main/java/sws/songpin/domain/song/service/SongService.java @@ -5,7 +5,6 @@ import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import sws.songpin.domain.genre.entity.Genre; import sws.songpin.domain.genre.entity.GenreName; import sws.songpin.domain.member.entity.Member; import sws.songpin.domain.member.service.MemberService; @@ -23,7 +22,6 @@ import java.time.LocalDate; import java.util.List; -import java.util.Map; import java.util.Optional; import java.util.stream.Collectors; @@ -31,7 +29,6 @@ @Service @RequiredArgsConstructor -@Transactional public class SongService { private final SpotifyUtil spotifyUtil; @@ -40,7 +37,6 @@ public class SongService { private final MemberService memberService; // Spotify - @Transactional(readOnly = true) public List searchTracks(String keyword, int offset) { List tracks = spotifyUtil.searchTracks(keyword, offset); return tracks.stream() @@ -53,32 +49,12 @@ public List searchTracks(String keyword, int offset) { .collect(Collectors.toList()); } - @Transactional(readOnly = true) - public Optional calculateAvgGenreName(List genres) { - Map genreCount = genres.stream() - .collect(Collectors.groupingBy(Genre::getGenreName, Collectors.counting())); - - return genreCount.entrySet().stream() - .sorted(Map.Entry.comparingByValue().reversed() - .thenComparing(Map.Entry::getKey)) - .map(Map.Entry::getKey) - .findFirst(); - } - // 노래 검색 - @Transactional(readOnly = true) public SongSearchResponseDto searchSongs(String keyword, SortBy sortBy, Pageable pageable) { // 키워드의 이스케이프 처리 및 띄어쓰기 제거 String escapedWord = escapeSpecialCharacters(keyword); String keywordNoSpaces = escapedWord.replace(" ", ""); - - Page songPage; - switch (sortBy) { - case NEWEST -> songPage = songRepository.findAllBySongNameOrArtistContainingIgnoreSpacesOrderByNewest(keywordNoSpaces, pageable); - case COUNT -> songPage = songRepository.findAllBySongNameOrArtistContainingIgnoreSpacesOrderByCount(keywordNoSpaces, pageable); - case ACCURACY -> songPage = songRepository.findAllBySongNameOrArtistContainingIgnoreSpacesOrderByAccuracy(keywordNoSpaces, pageable); - default -> throw new CustomException(ErrorCode.INVALID_ENUM_VALUE); - } + Page songPage = getSearchedSongPage(sortBy, keywordNoSpaces, pageable); Page songUnitPage = songPage.map(objects -> { SongInfoDto songInfo = new SongInfoDto( @@ -96,6 +72,19 @@ public SongSearchResponseDto searchSongs(String keyword, SortBy sortBy, Pageable return SongSearchResponseDto.from(songUnitPage); } + @Transactional(readOnly = true) + Page getSearchedSongPage(SortBy sortBy, String keywordNoSpaces, Pageable pageable) { + Page songPage; + switch (sortBy) { + case NEWEST -> songPage = songRepository.findAllBySongNameOrArtistContainingIgnoreSpacesOrderByNewest(keywordNoSpaces, pageable); + case COUNT -> songPage = songRepository.findAllBySongNameOrArtistContainingIgnoreSpacesOrderByCount(keywordNoSpaces, pageable); + case ACCURACY -> songPage = songRepository.findAllBySongNameOrArtistContainingIgnoreSpacesOrderByAccuracy(keywordNoSpaces, pageable); + default -> throw new CustomException(ErrorCode.INVALID_ENUM_VALUE); + } + return songPage; + } + + @Transactional public Song createSong(SongAddRequestDto songRequestDto) { Song song = songRequestDto.toEntity(); return songRepository.save(song); @@ -106,13 +95,13 @@ public Optional getSongByProviderTrackCode(String providerTrackCode) { return songRepository.findByProviderTrackCode(providerTrackCode); } + @Transactional public Song getOrCreateSong(SongAddRequestDto songRequestDto) { return getSongByProviderTrackCode(songRequestDto.providerTrackCode()) .orElseGet(() -> createSong(songRequestDto)); } // 특정 노래에 대한 상세정보 - @Transactional(readOnly = true) public SongDetailsResponseDto getSongDetails(Long songId) { Song song = getSongById(songId); Member currentMember = memberService.getCurrentMember(); diff --git a/src/main/java/sws/songpin/domain/statistics/service/StatisticsService.java b/src/main/java/sws/songpin/domain/statistics/service/StatisticsService.java index 574991ca..3af4e3c2 100644 --- a/src/main/java/sws/songpin/domain/statistics/service/StatisticsService.java +++ b/src/main/java/sws/songpin/domain/statistics/service/StatisticsService.java @@ -20,7 +20,6 @@ import java.util.Optional; @Service -@Transactional(readOnly = true) // Transaction 모두 읽기 전용 @RequiredArgsConstructor public class StatisticsService { private final MapPlaceRepository mapPlaceRepository; @@ -30,8 +29,7 @@ public class StatisticsService { // 종합 통계 public StatsOverallResponseDto getOverallStats() { - int currentYear = LocalDate.now().getYear(); - long totalPinCount = pinRepository.countByListenedDateYear(currentYear); + long totalPinCount = getTotalPinCount(); Pageable pageable = PageRequest.of(0, 1); StatsPopularSongDto popularSong = getMostPopularSongDto(pageable).orElse(null); StatsPlaceUnitDto popularPlace = getMostPopularPlaceDto(pageable).orElse(null); @@ -39,7 +37,12 @@ public StatsOverallResponseDto getOverallStats() { return StatsOverallResponseDto.from(totalPinCount, popularSong, popularPlace, popularGenreName); } - private Optional getMostPopularSongDto(Pageable pageable) { + public long getTotalPinCount() { + return pinRepository.countByCreatedTimeYear(LocalDate.now().getYear()); + } + + @Transactional(readOnly = true) + public Optional getMostPopularSongDto(Pageable pageable) { Slice topSongsSlice = songRepository.findTopSongs(pageable); if (topSongsSlice != null && !topSongsSlice.getContent().isEmpty()) { return Optional.of(StatsPopularSongDto.from(topSongsSlice.getContent().get(0))); @@ -47,7 +50,8 @@ private Optional getMostPopularSongDto(Pageable pageable) { return Optional.empty(); } - private Optional getMostPopularPlaceDto(Pageable pageable) { + @Transactional(readOnly = true) + public Optional getMostPopularPlaceDto(Pageable pageable) { Slice topPlacesSlice = mapPlaceRepository.findTopPlaces(pageable); if (topPlacesSlice != null && !topPlacesSlice.getContent().isEmpty()) { return Optional.of(StatsPlaceUnitDto.from(topPlacesSlice.getContent().get(0))); @@ -55,7 +59,8 @@ private Optional getMostPopularPlaceDto(Pageable pageable) { return Optional.empty(); } - private Optional getMostPopularGenreName() { + @Transactional(readOnly = true) + public Optional getMostPopularGenreName() { List objectList = pinRepository.findMostPopularGenreName(); if (!objectList.isEmpty()) { return Optional.of(GenreName.from(objectList.get(0)[0].toString())); @@ -64,6 +69,7 @@ private Optional getMostPopularGenreName() { } } + // 장르별 통계 public StatsGenreResponseDto getPlaceAndSongStatsByGenre() { @@ -74,7 +80,8 @@ public StatsGenreResponseDto getPlaceAndSongStatsByGenre() { return StatsGenreResponseDto.from(placeUnitDtos, songUnitDtos); } - private List getTopSongsFromAllGenres(GenreName[] genreNames, Pageable pageable) { + @Transactional(readOnly = true) + public List getTopSongsFromAllGenres(GenreName[] genreNames, Pageable pageable) { List songUnitDtos = new ArrayList<>(); for (GenreName genreName : genreNames) { Slice dtoSlice = songRepository.findTopSongsByGenreName(genreName, pageable); @@ -85,7 +92,8 @@ private List getTopSongsFromAllGenres(GenreName[] genreNames, return songUnitDtos; } - private List getTopPlacesFromAllGenres(GenreName[] genreNames, Pageable pageable) { + @Transactional(readOnly = true) + public List getTopPlacesFromAllGenres(GenreName[] genreNames, Pageable pageable) { List placeUnitDtos = new ArrayList<>(); for (GenreName genreName : genreNames) { Slice dtoSlice = mapPlaceRepository.findTopPlacesByGenreName(genreName, pageable); diff --git a/src/main/java/sws/songpin/global/auth/CustomAccessDeniedHandler.java b/src/main/java/sws/songpin/global/auth/CustomAccessDeniedHandler.java new file mode 100644 index 00000000..5744524d --- /dev/null +++ b/src/main/java/sws/songpin/global/auth/CustomAccessDeniedHandler.java @@ -0,0 +1,23 @@ +package sws.songpin.global.auth; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +public class CustomAccessDeniedHandler implements AccessDeniedHandler { + + + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException { + response.sendError(HttpServletResponse.SC_FORBIDDEN, "Access Denied"); + } + +} diff --git a/src/main/java/sws/songpin/global/auth/JwtAuthenticationEntryPoint.java b/src/main/java/sws/songpin/global/auth/CustomAuthenticationEntryPoint.java similarity index 93% rename from src/main/java/sws/songpin/global/auth/JwtAuthenticationEntryPoint.java rename to src/main/java/sws/songpin/global/auth/CustomAuthenticationEntryPoint.java index 08df7d6f..2f7205da 100644 --- a/src/main/java/sws/songpin/global/auth/JwtAuthenticationEntryPoint.java +++ b/src/main/java/sws/songpin/global/auth/CustomAuthenticationEntryPoint.java @@ -1,7 +1,6 @@ package sws.songpin.global.auth; import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.SerializationFeature; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -17,7 +16,7 @@ @Component @RequiredArgsConstructor -public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { +public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint { private final ObjectMapper objectMapper; @Override diff --git a/src/main/java/sws/songpin/global/auth/CustomUserDetails.java b/src/main/java/sws/songpin/global/auth/CustomUserDetails.java index c91ea6fd..ada27beb 100644 --- a/src/main/java/sws/songpin/global/auth/CustomUserDetails.java +++ b/src/main/java/sws/songpin/global/auth/CustomUserDetails.java @@ -3,10 +3,12 @@ import lombok.Getter; import lombok.RequiredArgsConstructor; import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; import sws.songpin.domain.member.entity.Member; import java.util.Collection; +import java.util.Collections; @Getter @RequiredArgsConstructor @@ -16,7 +18,7 @@ public class CustomUserDetails implements UserDetails { @Override public Collection getAuthorities() { - return null; + return Collections.singletonList(new SimpleGrantedAuthority(member.getRole().name())); } @Override diff --git a/src/main/java/sws/songpin/global/auth/JwtUtil.java b/src/main/java/sws/songpin/global/auth/JwtUtil.java index 0ba138f7..476b8881 100644 --- a/src/main/java/sws/songpin/global/auth/JwtUtil.java +++ b/src/main/java/sws/songpin/global/auth/JwtUtil.java @@ -106,7 +106,7 @@ public Authentication getAuthentication(String token){ //Access Token 재발급에서 사용 public boolean isTokenExpired(String accessToken) { try { - Claims claims = Jwts.parser().setSigningKey(accessKey).parseClaimsJws(accessToken).getBody(); + Claims claims = Jwts.parserBuilder().setSigningKey(accessKey).build().parseClaimsJws(accessToken).getBody(); return claims.getExpiration().before(new Date()); } catch (ExpiredJwtException e){ return true; diff --git a/src/main/java/sws/songpin/global/auth/RedisService.java b/src/main/java/sws/songpin/global/auth/RedisService.java index 0d349629..5c5db62f 100644 --- a/src/main/java/sws/songpin/global/auth/RedisService.java +++ b/src/main/java/sws/songpin/global/auth/RedisService.java @@ -21,6 +21,7 @@ public boolean setValuesWithTimeoutIfAbsent(String key, String value, Duration t return redisTemplate.opsForValue().setIfAbsent(key, value, timeout); } + @Transactional(readOnly = true) public Object getValues(String key){ return redisTemplate.opsForValue().get(key); } diff --git a/src/main/java/sws/songpin/global/config/SecurityConfig.java b/src/main/java/sws/songpin/global/config/SecurityConfig.java index f006bf25..b1740d49 100644 --- a/src/main/java/sws/songpin/global/config/SecurityConfig.java +++ b/src/main/java/sws/songpin/global/config/SecurityConfig.java @@ -1,6 +1,7 @@ package sws.songpin.global.config; import lombok.RequiredArgsConstructor; +import org.springframework.boot.autoconfigure.security.servlet.PathRequest; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpStatus; @@ -22,10 +23,7 @@ import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; -import sws.songpin.global.auth.CustomLogoutHandler; -import sws.songpin.global.auth.JwtAuthenticationEntryPoint; -import sws.songpin.global.auth.JwtFilter; -import sws.songpin.global.auth.JwtUtil; +import sws.songpin.global.auth.*; import java.util.Arrays; @@ -36,14 +34,16 @@ public class SecurityConfig { private final JwtUtil jwtUtil; - private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; + private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint; + private final CustomAccessDeniedHandler customAccessDeniedHandler; private final CustomLogoutHandler logoutHandler; @Bean public WebSecurityCustomizer webSecurityCustomizer() { return web -> web.ignoring() .requestMatchers("/error", "/favicon.ico", - "/swagger-ui/**", "/swagger-ui.html", "/v3/api-docs", "/v3/api-docs/**"); + "/swagger-ui/**", "/swagger-ui.html", "/v3/api-docs", "/v3/api-docs/**") + .requestMatchers(PathRequest.toStaticResources().atCommonLocations()); } private static final String[] AUTH_WHITELIST = { @@ -110,7 +110,10 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{ .requestMatchers(HttpMethod.GET, "/members/{handle}/playlists").permitAll() .requestMatchers(HttpMethod.GET, "/members/{handle}/feed").permitAll() .anyRequest().authenticated()) - .exceptionHandling(e -> e.authenticationEntryPoint(jwtAuthenticationEntryPoint)) + .exceptionHandling(e -> e + .authenticationEntryPoint(customAuthenticationEntryPoint) + .accessDeniedHandler(customAccessDeniedHandler) + ) ; return http.build(); diff --git a/src/main/java/sws/songpin/global/exception/GlobalExceptionHandler.java b/src/main/java/sws/songpin/global/exception/GlobalExceptionHandler.java index 421b81c4..7dd5b331 100644 --- a/src/main/java/sws/songpin/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/sws/songpin/global/exception/GlobalExceptionHandler.java @@ -4,6 +4,7 @@ import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatusCode; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.AccessDeniedException; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.ExceptionHandler; @@ -74,4 +75,17 @@ protected ResponseEntity handleUnsupportedEncodingException(Unsupporte ); return new ResponseEntity<>(errorDto, HttpStatus.BAD_REQUEST); } + + // MVC 계층에서 발생한 AccessDeniedException 처리 + @ExceptionHandler({AccessDeniedException.class}) + protected ResponseEntity handleAccessDeniedException(AccessDeniedException e, HttpServletRequest request){ + ErrorDto errorDto = new ErrorDto( + LocalDateTime.now().toString(), + HttpStatus.FORBIDDEN.value(), + "ACCESS_DENIED", + "접근 권한이 없습니다.", + request.getRequestURI() + ); + return new ResponseEntity<>(errorDto, HttpStatus.FORBIDDEN); + } } \ No newline at end of file