diff --git a/src/main/java/sws/songpin/domain/alarm/controller/AlarmController.java b/src/main/java/sws/songpin/domain/alarm/controller/AlarmController.java new file mode 100644 index 00000000..5cc74c2a --- /dev/null +++ b/src/main/java/sws/songpin/domain/alarm/controller/AlarmController.java @@ -0,0 +1,33 @@ +package sws.songpin.domain.alarm.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; +import sws.songpin.domain.alarm.service.AlarmService; +import sws.songpin.domain.alarm.service.EmitterService; + +@Tag(name = "Alarm", description = "Alarm 관련 API입니다.") +@RestController +@RequiredArgsConstructor +@RequestMapping("/alarms") +public class AlarmController { + private final EmitterService emitterService; + private final AlarmService alarmService; + + @Operation(summary = "알림 구독", description = "알림을 구독합니다.") + @PostMapping(value = "/subscribe", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public ResponseEntity subscribe() { + return ResponseEntity.ok(emitterService.subscribe()); + } + + // 신규 알림 목록 읽어오기 API + @Operation(summary = "알림 목록 조회 및 읽음 처리", description = "알림 목록을 조회하고 읽음 처리합니다.") + @PatchMapping("/list") + public ResponseEntity getUnreadAlarms() { + return ResponseEntity.ok(alarmService.getAlarmList()); + } +} diff --git a/src/main/java/sws/songpin/domain/alarm/dto/response/AlarmListResponseDto.java b/src/main/java/sws/songpin/domain/alarm/dto/response/AlarmListResponseDto.java new file mode 100644 index 00000000..99b9280e --- /dev/null +++ b/src/main/java/sws/songpin/domain/alarm/dto/response/AlarmListResponseDto.java @@ -0,0 +1,11 @@ +package sws.songpin.domain.alarm.dto.response; + +import java.util.List; + +public record AlarmListResponseDto( + List alarmList +) { + public static AlarmListResponseDto fromAlarmUnitDto(List alarmUnitDtos) { + return new AlarmListResponseDto(alarmUnitDtos); + } +} diff --git a/src/main/java/sws/songpin/domain/alarm/dto/response/AlarmUnitDto.java b/src/main/java/sws/songpin/domain/alarm/dto/response/AlarmUnitDto.java new file mode 100644 index 00000000..f7192815 --- /dev/null +++ b/src/main/java/sws/songpin/domain/alarm/dto/response/AlarmUnitDto.java @@ -0,0 +1,21 @@ +package sws.songpin.domain.alarm.dto.response; + +import sws.songpin.domain.alarm.entity.Alarm; + +import java.time.LocalDateTime; + +public record AlarmUnitDto( + Boolean isRead, + String message, + LocalDateTime createdTime, + Long senderId +) { + public static AlarmUnitDto from(Alarm alarm) { + return new AlarmUnitDto( + alarm.getIsRead(), + alarm.getMessage(), + alarm.getCreatedTime(), + alarm.getSender().getMemberId() + ); + } +} \ No newline at end of file diff --git a/src/main/java/sws/songpin/domain/alarm/dto/ssedata/AlarmDefaultDataDto.java b/src/main/java/sws/songpin/domain/alarm/dto/ssedata/AlarmDefaultDataDto.java new file mode 100644 index 00000000..c81e0348 --- /dev/null +++ b/src/main/java/sws/songpin/domain/alarm/dto/ssedata/AlarmDefaultDataDto.java @@ -0,0 +1,13 @@ +package sws.songpin.domain.alarm.dto.ssedata; + +import sws.songpin.domain.member.entity.Member; + +public record AlarmDefaultDataDto( + Long memberId +) { + public static AlarmDefaultDataDto from (Member member) { + return new AlarmDefaultDataDto( + member.getMemberId() + ); + } +} \ No newline at end of file diff --git a/src/main/java/sws/songpin/domain/alarm/dto/ssedata/AlarmFollowDataDto.java b/src/main/java/sws/songpin/domain/alarm/dto/ssedata/AlarmFollowDataDto.java new file mode 100644 index 00000000..66818d3b --- /dev/null +++ b/src/main/java/sws/songpin/domain/alarm/dto/ssedata/AlarmFollowDataDto.java @@ -0,0 +1,20 @@ +package sws.songpin.domain.alarm.dto.ssedata; + +import sws.songpin.domain.alarm.entity.AlarmType; +import sws.songpin.domain.member.entity.Member; + +public record AlarmFollowDataDto( + AlarmType alarmType, + Long senderId, + String senderNickname, + String senderHandle +) { + public static AlarmFollowDataDto from (Member member) { + return new AlarmFollowDataDto( + AlarmType.FOLLOW, + member.getMemberId(), + member.getNickname(), + member.getHandle() + ); + } +} \ No newline at end of file diff --git a/src/main/java/sws/songpin/domain/alarm/entity/Alarm.java b/src/main/java/sws/songpin/domain/alarm/entity/Alarm.java index 0273effd..0a7de87e 100644 --- a/src/main/java/sws/songpin/domain/alarm/entity/Alarm.java +++ b/src/main/java/sws/songpin/domain/alarm/entity/Alarm.java @@ -4,41 +4,55 @@ import jakarta.validation.constraints.NotNull; import lombok.*; import org.hibernate.annotations.ColumnDefault; +import org.hibernate.annotations.OnDelete; +import org.hibernate.annotations.OnDeleteAction; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; import sws.songpin.domain.member.entity.Member; -import sws.songpin.global.BaseTimeEntity; + +import java.time.LocalDateTime; @Getter +@Builder @NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@EntityListeners(AuditingEntityListener.class) @Entity -public class Alarm extends BaseTimeEntity { +public class Alarm { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "alarm_id", updatable = false) private Long alarmId; - @Column(name = "message", length = 100) + @Enumerated(EnumType.STRING) + @Column(name = "alarm_type") @NotNull - private String message; + private AlarmType alarmType; - @Column(name = "target_mem_id") - private Long targetMemId; + @Column(name = "message", length = 100) + private String message; - @Column(name = "is_checked") - @NotNull + @Column(name = "is_read") @ColumnDefault("false") - private Boolean isChecked; + @Builder.Default + private Boolean isRead = false; + + @CreatedDate + @Column(updatable = false) + private LocalDateTime createdTime; @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "member_id", updatable = false) + @JoinColumn(name = "sender_id", updatable = false) + @OnDelete(action = OnDeleteAction.CASCADE) + private Member sender; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "receiver_id", updatable = false) @NotNull - private Member member; - - @Builder - public Alarm(Long alarmId, String message, Long targetMemId, Boolean isChecked, Member member) { - this.alarmId = alarmId; - this.message = message; - this.targetMemId = targetMemId; - this.isChecked = isChecked; - this.member = member; + @OnDelete(action = OnDeleteAction.CASCADE) + private Member receiver; + + public void readAlarm() { + this.isRead = true; } } diff --git a/src/main/java/sws/songpin/domain/alarm/entity/AlarmType.java b/src/main/java/sws/songpin/domain/alarm/entity/AlarmType.java new file mode 100644 index 00000000..b95dcc54 --- /dev/null +++ b/src/main/java/sws/songpin/domain/alarm/entity/AlarmType.java @@ -0,0 +1,14 @@ +package sws.songpin.domain.alarm.entity; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum AlarmType { + FOLLOW("{0}(@{1})님이 팔로우했어요."), + DEFAULT("{0}(@{1})님이 보낸 알림이에요.") + ; + + private final String messagePattern; +} diff --git a/src/main/java/sws/songpin/domain/alarm/repository/AlarmRepository.java b/src/main/java/sws/songpin/domain/alarm/repository/AlarmRepository.java new file mode 100644 index 00000000..a8f9d574 --- /dev/null +++ b/src/main/java/sws/songpin/domain/alarm/repository/AlarmRepository.java @@ -0,0 +1,12 @@ +package sws.songpin.domain.alarm.repository; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.jpa.repository.JpaRepository; +import sws.songpin.domain.alarm.entity.Alarm; +import sws.songpin.domain.member.entity.Member; + +public interface AlarmRepository extends JpaRepository { + Slice findByReceiverOrderByCreatedTimeDesc(Member receiver, Pageable pageable); + Boolean existsByReceiverAndIsReadFalse(Member member); +} \ No newline at end of file diff --git a/src/main/java/sws/songpin/domain/alarm/repository/EmitterRepository.java b/src/main/java/sws/songpin/domain/alarm/repository/EmitterRepository.java new file mode 100644 index 00000000..2686b3ec --- /dev/null +++ b/src/main/java/sws/songpin/domain/alarm/repository/EmitterRepository.java @@ -0,0 +1,26 @@ +package sws.songpin.domain.alarm.repository; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +@Repository +@RequiredArgsConstructor +public class EmitterRepository { + private final Map emitters = new ConcurrentHashMap<>(); + + public void save(Long id, SseEmitter emitter) { + emitters.put(id, emitter); + } + + public void delete(Long memberId) { + emitters.remove(memberId); + } + + public SseEmitter get(Long memberId) { + return emitters.get(memberId); + } +} diff --git a/src/main/java/sws/songpin/domain/alarm/service/AlarmService.java b/src/main/java/sws/songpin/domain/alarm/service/AlarmService.java new file mode 100644 index 00000000..4587c0b4 --- /dev/null +++ b/src/main/java/sws/songpin/domain/alarm/service/AlarmService.java @@ -0,0 +1,70 @@ +package sws.songpin.domain.alarm.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import sws.songpin.domain.alarm.dto.response.AlarmUnitDto; +import sws.songpin.domain.alarm.dto.ssedata.AlarmFollowDataDto; +import sws.songpin.domain.alarm.dto.response.AlarmListResponseDto; +import sws.songpin.domain.alarm.entity.Alarm; +import sws.songpin.domain.alarm.entity.AlarmType; +import sws.songpin.domain.alarm.repository.AlarmRepository; +import sws.songpin.domain.member.entity.Member; +import sws.songpin.domain.member.service.MemberService; + +import java.text.MessageFormat; +import java.util.ArrayList; +import java.util.List; + + +@Service +@Transactional +@RequiredArgsConstructor +public class AlarmService { + private final AlarmRepository alarmRepository; + private final EmitterService emitterService; + private final MemberService memberService; + + // 일반 알림 생성 (message, sender는 필수 요소 x) + public void createAlarm(AlarmType alarmType, String message, Member sender, Member receiver, Object data) { + Alarm alarm = Alarm.builder() + .alarmType(alarmType) + .message(message) // nullable + .sender(sender) // nullable + .receiver(receiver) + .isRead(false) + .build(); + alarmRepository.save(alarm); + emitterService.notify(sender.getMemberId(), data, "new alarm"); + } + + // 팔로우 알림 생성 + public void createFollowAlarm(Member follower, Member following) { + String alarmMessage = MessageFormat.format(AlarmType.FOLLOW.getMessagePattern(), follower.getNickname(), follower.getHandle()); + createAlarm(AlarmType.FOLLOW, alarmMessage, follower, following, AlarmFollowDataDto.from(follower)); + } + + // 최근 알림 목록 읽어오기 + public AlarmListResponseDto getAlarmList(){ + List alarmUnitDtos = getAndReadAlarms(); + return AlarmListResponseDto.fromAlarmUnitDto(alarmUnitDtos); + } + + private List getAndReadAlarms() { + List alarmList = new ArrayList<>(); + Member member = memberService.getCurrentMember(); + Pageable pageable = PageRequest.of(0, 30); + Slice alarmSlice = alarmRepository.findByReceiverOrderByCreatedTimeDesc(member, pageable); + if (alarmSlice != null && alarmSlice.hasContent()) { + for (Alarm alarm : alarmSlice) { + alarmList.add(AlarmUnitDto.from(alarm)); + alarm.readAlarm(); + } + alarmRepository.saveAll(alarmSlice); + } + return alarmList; + } +} diff --git a/src/main/java/sws/songpin/domain/alarm/service/EmitterService.java b/src/main/java/sws/songpin/domain/alarm/service/EmitterService.java new file mode 100644 index 00000000..20727596 --- /dev/null +++ b/src/main/java/sws/songpin/domain/alarm/service/EmitterService.java @@ -0,0 +1,73 @@ +package sws.songpin.domain.alarm.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +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; +import sws.songpin.domain.alarm.repository.EmitterRepository; +import sws.songpin.domain.member.entity.Member; +import sws.songpin.global.auth.CustomUserDetails; + +import java.io.IOException; + +@Slf4j +@Service +@Transactional +@RequiredArgsConstructor +public class EmitterService { + private final EmitterRepository emitterRepository; + private final AlarmRepository alarmRepository; + + private static final Long DEFAULT_TIMEOUT = 5L * 1000; // 5초 + + public SseEmitter subscribe() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + Member member = ((CustomUserDetails) authentication.getPrincipal()).getMember(); + SseEmitter emitter = registerEmitter(member); + sendToClientIfNewAlarmExists(member); + return emitter; + } + + public void notify(Long memberId, Object data, String comment) { + sendToClient(memberId, data, comment); + } + + private SseEmitter registerEmitter(Member member) { + Long memberId = member.getMemberId(); + SseEmitter emitter = new SseEmitter(DEFAULT_TIMEOUT); + emitterRepository.save(memberId, emitter); + + emitter.onCompletion(() -> emitterRepository.delete(memberId)); + emitter.onTimeout(() -> emitterRepository.delete(memberId)); + + return emitter; + } + + private void sendToClientIfNewAlarmExists(Member member) { + Boolean isMissedAlarms = alarmRepository.existsByReceiverAndIsReadFalse(member); + if (isMissedAlarms.equals(false)) { + sendToClient(member.getMemberId(), AlarmDefaultDataDto.from(member), "new sse alarm exists"); + } + } + + private void sendToClient(Long memberId, Object data, String comment) { + SseEmitter emitter = emitterRepository.get(memberId); + if (emitter != null) { + try { + emitter.send(SseEmitter.event() + .id(String.valueOf(memberId)) + .name("sse-alarm") + .data(data) + .comment(comment)); + } catch (IOException e) { + emitterRepository.delete(memberId); + emitter.completeWithError(e); + } + } + } +} 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 34287115..4ca487fa 100644 --- a/src/main/java/sws/songpin/domain/follow/service/FollowService.java +++ b/src/main/java/sws/songpin/domain/follow/service/FollowService.java @@ -3,6 +3,8 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import sws.songpin.domain.alarm.entity.AlarmType; +import sws.songpin.domain.alarm.service.AlarmService; import sws.songpin.domain.follow.dto.request.FollowAddRequestDto; import sws.songpin.domain.follow.dto.response.FollowAddResponseDto; import sws.songpin.domain.follow.dto.response.FollowDto; @@ -14,6 +16,7 @@ import sws.songpin.global.exception.CustomException; import sws.songpin.global.exception.ErrorCode; +import java.text.MessageFormat; import java.util.Comparator; import java.util.List; import java.util.Map; @@ -25,6 +28,7 @@ public class FollowService { private final FollowRepository followRepository; private final MemberService memberService; + private final AlarmService alarmService; // 팔로우 추가 public FollowAddResponseDto addFollow(FollowAddRequestDto followAddRequestDto){ @@ -39,6 +43,7 @@ public FollowAddResponseDto addFollow(FollowAddRequestDto followAddRequestDto){ throw new CustomException(ErrorCode.FOLLOW_ALREADY_EXISTS); } Follow follow = followRepository.save(FollowAddRequestDto.toEntity(follower, following)); + alarmService.createFollowAlarm(follower, following); return FollowAddResponseDto.from(follow); } @@ -56,7 +61,6 @@ public void deleteFollow(Long followId) { // 특정 사용자의 팔로잉/팔로워 목록 조회 public FollowListResponseDto getFollowList(Long memberId, boolean isFollowingList) { Member targetMember = memberService.getMemberById(memberId); - List followerList = findAllFollowersOfMember(targetMember); Member currentMember = memberService.getCurrentMember(); Map currentMemberFollowingCache = getMemberFollowingCache(currentMember); List followList = isFollowingList ? findAllFollowingsOfMember(targetMember) : findAllFollowersOfMember(targetMember); diff --git a/src/main/java/sws/songpin/domain/member/controller/AuthController.java b/src/main/java/sws/songpin/domain/member/controller/AuthController.java index 8a83f4f9..5cf8046b 100644 --- a/src/main/java/sws/songpin/domain/member/controller/AuthController.java +++ b/src/main/java/sws/songpin/domain/member/controller/AuthController.java @@ -1,6 +1,9 @@ package sws.songpin.domain.member.controller; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; @@ -13,8 +16,10 @@ import sws.songpin.domain.member.dto.request.LoginRequestDto; import sws.songpin.domain.member.dto.request.SignUpRequestDto; import sws.songpin.domain.member.dto.response.LoginResponseDto; +import sws.songpin.domain.member.dto.response.TokenDto; import sws.songpin.domain.member.service.AuthService; +@Tag(name = "Auth", description = "인증 관련 API입니다.") @RestController @RequiredArgsConstructor public class AuthController { @@ -29,9 +34,25 @@ public ResponseEntity signUp(@Valid @RequestBody SignUpRequestDto reques @Operation(summary = "로그인", description = "로그인 결과를 반환합니다.") @PostMapping("/login") - public ResponseEntity login(@Valid @RequestBody LoginRequestDto requestDto){ - LoginResponseDto responseDto = authService.login(requestDto); - return ResponseEntity.ok(responseDto); + public ResponseEntity login(@Valid @RequestBody LoginRequestDto requestDto, HttpServletResponse response){ + TokenDto tokenDto = authService.login(requestDto); + + Cookie refreshTokenCookie = new Cookie("refreshToken", tokenDto.refreshToken()); + refreshTokenCookie.setHttpOnly(true); + refreshTokenCookie.setSecure(true); + refreshTokenCookie.setPath("/"); + refreshTokenCookie.setMaxAge(tokenDto.refreshTokenMaxAge()); + + response.addCookie(refreshTokenCookie); + + return ResponseEntity.ok(new LoginResponseDto(tokenDto.accessToken())); + } + + @Operation(summary = "로그아웃", description = "Redis와 쿠키에 저장되었던 회원의 Refresh Token을 삭제합니다.") + @PostMapping("/logout") + public ResponseEntity logout(HttpServletResponse response){ + + return ResponseEntity.ok().build(); } @Operation(summary = "토큰 검증 테스트") diff --git a/src/main/java/sws/songpin/domain/member/controller/HomeController.java b/src/main/java/sws/songpin/domain/member/controller/HomeController.java index f62f2a4e..d35f4769 100644 --- a/src/main/java/sws/songpin/domain/member/controller/HomeController.java +++ b/src/main/java/sws/songpin/domain/member/controller/HomeController.java @@ -1,12 +1,25 @@ package sws.songpin.domain.member.controller; +import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import sws.songpin.domain.member.service.MemberService; @Tag(name = "Home", description = "Home 기능 관련 API입니다.") @RestController @RequiredArgsConstructor +@RequestMapping("/home") public class HomeController { + private final MemberService memberService; + + @Operation(summary = "홈 페이지", description = "(1) 인사말, (2) 최근 등록된 핀 3개, (3) 최근 등록된 장소 3개") + @GetMapping + public ResponseEntity getHome(){ + return ResponseEntity.ok(memberService.getHome()); + } } diff --git a/src/main/java/sws/songpin/domain/member/controller/MyPageController.java b/src/main/java/sws/songpin/domain/member/controller/MyPageController.java index 654824f0..2f1d13c9 100644 --- a/src/main/java/sws/songpin/domain/member/controller/MyPageController.java +++ b/src/main/java/sws/songpin/domain/member/controller/MyPageController.java @@ -2,13 +2,15 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import sws.songpin.domain.bookmark.service.BookmarkService; +import sws.songpin.domain.member.dto.request.ProfileDeactivateRequestDto; import sws.songpin.domain.member.dto.request.ProfileUpdateRequestDto; -import sws.songpin.domain.member.service.MemberService; import sws.songpin.domain.member.service.ProfileService; import sws.songpin.domain.pin.service.PinService; import sws.songpin.domain.playlist.service.PlaylistService; @@ -21,7 +23,6 @@ public class MyPageController { private final PlaylistService playlistService; private final BookmarkService bookmarkService; private final ProfileService profileService; - private final MemberService memberService; private final PinService pinService; @Operation(summary = "내 플레이리스트 목록 조회", description = "마이페이지에서 내 플레이리스트 목록 조회") @@ -45,7 +46,7 @@ public ResponseEntity getMyProfile(){ @Operation(summary = "프로필 편집", description = "프로필 이미지, 닉네임, 핸들 변경") @PatchMapping public ResponseEntity updateProfile(@RequestBody @Valid ProfileUpdateRequestDto requestDto){ - memberService.updateProfile(requestDto); + profileService.updateProfile(requestDto); return ResponseEntity.ok().build(); } @@ -61,4 +62,20 @@ public ResponseEntity getMyFeedPinsByMonth(@RequestParam("year") int year, @R return ResponseEntity.ok(pinService.getMyPinFeedForMonth(year, month)); } + @Operation(summary = "회원 탈퇴", description = "회원 상태를 '탈퇴'로 변경하고 닉네임을 '(알 수 없음)'으로 변경합니다. \t\n해당 회원의 handle을 랜덤 uuid 값으로 변경합니다. \t\nRedis와 쿠키에 저장되었던 회원의 Refresh Token을 삭제합니다. \t\n해당 회원이 등록했던 핀 등의 데이터는 남겨둡니다. \t\n해당 회원의 팔로우, 팔로잉 데이터는 삭제합니다.") + @PatchMapping("/status") + public ResponseEntity deactivate(@Valid @RequestBody ProfileDeactivateRequestDto requestDto, HttpServletResponse response){ + profileService.deactivateProfile(requestDto); + + //쿠키 삭제 + Cookie refreshTokenCookie = new Cookie("refreshToken",null); + refreshTokenCookie.setHttpOnly(true); + refreshTokenCookie.setPath("/"); + refreshTokenCookie.setMaxAge(0); + + response.addCookie(refreshTokenCookie); + + return ResponseEntity.ok().build(); + } + } diff --git a/src/main/java/sws/songpin/domain/member/dto/request/LoginRequestDto.java b/src/main/java/sws/songpin/domain/member/dto/request/LoginRequestDto.java index 499160ac..43ffb6d0 100644 --- a/src/main/java/sws/songpin/domain/member/dto/request/LoginRequestDto.java +++ b/src/main/java/sws/songpin/domain/member/dto/request/LoginRequestDto.java @@ -4,7 +4,7 @@ import jakarta.validation.constraints.NotBlank; public record LoginRequestDto( - @Email + @Email(message = "INVALID_INPUT_FORMAT-유효한 이메일 형식이 아닙니다.") @NotBlank String email, @NotBlank diff --git a/src/main/java/sws/songpin/domain/member/dto/request/ProfileDeactivateRequestDto.java b/src/main/java/sws/songpin/domain/member/dto/request/ProfileDeactivateRequestDto.java new file mode 100644 index 00000000..4df5ec7b --- /dev/null +++ b/src/main/java/sws/songpin/domain/member/dto/request/ProfileDeactivateRequestDto.java @@ -0,0 +1,9 @@ +package sws.songpin.domain.member.dto.request; + +import jakarta.validation.constraints.NotBlank; + +public record ProfileDeactivateRequestDto( + @NotBlank(message = "INVALID_INPUT_VALUE-비밀번호는 한 글자 이상 입력해야 합니다.") + String password +) { +} diff --git a/src/main/java/sws/songpin/domain/member/dto/request/ProfileUpdateRequestDto.java b/src/main/java/sws/songpin/domain/member/dto/request/ProfileUpdateRequestDto.java index ea255821..1ab427ae 100644 --- a/src/main/java/sws/songpin/domain/member/dto/request/ProfileUpdateRequestDto.java +++ b/src/main/java/sws/songpin/domain/member/dto/request/ProfileUpdateRequestDto.java @@ -7,7 +7,7 @@ public record ProfileUpdateRequestDto ( @NotBlank String profileImg, - @Pattern(regexp = "^[가-힣a-zA-Z0-9]+$", message = "INVALID_INPUT_FORMAT-닉네임은 한글 문자, 영어 대소문자, 숫자 조합만 허용됩니다.") + @Pattern(regexp = "^(?!.*\\s$)(?!^\\s)[가-힣a-zA-Z0-9\\s]+$", message = "INVALID_INPUT_FORMAT-닉네임은 한글 문자, 영어 대소문자, 숫자, 공백 조합만 허용됩니다. 단, 공백만으로 구성되거나 공백이 맨 앞과 맨 뒤에 올 수 없습니다.") @Size(max = 8, message = "INVALID_INPUT_LENGTH-닉네임은 8자 이내여야 합니다.") @NotBlank String nickname, diff --git a/src/main/java/sws/songpin/domain/member/dto/response/HomeResponseDto.java b/src/main/java/sws/songpin/domain/member/dto/response/HomeResponseDto.java new file mode 100644 index 00000000..4fc22811 --- /dev/null +++ b/src/main/java/sws/songpin/domain/member/dto/response/HomeResponseDto.java @@ -0,0 +1,21 @@ +package sws.songpin.domain.member.dto.response; + +import sws.songpin.domain.member.entity.Member; +import sws.songpin.domain.pin.dto.response.PinBasicUnitDto; +import sws.songpin.domain.place.dto.response.PlaceUnitDto; + +import java.util.List; + +public record HomeResponseDto( + String welcomeMessage, + List pinList, + List placeList) { + + public static HomeResponseDto from(Member member, List pinList, List placeList){ + return new HomeResponseDto( + member.getNickname()+"님,\n무슨 노래 듣고 계세요?", + pinList, + placeList + ); + } +} diff --git a/src/main/java/sws/songpin/domain/member/dto/response/LoginResponseDto.java b/src/main/java/sws/songpin/domain/member/dto/response/LoginResponseDto.java index d206a929..305cbeab 100644 --- a/src/main/java/sws/songpin/domain/member/dto/response/LoginResponseDto.java +++ b/src/main/java/sws/songpin/domain/member/dto/response/LoginResponseDto.java @@ -1,6 +1,7 @@ package sws.songpin.domain.member.dto.response; -public record LoginResponseDto( - String accessToken, - String refreshToken -) { } +public record LoginResponseDto ( + String accessToken +){ + +} diff --git a/src/main/java/sws/songpin/domain/member/dto/response/TokenDto.java b/src/main/java/sws/songpin/domain/member/dto/response/TokenDto.java new file mode 100644 index 00000000..435ba265 --- /dev/null +++ b/src/main/java/sws/songpin/domain/member/dto/response/TokenDto.java @@ -0,0 +1,13 @@ +package sws.songpin.domain.member.dto.response; + +import lombok.Builder; + +public record TokenDto( + String accessToken, + String refreshToken, + int refreshTokenMaxAge +) { + public TokenDto(String accessToken, String refreshToken){ + this(accessToken,refreshToken,7*24*60*60); + } +} diff --git a/src/main/java/sws/songpin/domain/member/entity/Member.java b/src/main/java/sws/songpin/domain/member/entity/Member.java index 73558ef0..fb6ae825 100644 --- a/src/main/java/sws/songpin/domain/member/entity/Member.java +++ b/src/main/java/sws/songpin/domain/member/entity/Member.java @@ -53,9 +53,9 @@ public class Member extends BaseTimeEntity { @Enumerated(EnumType.STRING) private Status status; - @Column(name = "is_new_alarm") + /*@Column(name = "is_new_alarm") @NotNull - private Boolean isNewAlarm; + private Boolean isNewAlarm;*/ @OneToMany(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true) private List pins; @@ -69,7 +69,7 @@ public Member(Long memberId, String email, String password, String nickname, Str this.handle= handle; this.profileImg = ProfileImg.POP; this.status = Status.ACTIVE; - this.isNewAlarm = false; + //this.isNewAlarm = false; this.pins = new ArrayList<>(); } @@ -78,4 +78,10 @@ public void modifyProfile(ProfileImg profileImg, String nickname, String handle) this.nickname = nickname; this.handle = handle; } + + public void deactivate(String handle){ + this.status = Status.DELETED; + this.nickname = "(알 수 없음)"; + this.handle = handle; + } } diff --git a/src/main/java/sws/songpin/domain/member/service/AuthService.java b/src/main/java/sws/songpin/domain/member/service/AuthService.java index 93d4f658..33bc6cd0 100644 --- a/src/main/java/sws/songpin/domain/member/service/AuthService.java +++ b/src/main/java/sws/songpin/domain/member/service/AuthService.java @@ -10,13 +10,16 @@ import org.springframework.transaction.annotation.Transactional; import sws.songpin.domain.member.dto.request.LoginRequestDto; import sws.songpin.domain.member.dto.request.SignUpRequestDto; -import sws.songpin.domain.member.dto.response.LoginResponseDto; +import sws.songpin.domain.member.dto.response.TokenDto; import sws.songpin.domain.member.entity.Member; +import sws.songpin.domain.member.entity.Status; import sws.songpin.domain.member.repository.MemberRepository; +import sws.songpin.global.auth.CustomUserDetailsService; import sws.songpin.global.auth.JwtUtil; import sws.songpin.global.exception.CustomException; import sws.songpin.global.exception.ErrorCode; +import java.util.Optional; import java.util.UUID; @Service @@ -27,11 +30,19 @@ public class AuthService { private final PasswordEncoder passwordEncoder; private final AuthenticationManager authenticationManager; private final JwtUtil jwtUtil; + private final CustomUserDetailsService userDetailsService; public void signUp(SignUpRequestDto requestDto) { + Optional memberOptional = memberRepository.findByEmail(requestDto.email()); + //이메일 중복 검사 - if (memberRepository.findByEmail(requestDto.email()).isPresent()) { + if (memberOptional.isPresent()) { + + if(memberOptional.get().getStatus().equals(Status.DELETED)){ + throw new CustomException(ErrorCode.ALREADY_DELETED_MEMBER); + } + throw new CustomException(ErrorCode.EMAIL_ALREADY_EXISTS); } @@ -48,7 +59,7 @@ public void signUp(SignUpRequestDto requestDto) { memberRepository.save(member); } - public LoginResponseDto login(LoginRequestDto requestDto){ + public TokenDto login(LoginRequestDto requestDto){ try{ Authentication authentication = authenticationManager.authenticate( @@ -57,7 +68,7 @@ public LoginResponseDto login(LoginRequestDto requestDto){ ) ); - LoginResponseDto responseDto = new LoginResponseDto(jwtUtil.generateAccessToken(authentication), jwtUtil.generateRefreshToken(authentication) ); + TokenDto responseDto = new TokenDto(jwtUtil.generateAccessToken(authentication), jwtUtil.generateRefreshToken(authentication) ); return responseDto; } catch (BadCredentialsException e){ @@ -65,4 +76,9 @@ public LoginResponseDto login(LoginRequestDto requestDto){ } } + @Transactional(readOnly = true) + public boolean checkPassword(Member member, String password){ + return passwordEncoder.matches(password, member.getPassword()); + } + } diff --git a/src/main/java/sws/songpin/domain/member/service/HomeService.java b/src/main/java/sws/songpin/domain/member/service/HomeService.java deleted file mode 100644 index e7543098..00000000 --- a/src/main/java/sws/songpin/domain/member/service/HomeService.java +++ /dev/null @@ -1,11 +0,0 @@ -package sws.songpin.domain.member.service; - -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Service -@Transactional -@RequiredArgsConstructor -public class HomeService { -} 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 1adaea8e..b71a5033 100644 --- a/src/main/java/sws/songpin/domain/member/service/MemberService.java +++ b/src/main/java/sws/songpin/domain/member/service/MemberService.java @@ -7,20 +7,30 @@ import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import sws.songpin.domain.member.dto.request.ProfileUpdateRequestDto; +import sws.songpin.domain.member.dto.response.HomeResponseDto; import sws.songpin.domain.member.dto.response.MemberSearchResponseDto; import sws.songpin.domain.member.dto.response.MemberUnitDto; import sws.songpin.domain.member.entity.Member; -import sws.songpin.domain.member.entity.ProfileImg; import sws.songpin.domain.member.repository.MemberRepository; +import sws.songpin.domain.pin.dto.response.PinBasicUnitDto; +import sws.songpin.domain.pin.entity.Pin; +import sws.songpin.domain.pin.repository.PinRepository; +import sws.songpin.domain.place.dto.response.PlaceUnitDto; +import sws.songpin.domain.place.entity.Place; +import sws.songpin.domain.place.repository.PlaceRepository; import sws.songpin.global.exception.CustomException; import sws.songpin.global.exception.ErrorCode; +import java.util.List; +import java.util.stream.Collectors; + @Service @Transactional @RequiredArgsConstructor public class MemberService { private final MemberRepository memberRepository; + private final PinRepository pinRepository; + private final PlaceRepository placeRepository; // 유저 검색 @Transactional(readOnly = true) @@ -54,17 +64,26 @@ public boolean checkMemberExistsByHandle(String handle){ return memberRepository.existsByHandle(handle); } - public void updateProfile(ProfileUpdateRequestDto requestDto){ - - Member member = getCurrentMember(); - - //핸들 중복 검사 - if(checkMemberExistsByHandle(requestDto.handle()) && !(member.getHandle().equals(requestDto.handle()))){ - throw new CustomException(ErrorCode.HANDLE_ALREADY_EXISTS); - } - - member.modifyProfile(ProfileImg.from(requestDto.profileImg()), requestDto.nickname(), requestDto.handle()); + public Member saveMember(Member member){ + return memberRepository.save(member); + } - memberRepository.save(member); + @Transactional(readOnly = true) + public HomeResponseDto getHome() { + Member currentMember = getCurrentMember(); + List pins = pinRepository.findTop3ByOrderByPinIdDesc(); + List places = placeRepository.findTop3ByOrderByPlaceIdDesc(); + // pinList + List pinList = pins.stream() + .map(pin -> PinBasicUnitDto.from(pin, pin.getMember().equals(currentMember))) + .collect(Collectors.toList()); + // placeList + List placeList = places.stream() + .map(place -> { + int placePinCount = place.getPins().size(); + return PlaceUnitDto.from(place, placePinCount); + }) + .collect(Collectors.toList()); + return HomeResponseDto.from(currentMember, pinList, placeList); } } diff --git a/src/main/java/sws/songpin/domain/member/service/ProfileService.java b/src/main/java/sws/songpin/domain/member/service/ProfileService.java index eb159345..c93bfe73 100644 --- a/src/main/java/sws/songpin/domain/member/service/ProfileService.java +++ b/src/main/java/sws/songpin/domain/member/service/ProfileService.java @@ -2,19 +2,30 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import sws.songpin.domain.follow.service.FollowService; +import sws.songpin.domain.member.dto.request.ProfileDeactivateRequestDto; +import sws.songpin.domain.member.dto.request.ProfileUpdateRequestDto; import sws.songpin.domain.member.dto.response.MemberProfileResponseDto; import sws.songpin.domain.member.dto.response.MyProfileResponseDto; import sws.songpin.domain.member.entity.Member; +import sws.songpin.domain.member.entity.ProfileImg; +import sws.songpin.global.auth.RedisService; import sws.songpin.global.exception.CustomException; import sws.songpin.global.exception.ErrorCode; +import java.util.UUID; + @Service @RequiredArgsConstructor +@Transactional public class ProfileService { private final MemberService memberService; private final FollowService followService; + private final AuthService authService; + private final RedisService redisService; + @Transactional(readOnly = true) public MemberProfileResponseDto getMemberProfile(Long memberId){ Member member = memberService.getMemberById(memberId); Member currentMember = memberService.getCurrentMember(); @@ -39,6 +50,7 @@ public MemberProfileResponseDto getMemberProfile(Long memberId){ } + @Transactional(readOnly = true) public MyProfileResponseDto getMyProfile(){ Member member = memberService.getCurrentMember(); @@ -50,4 +62,39 @@ public MyProfileResponseDto getMyProfile(){ return MyProfileResponseDto.from(member, followerCount, followingCount); } + + public void updateProfile(ProfileUpdateRequestDto requestDto){ + + Member member = memberService.getCurrentMember(); + + //핸들 중복 검사 + if(memberService.checkMemberExistsByHandle(requestDto.handle()) && !(member.getHandle().equals(requestDto.handle()))){ + throw new CustomException(ErrorCode.HANDLE_ALREADY_EXISTS); + } + + member.modifyProfile(ProfileImg.from(requestDto.profileImg()), requestDto.nickname(), requestDto.handle()); + + memberService.saveMember(member); + + } + + public void deactivateProfile(ProfileDeactivateRequestDto requestDto){ + + Member member = memberService.getCurrentMember(); + + //패스워드 검사 + if(!(authService.checkPassword(member, requestDto.password()))){ + throw new CustomException(ErrorCode.PASSWORD_MISMATCH); + } + + //handle 랜덤값 생성 + String handle = UUID.randomUUID().toString().replaceAll("-", "").substring(0, 12); + //Status, Nickname, Handle 변경 + member.deactivate(handle); + memberService.saveMember(member); + + //Redis에서 Refresh Token 삭제 + redisService.deleteValues(member.getEmail()); + } + } 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 5a2b27e3..bd8c8c6d 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 @@ -11,6 +11,8 @@ public record PinFeedUnitDto( SongInfoDto songInfo, LocalDate listenedDate, String placeName, + double latitude, + double longitude, GenreName genreName, String memo, Visibility visibility, @@ -22,6 +24,8 @@ public static PinFeedUnitDto from(Pin pin, Boolean isMine) { SongInfoDto.from(pin.getSong()), pin.getListenedDate(), pin.getPlace().getPlaceName(), + pin.getPlace().getLatitude( ), + pin.getPlace().getLongitude(), pin.getGenre().getGenreName(), pin.getMemo(), pin.getVisibility(), 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 b79e532a..2b5750e9 100644 --- a/src/main/java/sws/songpin/domain/pin/repository/PinRepository.java +++ b/src/main/java/sws/songpin/domain/pin/repository/PinRepository.java @@ -22,4 +22,12 @@ public interface PinRepository extends JpaRepository { List findAllByPlace(Place place); @Query("SELECT p FROM Pin p WHERE p.member = :member AND YEAR(p.listenedDate) = :year AND MONTH(p.listenedDate) = :month") List findAllByMemberAndDate(@Param("member") Member member, @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 p.genre.genreName, COUNT(p) FROM Pin p GROUP BY p.genre ORDER BY COUNT(p) DESC") + List findMostPopularGenreName(); + + @Query(value = "SELECT * FROM pin ORDER BY pin_id DESC LIMIT 3", nativeQuery = true) + List findTop3ByOrderByPinIdDesc(); } diff --git a/src/main/java/sws/songpin/domain/place/controller/MapController.java b/src/main/java/sws/songpin/domain/place/controller/MapController.java index 96aca061..48603ed6 100644 --- a/src/main/java/sws/songpin/domain/place/controller/MapController.java +++ b/src/main/java/sws/songpin/domain/place/controller/MapController.java @@ -6,10 +6,10 @@ import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import sws.songpin.domain.place.dto.request.MapFetchBasicRequestDto; +import sws.songpin.domain.place.dto.request.MapFetchEntirePeriodRequestDto; import sws.songpin.domain.place.dto.request.MapFetchCustomPeriodRequestDto; import sws.songpin.domain.place.dto.request.MapFetchRecentPeriodRequestDto; -import sws.songpin.domain.place.dto.response.MapFetchResponseDto; +import sws.songpin.domain.place.dto.response.MapPlaceFetchResponseDto; import sws.songpin.domain.place.service.MapService; @Tag(name = "Map", description = "장소들을 지도에 마커로 표시하기 위해, 요청 조건을 충족하는 최대 100개 장소의 위도/경도 좌표를 반환하는 API입니다.") @@ -20,25 +20,31 @@ public class MapController { private final MapService mapService; - @Operation(summary = "장소 좌표들 가져오기-기본", description = "요청한 좌표 영역 안에 위치한 장소 좌표들을 불러옵니다.") - @GetMapping - public ResponseEntity getPlaceCoordinates(@RequestBody @Valid MapFetchBasicRequestDto requestDto) { - MapFetchResponseDto responseDto = mapService.getPlacesWithinBoundsByBasic(requestDto); + @Operation(summary = "장소 좌표들 가져오기-전체 기간", description = "전체 기간 범위에서 핀이 1개 이상 등록된, 요청 좌표 영역 안의 장소 좌표들을 불러옵니다.") + @PostMapping + public ResponseEntity getMapPlacesWithinBounds(@RequestBody @Valid MapFetchEntirePeriodRequestDto requestDto) { + MapPlaceFetchResponseDto responseDto = mapService.getMapPlacesWithinBoundsByEntirePeriod(requestDto); return ResponseEntity.ok(responseDto); } - @Operation(summary = "장소 좌표들 가져오기-최근 기준 기간", description = "요청한 좌표 영역 안에 위치하고, 선택한 기간 조건이 충족되는 장소 좌표들을 불러옵니다.") - @GetMapping("/period/recent") - public ResponseEntity getRecentPeriodPlaceCoordinates(@RequestBody @Valid MapFetchRecentPeriodRequestDto requestDto) { - MapFetchResponseDto responseDto = mapService.getPlaceCoordinatesByRecentPeriod(requestDto); + @Operation(summary = "장소 좌표들 가져오기-최근 기준 기간", description = "선택한 기간 조건 안에 핀이 1개 이상 등록된, 요청 좌표 영역 안의 장소 좌표들을 불러옵니다.") + @PostMapping("/period/recent") + public ResponseEntity getMapPlacesWithinBoundsForRecentPeriod(@RequestBody @Valid MapFetchRecentPeriodRequestDto requestDto) { + MapPlaceFetchResponseDto responseDto = mapService.getMapPlacesWithinBoundsByRecentPeriod(requestDto); return ResponseEntity.ok(responseDto); } - @Operation(summary = "장소 좌표들 가져오기-기간 직접 설정", description = "요청한 좌표 영역 안에 위치하고, 직접 설정한 기간 조건이 충족되는 장소 좌표들을 불러옵니다.") - @GetMapping("/period/custom") - public ResponseEntity getCustomPeriodPlaceCoordinates(@RequestBody @Valid MapFetchCustomPeriodRequestDto requestDto) { - MapFetchResponseDto responseDto = mapService.getPlacesWithinBoundsByCustomPeriod(requestDto); + @Operation(summary = "장소 좌표들 가져오기-기간 직접 설정", description = "직접 설정한 기간 조건 안에 핀이 1개 이상 등록된, 요청 좌표 영역 안의 장소 좌표들을 불러옵니다.") + @PostMapping("/period/custom") + public ResponseEntity getMapPlacesWithinBoundsForCustomPeriod(@RequestBody @Valid MapFetchCustomPeriodRequestDto requestDto) { + MapPlaceFetchResponseDto responseDto = mapService.getMapPlacesWithinBoundsByCustomPeriod(requestDto); return ResponseEntity.ok(responseDto); } + @Operation(summary = "유저가 핀을 등록한 장소 좌표들 가져오기", description = "유저가 핀을 등록한 장소 좌표들을 가져옵니다. (공개여부 무관)") + @GetMapping("/members/{memberId}") + public ResponseEntity getMapPlacesOfMember(@PathVariable final Long memberId) { + MapPlaceFetchResponseDto responseDto = mapService.getMapPlacesOfMember(memberId); + return ResponseEntity.ok(responseDto); + } } \ No newline at end of file diff --git a/src/main/java/sws/songpin/domain/place/dto/projection/MapPlaceProjectionDto.java b/src/main/java/sws/songpin/domain/place/dto/projection/MapPlaceProjectionDto.java new file mode 100644 index 00000000..910d6b11 --- /dev/null +++ b/src/main/java/sws/songpin/domain/place/dto/projection/MapPlaceProjectionDto.java @@ -0,0 +1,15 @@ +package sws.songpin.domain.place.dto.projection; + +import sws.songpin.domain.genre.entity.GenreName; + +import java.time.LocalDate; + +public record MapPlaceProjectionDto ( + Long placeId, + Double latitude, + double longitude, + Long placePinCount, + LocalDate listenedDate, + Long songId, + GenreName genreName +) { } \ No newline at end of file diff --git a/src/main/java/sws/songpin/domain/place/dto/request/MapFetchBasicRequestDto.java b/src/main/java/sws/songpin/domain/place/dto/request/MapFetchBasicRequestDto.java deleted file mode 100644 index a722375a..00000000 --- a/src/main/java/sws/songpin/domain/place/dto/request/MapFetchBasicRequestDto.java +++ /dev/null @@ -1,8 +0,0 @@ -package sws.songpin.domain.place.dto.request; - -import jakarta.validation.constraints.NotNull; - -public record MapFetchBasicRequestDto( - @NotNull MapBoundCoordsDto boundCoords -) { -} diff --git a/src/main/java/sws/songpin/domain/place/dto/request/MapFetchCustomPeriodRequestDto.java b/src/main/java/sws/songpin/domain/place/dto/request/MapFetchCustomPeriodRequestDto.java index 5900bab5..c0927152 100644 --- a/src/main/java/sws/songpin/domain/place/dto/request/MapFetchCustomPeriodRequestDto.java +++ b/src/main/java/sws/songpin/domain/place/dto/request/MapFetchCustomPeriodRequestDto.java @@ -1,11 +1,14 @@ package sws.songpin.domain.place.dto.request; import jakarta.validation.constraints.NotNull; +import sws.songpin.domain.genre.entity.GenreName; import java.time.LocalDate; +import java.util.List; public record MapFetchCustomPeriodRequestDto( @NotNull MapBoundCoordsDto boundCoords, + List genreNameFilters, @NotNull LocalDate startDate, @NotNull LocalDate endDate ) { diff --git a/src/main/java/sws/songpin/domain/place/dto/request/MapFetchEntirePeriodRequestDto.java b/src/main/java/sws/songpin/domain/place/dto/request/MapFetchEntirePeriodRequestDto.java new file mode 100644 index 00000000..7513d496 --- /dev/null +++ b/src/main/java/sws/songpin/domain/place/dto/request/MapFetchEntirePeriodRequestDto.java @@ -0,0 +1,12 @@ +package sws.songpin.domain.place.dto.request; + +import jakarta.validation.constraints.NotNull; +import sws.songpin.domain.genre.entity.GenreName; + +import java.util.List; + +public record MapFetchEntirePeriodRequestDto( + @NotNull MapBoundCoordsDto boundCoords, + List genreNameFilters +) { +} diff --git a/src/main/java/sws/songpin/domain/place/dto/request/MapFetchRecentPeriodRequestDto.java b/src/main/java/sws/songpin/domain/place/dto/request/MapFetchRecentPeriodRequestDto.java index cdabdc8e..4443aee6 100644 --- a/src/main/java/sws/songpin/domain/place/dto/request/MapFetchRecentPeriodRequestDto.java +++ b/src/main/java/sws/songpin/domain/place/dto/request/MapFetchRecentPeriodRequestDto.java @@ -1,9 +1,13 @@ package sws.songpin.domain.place.dto.request; import jakarta.validation.constraints.NotNull; +import sws.songpin.domain.genre.entity.GenreName; + +import java.util.List; public record MapFetchRecentPeriodRequestDto( @NotNull MapBoundCoordsDto boundCoords, + List genreNameFilters, @NotNull String periodFilter // "week", "month", "threeMonths" ) { } diff --git a/src/main/java/sws/songpin/domain/place/dto/response/MapFetchResponseDto.java b/src/main/java/sws/songpin/domain/place/dto/response/MapFetchResponseDto.java deleted file mode 100644 index 97eb7914..00000000 --- a/src/main/java/sws/songpin/domain/place/dto/response/MapFetchResponseDto.java +++ /dev/null @@ -1,19 +0,0 @@ -package sws.songpin.domain.place.dto.response; - -import sws.songpin.domain.place.entity.Place; - -import java.util.List; - -public record MapFetchResponseDto( - int placeCount, - List placeList -) { - public static MapFetchResponseDto from(List places) { - int placeCount = places.size(); - List placeDtoList = places.stream() - .map(MapPlaceDto::from) - .toList(); - return new MapFetchResponseDto(placeCount, placeDtoList); - } - -} \ No newline at end of file diff --git a/src/main/java/sws/songpin/domain/place/dto/response/MapPlaceDto.java b/src/main/java/sws/songpin/domain/place/dto/response/MapPlaceDto.java deleted file mode 100644 index 97e675f7..00000000 --- a/src/main/java/sws/songpin/domain/place/dto/response/MapPlaceDto.java +++ /dev/null @@ -1,45 +0,0 @@ -package sws.songpin.domain.place.dto.response; - -import sws.songpin.domain.genre.entity.GenreName; -import sws.songpin.domain.pin.entity.Pin; -import sws.songpin.domain.place.entity.Place; - -import java.util.Comparator; -import java.util.Map; -import java.util.stream.Collectors; - -public record MapPlaceDto( - Long placeId, - double placeLatitude, - double placeLongitude, - int placePinCount, - Map placePinCountByGenre, - PlaceLatestPinSongDto latestPinSong -) { - // Place 객체를 PlaceDto로 변환하는 static 메서드 - public static MapPlaceDto from(Place place) { - // Genre별 핀 갯수 생성 - Map placePinCountByGenreMap = place.getPins().stream() - .map(Pin::getGenre) - .distinct() - .collect(Collectors.toMap( - genre -> genre.getGenreName(), - genre -> (int) place.getPins().stream().filter(pin -> pin.getGenre().equals(genre)).count() - )); - - // 최신 핀의 정보 - PlaceLatestPinSongDto latestPinSong = place.getPins().stream() - .max(Comparator.comparing(Pin::getCreatedTime)) - .map(pin -> new PlaceLatestPinSongDto(pin.getSong().getSongId(), pin.getSong().getAvgGenreName())) - .orElse(null); - - return new MapPlaceDto( - place.getPlaceId(), - place.getLatitude(), - place.getLongitude(), - place.getPins().size(), - placePinCountByGenreMap, - latestPinSong - ); - } -} \ No newline at end of file diff --git a/src/main/java/sws/songpin/domain/place/dto/response/MapPlaceFetchResponseDto.java b/src/main/java/sws/songpin/domain/place/dto/response/MapPlaceFetchResponseDto.java new file mode 100644 index 00000000..6547579d --- /dev/null +++ b/src/main/java/sws/songpin/domain/place/dto/response/MapPlaceFetchResponseDto.java @@ -0,0 +1,37 @@ +package sws.songpin.domain.place.dto.response; + +import org.springframework.data.domain.Slice; +import sws.songpin.domain.place.dto.projection.MapPlaceProjectionDto; + +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +public record MapPlaceFetchResponseDto( + int mapPlaceCount, + Set mapPlaceSet +){ + public static MapPlaceFetchResponseDto from(Slice dtoSlice) { + // Collecting entries by placeId + Map latestPlaceMap = dtoSlice.stream() + .collect(Collectors.toMap( + MapPlaceProjectionDto::placeId, + dto -> dto, + (existing, replacement) -> { + if (existing.listenedDate().isAfter(replacement.listenedDate())) { + return existing; + } else { + return replacement; + } + } + )); + Set mapPlaceSet = latestPlaceMap.values().stream() + .map(MapPlaceUnitDto::from) + .collect(Collectors.toCollection(HashSet::new)); + return new MapPlaceFetchResponseDto( + mapPlaceSet.size(), + mapPlaceSet + ); + } +} diff --git a/src/main/java/sws/songpin/domain/place/dto/response/MapPlaceUnitDto.java b/src/main/java/sws/songpin/domain/place/dto/response/MapPlaceUnitDto.java new file mode 100644 index 00000000..134c4363 --- /dev/null +++ b/src/main/java/sws/songpin/domain/place/dto/response/MapPlaceUnitDto.java @@ -0,0 +1,17 @@ +package sws.songpin.domain.place.dto.response; + +import sws.songpin.domain.genre.entity.GenreName; +import sws.songpin.domain.place.dto.projection.MapPlaceProjectionDto; + +public record MapPlaceUnitDto( + Long placeId, + Double latitude, + Double longitude, + Long placePinCount, + Long songId, + GenreName genreName +){ + public static MapPlaceUnitDto from(MapPlaceProjectionDto dto) { + return new MapPlaceUnitDto(dto.placeId(), dto.latitude(), dto.longitude(), dto.placePinCount(), dto.songId(), dto.genreName()); + } +} \ No newline at end of file diff --git a/src/main/java/sws/songpin/domain/place/dto/response/PlaceLatestPinSongDto.java b/src/main/java/sws/songpin/domain/place/dto/response/PlaceLatestPinSongDto.java deleted file mode 100644 index a23fb7a5..00000000 --- a/src/main/java/sws/songpin/domain/place/dto/response/PlaceLatestPinSongDto.java +++ /dev/null @@ -1,9 +0,0 @@ -package sws.songpin.domain.place.dto.response; - -import sws.songpin.domain.genre.entity.GenreName; - -public record PlaceLatestPinSongDto( - Long songId, - GenreName avgGenreName -) { -} \ No newline at end of file diff --git a/src/main/java/sws/songpin/domain/place/repository/MapPlaceRepository.java b/src/main/java/sws/songpin/domain/place/repository/MapPlaceRepository.java new file mode 100644 index 00000000..44826851 --- /dev/null +++ b/src/main/java/sws/songpin/domain/place/repository/MapPlaceRepository.java @@ -0,0 +1,124 @@ +package sws.songpin.domain.place.repository; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import sws.songpin.domain.genre.entity.GenreName; +import sws.songpin.domain.place.dto.projection.MapPlaceProjectionDto; +import sws.songpin.domain.place.entity.Place; +import sws.songpin.domain.statistics.dto.projection.StatsPlaceProjectionDto; + +import java.time.LocalDate; +import java.util.List; + +public interface MapPlaceRepository extends JpaRepository { + + //// Home 페이지 + // 좌표 범위에 포함되는 장소들 불러오기 + @Query(""" + SELECT new sws.songpin.domain.place.dto.projection.MapPlaceProjectionDto( + p.placeId, p.latitude, p.longitude, COUNT(pin), latestPin.listenedDate, latestPin.song.songId, latestPin.genre.genreName + ) + FROM Place p + JOIN p.pins pin ON pin.genre.genreName IN :genreNameList + LEFT JOIN Pin latestPin ON latestPin.place = p AND latestPin.genre.genreName IN :genreNameList AND latestPin.listenedDate = ( + SELECT MAX(innerPin.listenedDate) + FROM Pin innerPin + WHERE innerPin.place = p + AND innerPin.genre.genreName IN :genreNameList + ) + WHERE p.latitude BETWEEN :swLat AND :neLat + AND p.longitude BETWEEN :swLng AND :neLng + GROUP BY p.placeId, p.latitude, p.longitude, latestPin.listenedDate, latestPin.song.songId, latestPin.genre.genreName + ORDER BY latestPin.listenedDate DESC, p.placeId DESC + """) + Slice findPlacesWithLatestPinsByGenre(@Param("swLat") double swLat, + @Param("neLat") double neLat, + @Param("swLng") double swLng, + @Param("neLng") double neLng, + @Param("genreNameList") List genreNameList, + Pageable pageable); + + // 좌표 범위 & 기간 범위에 모두 포함되는 장소들 불러오기 + @Query(""" + SELECT new sws.songpin.domain.place.dto.projection.MapPlaceProjectionDto( + p.placeId, p.latitude, p.longitude, COUNT(pin), latestPin.listenedDate, latestPin.song.songId, latestPin.genre.genreName + ) + FROM Place p + JOIN p.pins pin ON pin.genre.genreName IN :genreNameList AND pin.listenedDate BETWEEN :startDate AND :endDate + LEFT JOIN Pin latestPin ON latestPin.place = p AND latestPin.genre.genreName IN :genreNameList AND latestPin.listenedDate = ( + SELECT MAX(innerPin.listenedDate) + FROM Pin innerPin + WHERE innerPin.place = p + AND innerPin.genre.genreName IN :genreNameList + ) + WHERE p.latitude BETWEEN :swLat AND :neLat + AND p.longitude BETWEEN :swLng AND :neLng + AND pin.listenedDate BETWEEN :startDate AND :endDate + GROUP BY p.placeId, p.latitude, p.longitude, latestPin.listenedDate, latestPin.song.songId, latestPin.genre.genreName + ORDER BY latestPin.listenedDate DESC, p.placeId DESC + """) + Slice findPlacesWithLatestPinsByGenreAndDateRange(@Param("swLat") double swLat, + @Param("neLat") double neLat, + @Param("swLng") double swLng, + @Param("neLng") double neLng, + @Param("genreNameList") List genreNameList, + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate, + Pageable pageable); + + //// Member 페이지 + // 유저가 핀을 등록한 지도 장소들 불러오기 + @Query(""" + SELECT new sws.songpin.domain.place.dto.projection.MapPlaceProjectionDto( + p.placeId, p.latitude, p.longitude, COUNT(pin), latestPin.listenedDate, latestPin.song.songId, latestPin.genre.genreName + ) + FROM Place p + JOIN p.pins pin ON pin.member.memberId = :memberId + LEFT JOIN Pin latestPin ON latestPin.place = p AND latestPin.member.memberId = :memberId AND latestPin.listenedDate = ( + SELECT MAX(innerPin.listenedDate) + FROM Pin innerPin + WHERE innerPin.place = p AND innerPin.member.memberId = :memberId + ) + GROUP BY p.placeId, p.latitude, p.longitude, latestPin.listenedDate, latestPin.song.songId, latestPin.genre.genreName + ORDER BY latestPin.listenedDate DESC, p.placeId DESC + """) + Slice findPlacesWithLatestPinsByMember(Long memberId, Pageable pageable); + + //// 통계 페이지 + // 모든 장르 통틀어 가장 핀이 많이 등록된 장소 가져오기 + @Query(""" + SELECT new sws.songpin.domain.statistics.dto.projection.StatsPlaceProjectionDto( + p.placeId, p.placeName, p.latitude, p.longitude, COUNT(pin), latestPin.listenedDate, latestPin.song.songId, latestPin.genre.genreName + ) + FROM Place p + JOIN p.pins pin + LEFT JOIN Pin latestPin ON latestPin.place = p AND latestPin.listenedDate = ( + SELECT MAX(innerPin.listenedDate) + FROM Pin innerPin + WHERE innerPin.place = p + ) + GROUP BY p.placeId, p.placeName, p.latitude, p.longitude, latestPin.listenedDate, latestPin.song.songId, latestPin.genre.genreName + ORDER BY COUNT(pin) DESC, latestPin.listenedDate DESC, p.placeId DESC + """) + Slice findTopPlaces(Pageable pageable); + + // 해당 장르에서 가장 핀이 많이 등록된 장소 가져오기 + @Query(""" + SELECT new sws.songpin.domain.statistics.dto.projection.StatsPlaceProjectionDto( + p.placeId, p.placeName, p.latitude, p.longitude, COUNT(pin), latestPin.listenedDate, latestPin.song.songId, latestPin.genre.genreName + ) + FROM Place p + JOIN p.pins pin ON pin.genre.genreName = :genreName + LEFT JOIN Pin latestPin ON latestPin.place = p AND latestPin.genre.genreName = :genreName AND latestPin.listenedDate = ( + SELECT MAX(innerPin.listenedDate) + FROM Pin innerPin + WHERE innerPin.place = p AND latestPin.genre.genreName = :genreName + ) + GROUP BY p.placeId, p.placeName, p.latitude, p.longitude, latestPin.listenedDate, latestPin.song.songId, latestPin.genre.genreName + ORDER BY COUNT(pin) DESC, latestPin.listenedDate DESC, p.placeId DESC + """) + Slice findTopPlacesByGenreName(@Param("genreName") GenreName genreName, Pageable pageable); +} diff --git a/src/main/java/sws/songpin/domain/place/repository/PlaceRepository.java b/src/main/java/sws/songpin/domain/place/repository/PlaceRepository.java index 3b530fef..9d43c5c0 100644 --- a/src/main/java/sws/songpin/domain/place/repository/PlaceRepository.java +++ b/src/main/java/sws/songpin/domain/place/repository/PlaceRepository.java @@ -7,64 +7,14 @@ import org.springframework.data.repository.query.Param; import sws.songpin.domain.place.entity.Place; -import java.time.LocalDate; +import java.util.List; import java.util.Optional; public interface PlaceRepository extends JpaRepository { Optional findByProviderAddressId(Long providerAddressId); - // 좌표 범위에 포함되는 장소들 불러오기 - @Query(value = "SELECT p.* FROM place p " + - "JOIN pin ON p.place_id = pin.place_id " + - "WHERE p.latitude BETWEEN :swLat AND :neLat " + - "AND p.longitude BETWEEN :swLng AND :neLng " + - "GROUP BY p.place_id " + - "HAVING COUNT(pin.pin_id) > 0 " + - "ORDER BY MAX(pin.listened_date) DESC, p.place_id DESC", - countQuery = "SELECT COUNT(DISTINCT p.place_id) FROM place p " + - "JOIN pin ON p.place_id = pin.place_id " + - "WHERE p.latitude BETWEEN :swLat AND :neLat " + - "AND p.longitude BETWEEN :swLng AND :neLng " + - "GROUP BY p.place_id " + - "HAVING COUNT(pin.pin_id) > 0", - nativeQuery = true) - Page findAllByLatitudeBetweenAndLongitudeBetween( - @Param("swLat") double swLat, - @Param("neLat") double neLat, - @Param("swLng") double swLng, - @Param("neLng") double neLng, - Pageable pageable - ); - - // 좌표 범위 & 기간 범위에 모두 포함되는 장소들 불러오기 - @Query(value = "SELECT p.* FROM place p " + - "JOIN pin ON p.place_id = pin.place_id " + - "WHERE p.latitude BETWEEN :swLat AND :neLat " + - "AND p.longitude BETWEEN :swLng AND :neLng " + - "AND pin.listened_date BETWEEN :startDate AND :endDate " + - "GROUP BY p.place_id " + - "HAVING COUNT(pin.pin_id) > 0 " + - "ORDER BY MAX(pin.listened_date) DESC, p.place_id DESC", - countQuery = "SELECT COUNT(DISTINCT p.place_id) FROM place p " + - "JOIN pin ON p.place_id = pin.place_id " + - "WHERE p.latitude BETWEEN :swLat AND :neLat " + - "AND p.longitude BETWEEN :swLng AND :neLng " + - "AND pin.listened_date BETWEEN :startDate AND :endDate " + - "GROUP BY p.place_id " + - "HAVING COUNT(pin.pin_id) > 0", - nativeQuery = true) - Page findAllByLatitudeBetweenAndLongitudeBetweenAndPinsListenedDateBetween( - @Param("swLat") double swLat, - @Param("neLat") double neLat, - @Param("swLng") double swLng, - @Param("neLng") double neLng, - @Param("startDate") LocalDate startDate, - @Param("endDate") LocalDate endDate, - Pageable pageable - ); + //// 장소 검색 (페이징 방식) - // 페이징 방식으로 장소 검색 - // countQuery 추가함 (total elements 계산시 사용) @Query(value = "SELECT p.place_id, p.place_name, COUNT(pin.pin_id) AS pin_count " + "FROM place p " + "LEFT JOIN pin pin ON p.place_id = pin.place_id " + @@ -103,4 +53,6 @@ Page findAllByLatitudeBetweenAndLongitudeBetweenAndPinsListenedDateBetwee "WHERE REPLACE(p.place_name, ' ', '') LIKE %:keywordNoSpaces%", nativeQuery = true) Page findAllByPlaceNameContainingIgnoreSpacesOrderByAccuracy(@Param("keywordNoSpaces") String keywordNoSpaces, Pageable pageable); + @Query(value = "SELECT * FROM Place p ORDER BY p.place_id DESC LIMIT 3", nativeQuery = true) + List findTop3ByOrderByPlaceIdDesc(); } \ No newline at end of file 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 72ec9144..3a66a385 100644 --- a/src/main/java/sws/songpin/domain/place/service/MapService.java +++ b/src/main/java/sws/songpin/domain/place/service/MapService.java @@ -2,38 +2,40 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import sws.songpin.domain.genre.entity.GenreName; import sws.songpin.domain.place.dto.request.MapBoundCoordsDto; -import sws.songpin.domain.place.dto.request.MapFetchBasicRequestDto; +import sws.songpin.domain.place.dto.request.MapFetchEntirePeriodRequestDto; import sws.songpin.domain.place.dto.request.MapFetchCustomPeriodRequestDto; import sws.songpin.domain.place.dto.request.MapFetchRecentPeriodRequestDto; -import sws.songpin.domain.place.dto.response.MapFetchResponseDto; -import sws.songpin.domain.place.entity.Place; -import sws.songpin.domain.place.repository.PlaceRepository; +import sws.songpin.domain.place.dto.response.MapPlaceFetchResponseDto; +import sws.songpin.domain.place.dto.projection.MapPlaceProjectionDto; +import sws.songpin.domain.place.repository.MapPlaceRepository; import java.time.LocalDate; - +import java.util.Arrays; +import java.util.List; @Slf4j @Service @Transactional(readOnly = true) // Transaction 모두 읽기 전용 @RequiredArgsConstructor public class MapService { - private final PlaceRepository placeRepository; + private final MapPlaceRepository mapPlaceRepository; - // 기본 - public MapFetchResponseDto getPlacesWithinBoundsByBasic(MapFetchBasicRequestDto requestDto) { - Pageable pageable = getCustomPageableForMap(); - Page pageResult = getPlacesWithinBounds(requestDto.boundCoords(), pageable); - return MapFetchResponseDto.from(pageResult.getContent()); + // 장소 좌표들 가져오기-전체 기간 & 장르 필터링 + public MapPlaceFetchResponseDto getMapPlacesWithinBoundsByEntirePeriod(MapFetchEntirePeriodRequestDto requestDto) { + List genreNameList = getSelectedGenreNames(requestDto.genreNameFilters()); + Slice dtoSlice = getMapPlaceSlicesWithinBounds(requestDto.boundCoords(), genreNameList); + return MapPlaceFetchResponseDto.from(dtoSlice); } - // 최근 기준 기간 - public MapFetchResponseDto getPlaceCoordinatesByRecentPeriod(MapFetchRecentPeriodRequestDto requestDto) { + // 장소 좌표들 가져오기-최근 기준 기간 & 장르 필터링 + public MapPlaceFetchResponseDto getMapPlacesWithinBoundsByRecentPeriod(MapFetchRecentPeriodRequestDto requestDto) { LocalDate startDate; LocalDate endDate = LocalDate.now(); switch (requestDto.periodFilter()) { @@ -42,30 +44,48 @@ public MapFetchResponseDto getPlaceCoordinatesByRecentPeriod(MapFetchRecentPerio case "threeMonths"-> startDate = endDate.minusMonths(3); default -> throw new IllegalArgumentException("Invalid period filter: " + requestDto.periodFilter()); } + List genreNameList = getSelectedGenreNames(requestDto.genreNameFilters()); + Slice dtoSlice = getMapPlaceSlicesWithinBoundsAndDateRange(requestDto.boundCoords(), genreNameList, startDate, endDate); + return MapPlaceFetchResponseDto.from(dtoSlice); + } + + // 장소 좌표들 가져오기-기간 직접 설정 & 장르 필터링 + public MapPlaceFetchResponseDto getMapPlacesWithinBoundsByCustomPeriod(MapFetchCustomPeriodRequestDto requestDto) { + List genreNameList = getSelectedGenreNames(requestDto.genreNameFilters()); + Slice dtoSlice = getMapPlaceSlicesWithinBoundsAndDateRange(requestDto.boundCoords(), genreNameList, requestDto.startDate(), requestDto.endDate()); + return MapPlaceFetchResponseDto.from(dtoSlice); + } + + // 날짜 범위 조건 걸지 않는 경우 + public Slice getMapPlaceSlicesWithinBounds(MapBoundCoordsDto dto, List genreNameList) { Pageable pageable = getCustomPageableForMap(); - Page pageResult = getPlacePagesWithinBoundsAndDateRange(requestDto.boundCoords(), startDate, endDate, pageable); - return MapFetchResponseDto.from(pageResult.getContent()); + return mapPlaceRepository.findPlacesWithLatestPinsByGenre(dto.swLat(), dto.neLat(), dto.swLng(), dto.neLng(), genreNameList, pageable); } - // 기간 직접 설정 - public MapFetchResponseDto getPlacesWithinBoundsByCustomPeriod(MapFetchCustomPeriodRequestDto requestDto) { + // 날짜 범위 조건 거는 경우 + public Slice getMapPlaceSlicesWithinBoundsAndDateRange(MapBoundCoordsDto dto, List genreNameList, LocalDate startDate, LocalDate endDate) { Pageable pageable = getCustomPageableForMap(); - Page pageResult = getPlacePagesWithinBoundsAndDateRange(requestDto.boundCoords(), requestDto.startDate(), requestDto.endDate(), pageable); - return MapFetchResponseDto.from(pageResult.getContent()); + return mapPlaceRepository.findPlacesWithLatestPinsByGenreAndDateRange(dto.swLat(), dto.neLat(), dto.swLng(), dto.neLng(), genreNameList, startDate, endDate, pageable); } - // Map에 Place 좌표를 띄우기 위한 Custom Pageable - private Pageable getCustomPageableForMap() { - return PageRequest.of(0, 100); + // 장르 필터링에 포함할 리스트 생성 + private List getSelectedGenreNames(List genreNameFilters) { + if (genreNameFilters == null || genreNameFilters.isEmpty()) { + return Arrays.asList(GenreName.values()); + } + return genreNameFilters; } - // 날짜 범위 조건 걸지 않는 경우 - public Page getPlacesWithinBounds(MapBoundCoordsDto dto, Pageable pageable) { - return placeRepository.findAllByLatitudeBetweenAndLongitudeBetween(dto.swLat(), dto.neLat(), dto.swLng(), dto.neLng(), pageable); + // 지도에 장소 좌표를 최대 100개 띄우도록 함 + private Pageable getCustomPageableForMap() { + return PageRequest.of(0, 100); } - // 날짜 범위 조건 거는 경우 - public Page getPlacePagesWithinBoundsAndDateRange(MapBoundCoordsDto dto, LocalDate startDate, LocalDate endDate, Pageable pageable) { - return placeRepository.findAllByLatitudeBetweenAndLongitudeBetweenAndPinsListenedDateBetween(dto.swLat(), dto.neLat(), dto.swLng(), dto.neLng(), startDate, endDate, pageable); + //// 유저로 필터링 + // 유저가 핀을 등록한 장소 좌표들 가져오기 + public MapPlaceFetchResponseDto getMapPlacesOfMember(Long memberId) { + Pageable pageable = PageRequest.of(0, 300); + Slice dtoSlice = mapPlaceRepository.findPlacesWithLatestPinsByMember(memberId, pageable); + return MapPlaceFetchResponseDto.from(dtoSlice); } } \ No newline at end of file diff --git a/src/main/java/sws/songpin/domain/playlist/repository/PlaylistRepository.java b/src/main/java/sws/songpin/domain/playlist/repository/PlaylistRepository.java index b3db1f0b..bbb93045 100644 --- a/src/main/java/sws/songpin/domain/playlist/repository/PlaylistRepository.java +++ b/src/main/java/sws/songpin/domain/playlist/repository/PlaylistRepository.java @@ -13,43 +13,48 @@ public interface PlaylistRepository extends JpaRepository { List findAllByCreator(Member member); - @Query(value = "SELECT p.playlist_id, p.playlist_name, COUNT(pin.pin_id) AS pin_count " + "FROM playlist p " + "LEFT JOIN playlist_pin pin ON p.playlist_id = pin.playlist_id " + "WHERE REPLACE(p.playlist_name, ' ', '') LIKE %:keywordNoSpaces% " + + "AND (p.visibility = 'PUBLIC' OR p.creator_id = :currentMemberId) " + "GROUP BY p.playlist_id " + "ORDER BY pin_count DESC, p.playlist_name ASC", countQuery = "SELECT COUNT(DISTINCT p.playlist_id) " + "FROM playlist p " + "LEFT JOIN playlist_pin pin ON p.playlist_id = pin.playlist_id " + - "WHERE REPLACE(p.playlist_name, ' ', '') LIKE %:keywordNoSpaces%", + "WHERE REPLACE(p.playlist_name, ' ', '') LIKE %:keywordNoSpaces% " + + "AND (p.visibility = 'PUBLIC' OR p.creator_id = :currentMemberId)", nativeQuery = true) - Page findAllByPlaylistNameContainingIgnoreSpacesOrderByCount(@Param("keywordNoSpaces") String keywordNoSpaces, Pageable pageable); + Page findAllByPlaylistNameContainingIgnoreSpacesOrderByCount(@Param("keywordNoSpaces") String keywordNoSpaces, @Param("currentMemberId") Long currentMemberId, Pageable pageable); @Query(value = "SELECT p.playlist_id, p.playlist_name, COUNT(pin.pin_id) AS pin_count " + "FROM playlist p " + "LEFT JOIN playlist_pin pin ON p.playlist_id = pin.playlist_id " + "WHERE REPLACE(p.playlist_name, ' ', '') LIKE %:keywordNoSpaces% " + + "AND (p.visibility = 'PUBLIC' OR p.creator_id = :currentMemberId) " + "GROUP BY p.playlist_id " + "ORDER BY MAX(p.modified_time) DESC", countQuery = "SELECT COUNT(DISTINCT p.playlist_id) " + "FROM playlist p " + "LEFT JOIN playlist_pin pin ON p.playlist_id = pin.playlist_id " + - "WHERE REPLACE(p.playlist_name, ' ', '') LIKE %:keywordNoSpaces%", + "WHERE REPLACE(p.playlist_name, ' ', '') LIKE %:keywordNoSpaces% " + + "AND (p.visibility = 'PUBLIC' OR p.creator_id = :currentMemberId)", nativeQuery = true) - Page findAllByPlaylistNameContainingIgnoreSpacesOrderByNewest(@Param("keywordNoSpaces") String keywordNoSpaces, Pageable pageable); + Page findAllByPlaylistNameContainingIgnoreSpacesOrderByNewest(@Param("keywordNoSpaces") String keywordNoSpaces, @Param("currentMemberId") Long currentMemberId, Pageable pageable); @Query(value = "SELECT p.playlist_id, p.playlist_name, COUNT(pin.pin_id) AS pin_count " + "FROM playlist p " + "LEFT JOIN playlist_pin pin ON p.playlist_id = pin.playlist_id " + "WHERE REPLACE(p.playlist_name, ' ', '') LIKE %:keywordNoSpaces% " + + "AND (p.visibility = 'PUBLIC' OR p.creator_id = :currentMemberId) " + "GROUP BY p.playlist_id " + "ORDER BY p.playlist_name ASC", countQuery = "SELECT COUNT(DISTINCT p.playlist_id) " + "FROM playlist p " + "LEFT JOIN playlist_pin pin ON p.playlist_id = pin.playlist_id " + - "WHERE REPLACE(p.playlist_name, ' ', '') LIKE %:keywordNoSpaces%", + "WHERE REPLACE(p.playlist_name, ' ', '') LIKE %:keywordNoSpaces% " + + "AND (p.visibility = 'PUBLIC' OR p.creator_id = :currentMemberId)", nativeQuery = true) - Page findAllByPlaylistNameContainingIgnoreSpacesOrderByAccuracy(@Param("keywordNoSpaces") String keywordNoSpaces, Pageable pageable); -} + Page findAllByPlaylistNameContainingIgnoreSpacesOrderByAccuracy(@Param("keywordNoSpaces") String keywordNoSpaces, @Param("currentMemberId") Long currentMemberId, Pageable pageable); +} \ No newline at end of file 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 c11c1dc8..3e9f28b5 100644 --- a/src/main/java/sws/songpin/domain/playlist/service/PlaylistService.java +++ b/src/main/java/sws/songpin/domain/playlist/service/PlaylistService.java @@ -236,15 +236,16 @@ public PlaylistMainResponseDto getPlaylistMain() { public Object searchPlaylists(String keyword, SortBy sortBy, Pageable pageable) { String keywordNoSpaces = keyword.replace(" ", ""); Page playlistPage; + Member currentMember = memberService.getCurrentMember(); + Long currentMemberId = currentMember.getMemberId(); switch (sortBy) { - case COUNT -> playlistPage = playlistRepository.findAllByPlaylistNameContainingIgnoreSpacesOrderByCount(keywordNoSpaces, pageable); - case NEWEST -> playlistPage = playlistRepository.findAllByPlaylistNameContainingIgnoreSpacesOrderByNewest(keywordNoSpaces, pageable); - case ACCURACY -> playlistPage = playlistRepository.findAllByPlaylistNameContainingIgnoreSpacesOrderByAccuracy(keywordNoSpaces, pageable); + case COUNT -> playlistPage = playlistRepository.findAllByPlaylistNameContainingIgnoreSpacesOrderByCount(keywordNoSpaces, currentMemberId, pageable); + case NEWEST -> playlistPage = playlistRepository.findAllByPlaylistNameContainingIgnoreSpacesOrderByNewest(keywordNoSpaces, currentMemberId, pageable); + case ACCURACY -> playlistPage = playlistRepository.findAllByPlaylistNameContainingIgnoreSpacesOrderByAccuracy(keywordNoSpaces, currentMemberId, pageable); default -> throw new CustomException(ErrorCode.INVALID_ENUM_VALUE); } // Page를 Page로 변환 Page playlistUnitPage = playlistPage.map(objects -> { - Member currentMember = memberService.getCurrentMember(); Long playlistId = ((Number) objects[0]).longValue(); Playlist playlist = findPlaylistById(playlistId); List imgPathList = getPlaylistThumbnailImgPathList(playlist); 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 cf554b5e..7a5bdd12 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 @@ -7,21 +7,27 @@ public record SongDetailsPinDto( Long pinId, + Long creatorId, String creatorNickname, LocalDate listenedDate, String memo, Visibility visibility, String placeName, + double latitude, + double longitude, Boolean isMine) { public static SongDetailsPinDto from(Pin pin, Boolean isMine) { return new SongDetailsPinDto( pin.getPinId(), + pin.getMember().getMemberId(), pin.getMember().getNickname(), pin.getListenedDate(), pin.getMemo(), pin.getVisibility(), pin.getPlace().getPlaceName(), + pin.getPlace().getLatitude( ), + pin.getPlace().getLongitude(), isMine ); } diff --git a/src/main/java/sws/songpin/domain/song/repository/SongRepository.java b/src/main/java/sws/songpin/domain/song/repository/SongRepository.java index 8ceed446..67354368 100644 --- a/src/main/java/sws/songpin/domain/song/repository/SongRepository.java +++ b/src/main/java/sws/songpin/domain/song/repository/SongRepository.java @@ -1,10 +1,38 @@ package sws.songpin.domain.song.repository; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import sws.songpin.domain.genre.entity.GenreName; import sws.songpin.domain.song.entity.Song; +import sws.songpin.domain.statistics.dto.projection.StatsSongProjectionDto; import java.util.Optional; public interface SongRepository extends JpaRepository { Optional findByProviderTrackCode(String providerTrackCode); + + @Query(""" + SELECT new sws.songpin.domain.statistics.dto.projection.StatsSongProjectionDto( + s.songId, s.title, s.artist, s.imgPath, s.avgGenreName + ) + FROM Song s + LEFT JOIN s.pins p + WHERE s.avgGenreName = :genreName + GROUP BY s.songId + ORDER BY COUNT(p) DESC, s.songId DESC + """) + Slice findTopSongsByGenreName(GenreName genreName, Pageable pageable); + + @Query(""" + SELECT new sws.songpin.domain.statistics.dto.projection.StatsSongProjectionDto( + s.songId, s.title, s.artist, s.imgPath, s.avgGenreName + ) + FROM Song s + LEFT JOIN s.pins p + GROUP BY s.songId + ORDER BY COUNT(p) DESC, s.songId DESC + """) + Slice findTopSongs(Pageable pageable); } diff --git a/src/main/java/sws/songpin/domain/statistics/controller/StatisticsController.java b/src/main/java/sws/songpin/domain/statistics/controller/StatisticsController.java new file mode 100644 index 00000000..4f4f51b4 --- /dev/null +++ b/src/main/java/sws/songpin/domain/statistics/controller/StatisticsController.java @@ -0,0 +1,34 @@ +package sws.songpin.domain.statistics.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import sws.songpin.domain.statistics.dto.response.StatsGenreResponseDto; +import sws.songpin.domain.statistics.dto.response.StatsOverallResponseDto; +import sws.songpin.domain.statistics.service.StatisticsService; + +@Tag(name = "Statistics", description = "서비스 통계 데이터를 보여줍니다.") +@RestController +@RequestMapping("/stats") +@RequiredArgsConstructor +public class StatisticsController { + private final StatisticsService statisticsService; + + @Operation(summary = "서비스 통계-종합", description = "종합적인 4가지 통계 데이터를 제공합니다.") + @GetMapping("/overall") + public ResponseEntity overallStats() { + StatsOverallResponseDto responseDto = statisticsService.getOverallStats(); + return ResponseEntity.ok(responseDto); + } + + @Operation(summary = "서비스 통계-장르별 장소, 노래", description = "장르별로 가장 많은 핀이 등록된 장소, 노래 정보를 전달합니다.") + @GetMapping("/genre") + public ResponseEntity placeAndSongStatsByGenre() { + StatsGenreResponseDto responseDto = statisticsService.getPlaceAndSongStatsByGenre(); + return ResponseEntity.ok(responseDto); + } +} diff --git a/src/main/java/sws/songpin/domain/statistics/dto/projection/StatsPlaceProjectionDto.java b/src/main/java/sws/songpin/domain/statistics/dto/projection/StatsPlaceProjectionDto.java new file mode 100644 index 00000000..7483377a --- /dev/null +++ b/src/main/java/sws/songpin/domain/statistics/dto/projection/StatsPlaceProjectionDto.java @@ -0,0 +1,16 @@ +package sws.songpin.domain.statistics.dto.projection; + +import sws.songpin.domain.genre.entity.GenreName; + +import java.time.LocalDate; + +public record StatsPlaceProjectionDto ( + Long placeId, + String placeName, + Double latitude, + Double longitude, + Long placePinCount, + LocalDate listenedDate, + Long songId, + GenreName genreName +) {} \ No newline at end of file diff --git a/src/main/java/sws/songpin/domain/statistics/dto/projection/StatsSongProjectionDto.java b/src/main/java/sws/songpin/domain/statistics/dto/projection/StatsSongProjectionDto.java new file mode 100644 index 00000000..2ef8b853 --- /dev/null +++ b/src/main/java/sws/songpin/domain/statistics/dto/projection/StatsSongProjectionDto.java @@ -0,0 +1,11 @@ +package sws.songpin.domain.statistics.dto.projection; + +import sws.songpin.domain.genre.entity.GenreName; + +public record StatsSongProjectionDto ( + Long songId, + String title, + String artist, + String imgPath, + GenreName avgGenreName +) {} \ No newline at end of file diff --git a/src/main/java/sws/songpin/domain/statistics/dto/response/StatsGenreResponseDto.java b/src/main/java/sws/songpin/domain/statistics/dto/response/StatsGenreResponseDto.java new file mode 100644 index 00000000..d09f1470 --- /dev/null +++ b/src/main/java/sws/songpin/domain/statistics/dto/response/StatsGenreResponseDto.java @@ -0,0 +1,12 @@ +package sws.songpin.domain.statistics.dto.response; + +import java.util.List; + +public record StatsGenreResponseDto( + List placeList, + List songList +) { + public static StatsGenreResponseDto from(List placeUnitDtos, List songUnitDtos) { + return new StatsGenreResponseDto(placeUnitDtos, songUnitDtos); + } +} diff --git a/src/main/java/sws/songpin/domain/statistics/dto/response/StatsOverallResponseDto.java b/src/main/java/sws/songpin/domain/statistics/dto/response/StatsOverallResponseDto.java new file mode 100644 index 00000000..8a99a3ef --- /dev/null +++ b/src/main/java/sws/songpin/domain/statistics/dto/response/StatsOverallResponseDto.java @@ -0,0 +1,19 @@ +package sws.songpin.domain.statistics.dto.response; + +import sws.songpin.domain.genre.entity.GenreName; + +public record StatsOverallResponseDto ( + long totalPinCount, + StatsPopularSongDto popularSong, + StatsPlaceUnitDto popularPlace, + GenreName popularGenreName +) { + public static StatsOverallResponseDto from(long totalPinCount, StatsPopularSongDto popularSong, StatsPlaceUnitDto popularPlace, GenreName popularGenreName) { + return new StatsOverallResponseDto( + totalPinCount, + popularSong, + popularPlace, + popularGenreName + ); + } +} \ No newline at end of file diff --git a/src/main/java/sws/songpin/domain/statistics/dto/response/StatsPlaceUnitDto.java b/src/main/java/sws/songpin/domain/statistics/dto/response/StatsPlaceUnitDto.java new file mode 100644 index 00000000..17a9e040 --- /dev/null +++ b/src/main/java/sws/songpin/domain/statistics/dto/response/StatsPlaceUnitDto.java @@ -0,0 +1,17 @@ +package sws.songpin.domain.statistics.dto.response; + +import sws.songpin.domain.genre.entity.GenreName; +import sws.songpin.domain.statistics.dto.projection.StatsPlaceProjectionDto; + +public record StatsPlaceUnitDto( + GenreName genreName, + Long placeId, + String placeName, + Double latitude, + Double longitude, + Long placePinCount +){ + public static StatsPlaceUnitDto from(StatsPlaceProjectionDto dto) { + return new StatsPlaceUnitDto(dto.genreName(), dto.placeId(), dto.placeName(), dto.latitude(), dto.longitude(), dto.placePinCount()); + } +} \ No newline at end of file diff --git a/src/main/java/sws/songpin/domain/statistics/dto/response/StatsPopularSongDto.java b/src/main/java/sws/songpin/domain/statistics/dto/response/StatsPopularSongDto.java new file mode 100644 index 00000000..baee9fe2 --- /dev/null +++ b/src/main/java/sws/songpin/domain/statistics/dto/response/StatsPopularSongDto.java @@ -0,0 +1,13 @@ +package sws.songpin.domain.statistics.dto.response; + +import sws.songpin.domain.statistics.dto.projection.StatsSongProjectionDto; + +public record StatsPopularSongDto( + String title, + String artist, + String imgPath +) { + public static StatsPopularSongDto from(StatsSongProjectionDto dto) { + return new StatsPopularSongDto(dto.title(), dto.artist(), dto.imgPath()); + } +} \ No newline at end of file diff --git a/src/main/java/sws/songpin/domain/statistics/dto/response/StatsSongUnitDto.java b/src/main/java/sws/songpin/domain/statistics/dto/response/StatsSongUnitDto.java new file mode 100644 index 00000000..5cfc384b --- /dev/null +++ b/src/main/java/sws/songpin/domain/statistics/dto/response/StatsSongUnitDto.java @@ -0,0 +1,15 @@ +package sws.songpin.domain.statistics.dto.response; + +import sws.songpin.domain.genre.entity.GenreName; +import sws.songpin.domain.statistics.dto.projection.StatsSongProjectionDto; + +public record StatsSongUnitDto( + GenreName genreName, + String title, + String artist, + String imgPath +){ + public static StatsSongUnitDto from(StatsSongProjectionDto dto) { + return new StatsSongUnitDto(dto.avgGenreName(), dto.title(), dto.artist(), dto.imgPath()); + } +} \ No newline at end of file diff --git a/src/main/java/sws/songpin/domain/statistics/service/StatisticsService.java b/src/main/java/sws/songpin/domain/statistics/service/StatisticsService.java new file mode 100644 index 00000000..574991ca --- /dev/null +++ b/src/main/java/sws/songpin/domain/statistics/service/StatisticsService.java @@ -0,0 +1,98 @@ +package sws.songpin.domain.statistics.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import sws.songpin.domain.genre.entity.GenreName; +import sws.songpin.domain.pin.repository.PinRepository; +import sws.songpin.domain.place.repository.MapPlaceRepository; +import sws.songpin.domain.song.repository.SongRepository; +import sws.songpin.domain.statistics.dto.projection.StatsPlaceProjectionDto; +import sws.songpin.domain.statistics.dto.projection.StatsSongProjectionDto; +import sws.songpin.domain.statistics.dto.response.*; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +@Service +@Transactional(readOnly = true) // Transaction 모두 읽기 전용 +@RequiredArgsConstructor +public class StatisticsService { + private final MapPlaceRepository mapPlaceRepository; + private final SongRepository songRepository; + private final PinRepository pinRepository; + + // 종합 통계 + + public StatsOverallResponseDto getOverallStats() { + int currentYear = LocalDate.now().getYear(); + long totalPinCount = pinRepository.countByListenedDateYear(currentYear); + Pageable pageable = PageRequest.of(0, 1); + StatsPopularSongDto popularSong = getMostPopularSongDto(pageable).orElse(null); + StatsPlaceUnitDto popularPlace = getMostPopularPlaceDto(pageable).orElse(null); + GenreName popularGenreName = getMostPopularGenreName().orElse(GenreName.POP); + return StatsOverallResponseDto.from(totalPinCount, popularSong, popularPlace, popularGenreName); + } + + private Optional getMostPopularSongDto(Pageable pageable) { + Slice topSongsSlice = songRepository.findTopSongs(pageable); + if (topSongsSlice != null && !topSongsSlice.getContent().isEmpty()) { + return Optional.of(StatsPopularSongDto.from(topSongsSlice.getContent().get(0))); + } + return Optional.empty(); + } + + private Optional getMostPopularPlaceDto(Pageable pageable) { + Slice topPlacesSlice = mapPlaceRepository.findTopPlaces(pageable); + if (topPlacesSlice != null && !topPlacesSlice.getContent().isEmpty()) { + return Optional.of(StatsPlaceUnitDto.from(topPlacesSlice.getContent().get(0))); + } + return Optional.empty(); + } + + private Optional getMostPopularGenreName() { + List objectList = pinRepository.findMostPopularGenreName(); + if (!objectList.isEmpty()) { + return Optional.of(GenreName.from(objectList.get(0)[0].toString())); + } else { + return Optional.empty(); + } + } + + // 장르별 통계 + + public StatsGenreResponseDto getPlaceAndSongStatsByGenre() { + GenreName[] genreNames = GenreName.values(); + Pageable pageable = PageRequest.of(0, 1); + List placeUnitDtos = getTopPlacesFromAllGenres(genreNames, pageable); + List songUnitDtos = getTopSongsFromAllGenres(genreNames, pageable); + return StatsGenreResponseDto.from(placeUnitDtos, songUnitDtos); + } + + private List getTopSongsFromAllGenres(GenreName[] genreNames, Pageable pageable) { + List songUnitDtos = new ArrayList<>(); + for (GenreName genreName : genreNames) { + Slice dtoSlice = songRepository.findTopSongsByGenreName(genreName, pageable); + if (!dtoSlice.isEmpty()) { + songUnitDtos.add(StatsSongUnitDto.from(dtoSlice.getContent().get(0))); + } + } + return songUnitDtos; + } + + private List getTopPlacesFromAllGenres(GenreName[] genreNames, Pageable pageable) { + List placeUnitDtos = new ArrayList<>(); + for (GenreName genreName : genreNames) { + Slice dtoSlice = mapPlaceRepository.findTopPlacesByGenreName(genreName, pageable); + if (!dtoSlice.isEmpty()) { + placeUnitDtos.add(StatsPlaceUnitDto.from(dtoSlice.getContent().get(0))); + } + } + return placeUnitDtos; + } +} diff --git a/src/main/java/sws/songpin/global/auth/CustomLogoutHandler.java b/src/main/java/sws/songpin/global/auth/CustomLogoutHandler.java new file mode 100644 index 00000000..394649f6 --- /dev/null +++ b/src/main/java/sws/songpin/global/auth/CustomLogoutHandler.java @@ -0,0 +1,33 @@ +package sws.songpin.global.auth; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.logout.LogoutHandler; +import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler; +import org.springframework.stereotype.Component; +import sws.songpin.global.exception.CustomException; +import sws.songpin.global.exception.ErrorCode; + +@Component +@RequiredArgsConstructor +public class CustomLogoutHandler implements LogoutHandler { + + private final RedisService redisService; + + @Override + public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) { + + if(authentication!=null && authentication.getName() != null){ + //Redis 에서 Refresh Token 삭제 + redisService.deleteValues(authentication.getName()); + + // 기본 로그아웃 핸들러 기능 수행 + SecurityContextLogoutHandler securityContextLogoutHandler = new SecurityContextLogoutHandler(); + securityContextLogoutHandler.logout(request, response, authentication); + } else{ + throw new CustomException(ErrorCode.NOT_AUTHENTICATED); + } + } +} diff --git a/src/main/java/sws/songpin/global/auth/CustomUserDetailsService.java b/src/main/java/sws/songpin/global/auth/CustomUserDetailsService.java index cdf07f43..e9b1d1db 100644 --- a/src/main/java/sws/songpin/global/auth/CustomUserDetailsService.java +++ b/src/main/java/sws/songpin/global/auth/CustomUserDetailsService.java @@ -6,6 +6,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import sws.songpin.domain.member.entity.Member; +import sws.songpin.domain.member.entity.Status; import sws.songpin.domain.member.repository.MemberRepository; import sws.songpin.global.exception.CustomException; import sws.songpin.global.exception.ErrorCode; @@ -21,6 +22,11 @@ public class CustomUserDetailsService implements UserDetailsService { public UserDetails loadUserByUsername(String email) throws CustomException { Member member = memberRepository.findByEmail(email) .orElseThrow(()-> new CustomException(ErrorCode.MEMBER_NOT_FOUND)); + + if(member.getStatus().equals(Status.DELETED)){ + throw new CustomException(ErrorCode.ALREADY_DELETED_MEMBER); + } + return new CustomUserDetails(member); } } diff --git a/src/main/java/sws/songpin/global/auth/JwtUtil.java b/src/main/java/sws/songpin/global/auth/JwtUtil.java index d35b463d..4eab8f59 100644 --- a/src/main/java/sws/songpin/global/auth/JwtUtil.java +++ b/src/main/java/sws/songpin/global/auth/JwtUtil.java @@ -4,7 +4,6 @@ import io.jsonwebtoken.io.Decoders; import io.jsonwebtoken.security.Keys; import org.springframework.beans.factory.annotation.Value; -import org.springframework.data.redis.core.RedisTemplate; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.userdetails.UserDetails; @@ -20,17 +19,17 @@ public class JwtUtil { private final Key accessKey; private final Key refreshKey; - private final RedisTemplate redisTemplate; + private final RedisService redisService; private final CustomUserDetailsService userDetailsService; private static final Duration ACCESS_TOKEN_EXPIRE_TIME = Duration.ofMinutes(30); //30분 private static final Duration REFRESH_TOKEN_EXPIRE_TIME = Duration.ofDays(7); //7일 - public JwtUtil(@Value("${jwt.secret.access}") String accessSecret, @Value("${jwt.secret.refresh}") String refreshSecret, CustomUserDetailsService userDetailsService,RedisTemplate redisTemplate){ + public JwtUtil(@Value("${jwt.secret.access}") String accessSecret, @Value("${jwt.secret.refresh}") String refreshSecret, CustomUserDetailsService userDetailsService, RedisService redisService){ byte[] keyBytes = Decoders.BASE64.decode(accessSecret); this.accessKey = Keys.hmacShaKeyFor(keyBytes); keyBytes = Decoders.BASE64.decode(refreshSecret); this.refreshKey = Keys.hmacShaKeyFor(keyBytes); - this.redisTemplate = redisTemplate; + this.redisService = redisService; this.userDetailsService = userDetailsService; } @@ -67,7 +66,7 @@ public String generateRefreshToken(Authentication authentication){ .compact(); //refresh token을 redis에 저장 - redisTemplate.opsForValue().set(authentication.getName(),refreshToken,REFRESH_TOKEN_EXPIRE_TIME); + redisService.setValuesWithTimeout(authentication.getName(), refreshToken, REFRESH_TOKEN_EXPIRE_TIME); return refreshToken; } diff --git a/src/main/java/sws/songpin/global/auth/RedisService.java b/src/main/java/sws/songpin/global/auth/RedisService.java new file mode 100644 index 00000000..51d4a092 --- /dev/null +++ b/src/main/java/sws/songpin/global/auth/RedisService.java @@ -0,0 +1,28 @@ +package sws.songpin.global.auth; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Duration; + +@Service +@RequiredArgsConstructor +@Transactional +public class RedisService { + private final RedisTemplate redisTemplate; + + public void setValuesWithTimeout(String key, String value, Duration timeout){ + redisTemplate.opsForValue().set(key,value,timeout); + } + + public Object getValues(String key){ + return redisTemplate.opsForValue().get(key); + } + + public void deleteValues(String key){ + redisTemplate.delete(key); + } + +} diff --git a/src/main/java/sws/songpin/global/config/SecurityConfig.java b/src/main/java/sws/songpin/global/config/SecurityConfig.java index 33fdc3d6..00990f4f 100644 --- a/src/main/java/sws/songpin/global/config/SecurityConfig.java +++ b/src/main/java/sws/songpin/global/config/SecurityConfig.java @@ -3,6 +3,8 @@ import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpMethod; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; @@ -15,10 +17,12 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; -import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.security.web.authentication.logout.HttpStatusReturningLogoutSuccessHandler; +import org.springframework.security.web.authentication.logout.LogoutFilter; 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; @@ -31,6 +35,7 @@ public class SecurityConfig { private final JwtUtil jwtUtil; private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; + private final CustomLogoutHandler logoutHandler; private static final String[] AUTH_WHITELIST = { "/swagger-ui/**", "/swagger-ui.html", "/v3/api-docs", "/v3/api-docs/**", @@ -39,7 +44,7 @@ public class SecurityConfig { }; @Bean - public WebSecurityCustomizer webSecurityCustomizer() { // security를 적용하지 않을 리소스 + public WebSecurityCustomizer webSecurityCustomizer() { return web -> web.ignoring() .requestMatchers("/error", "/favicon.ico") .requestMatchers(AUTH_WHITELIST); @@ -63,6 +68,8 @@ public CorsConfigurationSource corsConfigurationSource() { configuration.addExposedHeader("Authorization"); configuration.addExposedHeader("Authorization-Refresh"); + configuration.addExposedHeader("Set-Cookie"); + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", configuration); //위에서 설정한 Configuration 적용 @@ -85,13 +92,22 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{ .csrf(AbstractHttpConfigurer::disable) .cors(cors -> cors.configurationSource(corsConfigurationSource())) .formLogin(AbstractHttpConfigurer::disable) + .logout(logout -> logout + .logoutUrl("/logout") + .deleteCookies("refreshToken") + .addLogoutHandler(logoutHandler) + .logoutSuccessHandler(new HttpStatusReturningLogoutSuccessHandler(HttpStatus.OK)) + ) .httpBasic(AbstractHttpConfigurer::disable) .sessionManagement(config -> config.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .headers(header -> header .frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin)) - .addFilterBefore(new JwtFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class) + .addFilterBefore(new JwtFilter(jwtUtil), LogoutFilter.class) .authorizeHttpRequests(auth -> auth .requestMatchers(AUTH_WHITELIST).permitAll() + .requestMatchers(HttpMethod.GET,"/places/**").permitAll() + .requestMatchers(HttpMethod.GET,"/playlists/main").authenticated() + .requestMatchers(HttpMethod.GET,"/playlists/**").permitAll() .anyRequest().authenticated()) .exceptionHandling(e -> e.authenticationEntryPoint(jwtAuthenticationEntryPoint)) ; diff --git a/src/main/java/sws/songpin/global/config/SwaggerConfig.java b/src/main/java/sws/songpin/global/config/SwaggerConfig.java index 86fc9598..1dd559c2 100644 --- a/src/main/java/sws/songpin/global/config/SwaggerConfig.java +++ b/src/main/java/sws/songpin/global/config/SwaggerConfig.java @@ -18,8 +18,7 @@ @OpenAPIDefinition( info = @Info(title = "Songpin API", version = "v1"), servers = { - @Server(url = "https://api.songpin.n-e.kr", description = "backend server"), - @Server(url = "http://localhost:8080", description = "local server") + @Server(url = "/", description = "Server URL") }) @RequiredArgsConstructor @Configuration diff --git a/src/main/java/sws/songpin/global/exception/ErrorCode.java b/src/main/java/sws/songpin/global/exception/ErrorCode.java index 34408f73..64edcbe6 100644 --- a/src/main/java/sws/songpin/global/exception/ErrorCode.java +++ b/src/main/java/sws/songpin/global/exception/ErrorCode.java @@ -37,6 +37,8 @@ public enum ErrorCode { INVALID_TOKEN(401, "유효하지 않은 토큰입니다."), // 만료된 토큰 EXPIRED_TOKEN(401,"만료된 토큰입니다."), + // 탈퇴한 회원 + ALREADY_DELETED_MEMBER(401, "탈퇴한 회원입니다."), // 404 Not Found // 각 리소스를 찾지 못함