diff --git a/src/main/java/ita/tinybite/domain/notification/controller/FcmTokenController.java b/src/main/java/ita/tinybite/domain/notification/controller/FcmTokenController.java index 7616daa..122fb08 100644 --- a/src/main/java/ita/tinybite/domain/notification/controller/FcmTokenController.java +++ b/src/main/java/ita/tinybite/domain/notification/controller/FcmTokenController.java @@ -5,10 +5,12 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import ita.tinybite.domain.notification.dto.request.FcmTokenRequest; import ita.tinybite.domain.notification.service.FcmTokenService; +import ita.tinybite.domain.notification.service.facade.NotificationFacade; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -18,6 +20,7 @@ public class FcmTokenController { private final FcmTokenService fcmTokenService; + private final NotificationFacade notificationFacade; // token 이미 존재 시 업데이트 해줌 @PostMapping("/token") @@ -26,4 +29,10 @@ public ResponseEntity registerToken(@RequestBody @Valid FcmTokenRequest re fcmTokenService.saveOrUpdateToken(currentUserId, request.token()); return ResponseEntity.noContent().build(); } + + @PostMapping("/Test") + public ResponseEntity test(@RequestParam Long userId) { + notificationFacade.notifyTest(userId); + return ResponseEntity.ok().build(); + } } diff --git a/src/main/java/ita/tinybite/domain/notification/enums/NotificationType.java b/src/main/java/ita/tinybite/domain/notification/enums/NotificationType.java index bca688e..00b34e2 100644 --- a/src/main/java/ita/tinybite/domain/notification/enums/NotificationType.java +++ b/src/main/java/ita/tinybite/domain/notification/enums/NotificationType.java @@ -27,6 +27,9 @@ public enum NotificationType { // 파티 참여 리마인드 PENDING_APPROVAL_REMINDER, + // 테스트 알림 + TEST_EVENT, + // 마케팅 알림 MARKETING_LOCAL_NEW_PARTY, MARKETING_WEEKLY_POPULAR, diff --git a/src/main/java/ita/tinybite/domain/notification/service/TestNotificationService.java b/src/main/java/ita/tinybite/domain/notification/service/TestNotificationService.java new file mode 100644 index 0000000..34aef5d --- /dev/null +++ b/src/main/java/ita/tinybite/domain/notification/service/TestNotificationService.java @@ -0,0 +1,42 @@ +package ita.tinybite.domain.notification.service; + +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.google.firebase.messaging.BatchResponse; + +import ita.tinybite.domain.notification.dto.request.NotificationMulticastRequest; +import ita.tinybite.domain.notification.enums.NotificationType; +import ita.tinybite.domain.notification.infra.fcm.FcmNotificationSender; +import ita.tinybite.domain.notification.infra.helper.NotificationTransactionHelper; +import ita.tinybite.domain.notification.service.manager.TestMessageManager; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class TestNotificationService { + private final FcmNotificationSender fcmNotificationSender; + private final FcmTokenService fcmTokenService; + private final TestMessageManager testMessageManager; + private final NotificationLogService notificationLogService; + private final NotificationTransactionHelper notificationTransactionHelper; + + @Transactional + public void sendTestNotification(Long targetUserId) { + String title = "알림 테스트 제목"; + String detail = "알림 테스트 내용"; + + notificationLogService.saveLog(targetUserId, NotificationType.TEST_EVENT.name(), title, detail); + + List tokens = fcmTokenService.getTokensAndLogIfEmpty(targetUserId); + if (tokens.isEmpty()) + return; + + NotificationMulticastRequest request = testMessageManager.createTestRequest(tokens, title, detail); + + BatchResponse response = fcmNotificationSender.send(request); + notificationTransactionHelper.handleBatchResponse(response, tokens); + } +} \ No newline at end of file diff --git a/src/main/java/ita/tinybite/domain/notification/service/facade/NotificationFacade.java b/src/main/java/ita/tinybite/domain/notification/service/facade/NotificationFacade.java index 4bfee10..930db48 100644 --- a/src/main/java/ita/tinybite/domain/notification/service/facade/NotificationFacade.java +++ b/src/main/java/ita/tinybite/domain/notification/service/facade/NotificationFacade.java @@ -8,6 +8,7 @@ import ita.tinybite.domain.notification.service.ChatNotificationService; import ita.tinybite.domain.notification.service.PartyNotificationService; +import ita.tinybite.domain.notification.service.TestNotificationService; import ita.tinybite.domain.party.entity.Party; import ita.tinybite.domain.party.enums.ParticipantStatus; import ita.tinybite.domain.party.repository.PartyParticipantRepository; @@ -31,6 +32,7 @@ public class NotificationFacade { private final PartyNotificationService partyNotificationService; private final ChatNotificationService chatNotificationService; + private final TestNotificationService testNotificationService; private final PartyRepository partyRepository; private final UserRepository userRepository; @@ -38,7 +40,13 @@ public class NotificationFacade { private final RedisTemplate redisTemplate; - @Transactional + public void notifyTest(Long targetUserId) { + userRepository.findById(targetUserId) + .orElseThrow(() -> new BusinessException(UserErrorCode.USER_NOT_EXISTS)); + + testNotificationService.sendTestNotification(targetUserId); + } + public void notifyNewPartyRequest(Long managerId, Long requesterId, Long partyId) { Party party = partyRepository.findById(partyId) .orElseThrow(() -> new BusinessException(PartyErrorCode.PARTY_NOT_FOUND)); diff --git a/src/main/java/ita/tinybite/domain/notification/service/manager/TestMessageManager.java b/src/main/java/ita/tinybite/domain/notification/service/manager/TestMessageManager.java new file mode 100644 index 0000000..52fe928 --- /dev/null +++ b/src/main/java/ita/tinybite/domain/notification/service/manager/TestMessageManager.java @@ -0,0 +1,26 @@ +package ita.tinybite.domain.notification.service.manager; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.springframework.stereotype.Component; + +import ita.tinybite.domain.notification.converter.NotificationRequestConverter; +import ita.tinybite.domain.notification.dto.request.NotificationMulticastRequest; +import ita.tinybite.domain.notification.enums.NotificationType; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class TestMessageManager { + + private final NotificationRequestConverter requestConverter; + + public NotificationMulticastRequest createTestRequest(List tokens, String title, String detail) { + Map data = new HashMap<>(); + data.put("eventType", NotificationType.TEST_EVENT.name()); + + return requestConverter.toMulticastRequest(tokens, title, detail, data); + } +} diff --git a/src/main/java/ita/tinybite/domain/party/controller/PartyController.java b/src/main/java/ita/tinybite/domain/party/controller/PartyController.java index 14ce54a..7fccca7 100644 --- a/src/main/java/ita/tinybite/domain/party/controller/PartyController.java +++ b/src/main/java/ita/tinybite/domain/party/controller/PartyController.java @@ -194,7 +194,7 @@ public ResponseEntity completeRecruitment( content = @Content ) }) - @PostMapping("{partyId}/participants/{participantId}/approve") + @PostMapping("/{partyId}/participants/{participantId}/approve") public ResponseEntity approveParticipant( @PathVariable Long partyId, @PathVariable Long participantId, @@ -262,7 +262,7 @@ public ResponseEntity rejectParticipant( content = @Content ) }) - @GetMapping("{partyId}/chat/group") + @GetMapping("/{partyId}/chat/group") public ResponseEntity getGroupChatRoom( @PathVariable Long partyId, @Parameter(hidden = true) @AuthenticationPrincipal Long userId) { @@ -285,7 +285,7 @@ public ResponseEntity getGroupChatRoom( content = @Content ) }) - @GetMapping("{partyId}/can-settle") + @GetMapping("/{partyId}/can-settle") public ResponseEntity canSettle(@PathVariable Long partyId) { boolean canSettle = partyService.canSettle(partyId); return ResponseEntity.ok(canSettle); @@ -319,7 +319,7 @@ public ResponseEntity canSettle(@PathVariable Long partyId) { content = @Content ) }) - @PostMapping("{partyId}/settle") + @PostMapping("/{partyId}/settle") public ResponseEntity settleParty( @PathVariable Long partyId, @Parameter(hidden = true) @AuthenticationPrincipal Long userId) { @@ -352,7 +352,7 @@ public ResponseEntity settleParty( content = @Content ) }) - @GetMapping("{partyId}/participants/pending") + @GetMapping("/{partyId}/participants/pending") public ResponseEntity> getPendingParticipants( @PathVariable Long partyId, @Parameter(hidden = true) @AuthenticationPrincipal Long userId) { @@ -365,6 +365,27 @@ public ResponseEntity> getPendingParticipants( /** * 파티 목록 조회 (홈 화면) */ + @Operation( + summary = "파티 목록 조회", + description = "홈 화면에서 파티 목록을 조회합니다. 카테고리별 필터링, 정렬, 위치 기반 조회를 지원합니다." + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "조회 성공", + content = @Content(schema = @Schema(implementation = PartyListResponse.class)) + ), + @ApiResponse( + responseCode = "400", + description = "잘못된 요청 (위치 정보 형식 오류)", + content = @Content + ), + @ApiResponse( + responseCode = "401", + description = "인증 실패", + content = @Content + ) + }) @GetMapping public ResponseEntity getPartyList( @Parameter(hidden = true) @AuthenticationPrincipal Long userId, @@ -380,9 +401,6 @@ public ResponseEntity getPartyList( ) { Double lat = null; Double lon = null; -// if (userLat == null || userLon == null) { -// throw new IllegalArgumentException("거리순 정렬을 위해서는 현재 위치 정보가 필요합니다."); -// } if (latitude != null && longitude != null) { try { @@ -577,6 +595,18 @@ public ResponseEntity deleteParty( 몇 번째 페이지 인지 (page), 한 페이지 당 몇 개의 파티를 조회할 지 (size) 파라미터로 입력해주시면 됩니다. """ ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "검색 성공", + content = @Content(schema = @Schema(implementation = PartyQueryListResponse.class)) + ), + @ApiResponse( + responseCode = "400", + description = "잘못된 요청 (검색어 누락)", + content = @Content + ) + }) @GetMapping("/search") public APIResponse getParty( @RequestParam String q, @@ -602,6 +632,17 @@ public APIResponse getParty( 한 번에 20개가 조회됩니다. """ ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "조회 성공" + ), + @ApiResponse( + responseCode = "401", + description = "인증 실패", + content = @Content + ) + }) @GetMapping("/search/log") public APIResponse> getRecentLog() { return success(partySearchService.getLog()); @@ -614,6 +655,17 @@ public APIResponse> getRecentLog() { 이때 검색어에 대한 Id값은 없고, 최근 검색어 자체를 keyword에 넣어주시면 됩니다. """ ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "삭제 성공" + ), + @ApiResponse( + responseCode = "401", + description = "인증 실패", + content = @Content + ) + }) @DeleteMapping("/search/log/{keyword}") public APIResponse deleteRecentLog(@PathVariable String keyword) { partySearchService.deleteLog(keyword); @@ -626,6 +678,17 @@ public APIResponse deleteRecentLog(@PathVariable String keyword) { 특정 유저에 대한 모든 최근 검색어를 삭제합니다. """ ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "전체 삭제 성공" + ), + @ApiResponse( + responseCode = "401", + description = "인증 실패", + content = @Content + ) + }) @DeleteMapping("/search/log") public APIResponse deleteRecentLogAll() { partySearchService.deleteAllLog(); diff --git a/src/main/java/ita/tinybite/domain/party/dto/response/PartyCardResponse.java b/src/main/java/ita/tinybite/domain/party/dto/response/PartyCardResponse.java index 11b97c0..d3f7437 100644 --- a/src/main/java/ita/tinybite/domain/party/dto/response/PartyCardResponse.java +++ b/src/main/java/ita/tinybite/domain/party/dto/response/PartyCardResponse.java @@ -28,14 +28,36 @@ public class PartyCardResponse { private LocalDateTime createdAt; public static PartyCardResponse from(Party party, int currentParticipants) { + return from(party, currentParticipants, null, null); + } + + public static PartyCardResponse from(Party party, int currentParticipants, Double userLat, Double userLon) { + String distanceValue = null; + String distanceKmValue = null; + + // 거리 계산 + if (userLat != null && userLon != null + && party.getPickupLocation() != null + && party.getPickupLocation().getPickupLatitude() != null + && party.getPickupLocation().getPickupLongitude() != null) { + double distance = DistanceCalculator.calculateDistance( + userLat, + userLon, + party.getPickupLocation().getPickupLatitude(), + party.getPickupLocation().getPickupLongitude() + ); + distanceKmValue = DistanceCalculator.formatDistance(distance); + distanceValue = Double.toString(distance); + } + return PartyCardResponse.builder() .partyId(party.getId()) .thumbnailImage(party.getThumbnailImage()) .title(party.getTitle()) .pricePerPerson(calculatePricePerPerson(party, currentParticipants)) .participantStatus(formatParticipantStatus(currentParticipants, party.getMaxParticipants())) - .distance(null) // 거리 계산은 별도 처리 필요 - .distanceKm(null) // 거리 계산은 별도 처리 필요 + .distance(distanceValue) + .distanceKm(distanceKmValue) .timeAgo(calculateTimeAgo(party.getCreatedAt())) .isClosed(checkIfClosed(party, currentParticipants)) .category(party.getCategory()) diff --git a/src/main/java/ita/tinybite/domain/party/service/PartyService.java b/src/main/java/ita/tinybite/domain/party/service/PartyService.java index 10c9af8..4bf6bb3 100644 --- a/src/main/java/ita/tinybite/domain/party/service/PartyService.java +++ b/src/main/java/ita/tinybite/domain/party/service/PartyService.java @@ -143,10 +143,10 @@ public PartyListResponse getPartyList(Long userId, PartyListRequest request) { int page = request.getPage() != null ? request.getPage() : 0; int size = request.getSize() != null ? request.getSize() : 20; -// String myTown = getMyTown(request.getUserLat(),request.getUserLon()); + String myTown = getMyTown(request.getUserLat(),request.getUserLon()); // 동네 기준으로 파티 조회 - List parties = fetchPartiesByTown(user, request); + List parties = fetchPartiesByTown(user, request, myTown); // PartyCardResponse로 변환 List cardResponses = parties.stream() @@ -234,7 +234,7 @@ public PartyDetailResponse getPartyDetail(Long partyId, Long userId, Double user // 거리 계산 (사용자 위치 필요) double distance = 0.0; - if (validateLocation(user,userLat, userLon,party)) { + if (validateLocation(userLat, userLon, party)) { distance = DistanceCalculator.calculateDistance( userLat, userLon, @@ -246,12 +246,12 @@ public PartyDetailResponse getPartyDetail(Long partyId, Long userId, Double user return convertToDetailResponse(party, distance, isParticipating); } - private boolean validateLocation(User user, Double userLat, Double userLon, Party party) { - return (user != null - && userLat != null - && userLon!= null - && party.getPickupLocation().getPickupLatitude()!= null - && party.getPickupLocation().getPickupLongitude()!=null); + private boolean validateLocation(Double userLat, Double userLon, Party party) { + return (userLat != null + && userLon != null + && party.getPickupLocation() != null + && party.getPickupLocation().getPickupLatitude() != null + && party.getPickupLocation().getPickupLongitude() != null); } /** @@ -301,6 +301,7 @@ public Long joinParty(Long partyId, Long userId) { /** * 파티 탈퇴 - 인원 감소 시 다시 모집 중으로 변경 */ + @Transactional public void leaveParty(Long partyId, Long userId) { Party party = partyRepository.findById(partyId) .orElseThrow(() -> new IllegalArgumentException("파티를 찾을 수 없습니다.")); @@ -373,8 +374,6 @@ private PartyCardResponse convertToCardResponse(Party party, .title(party.getTitle()) .pricePerPerson(pricePerPerson) .participantStatus(participantStatus) -// .distance(DistanceCalculator.formatDistance(distanceKm)) -// .distanceKm(distanceKm) .timeAgo(party.getTimeAgo()) .isClosed(party.getIsClosed()) .category(party.getCategory()) @@ -445,20 +444,27 @@ public void updateParty(Long partyId, Long userId, PartyUpdateRequest request) { ); } else { // 승인된 파티원이 없는 경우, 호스트 혼자인 경우: 모든 항목 수정 가능 + PickupLocation updatedPickupLocation = getPickUpLocationIfExists(request, party); party.updateAllFields( request.getTitle(), request.getTotalPrice(), request.getMaxParticipants(), - getPickUpLocationIfExists(request, party), + updatedPickupLocation, request.getProductLink(), request.getDescription(), request.getImages() ); - // 주어진 좌표로 법정동 반환 - String location = locationService.getLocation(request.getPickupLocation().getPickupLatitude().toString(), request.getPickupLocation().getPickupLongitude().toString()); - // place는 pickupLocation, locaton은 town에 저장 - party.updatePartyLocation(request.getPickupLocation(), location); + // pickupLocation이 변경된 경우에만 town 업데이트 + if (request.getPickupLocation() != null + && request.getPickupLocation().getPickupLatitude() != null + && request.getPickupLocation().getPickupLongitude() != null) { + String location = locationService.getLocation( + request.getPickupLocation().getPickupLatitude().toString(), + request.getPickupLocation().getPickupLongitude().toString() + ); + party.updatePartyLocation(updatedPickupLocation, location); + } } } private PickupLocation getPickUpLocationIfExists(PartyUpdateRequest request, Party currentParty) { @@ -813,7 +819,7 @@ private String formatDistanceIfExists(Double distance) { } //카테고리에 따라 파티 조회 - private List fetchPartiesByTown(User user, PartyListRequest request) { + private List fetchPartiesByTown(User user, PartyListRequest request,String myTown) { if (user == null || user.getLocation() == null) { return List.of(); } @@ -831,8 +837,11 @@ private List fetchPartiesByTown(User user, PartyListRequest request) { // 정렬 기준에 따른 Comparator 반환 private Comparator getComparator(PartySortType sortType) { if (sortType == PartySortType.DISTANCE) { - // 거리 가까운 순 - return Comparator.comparing(PartyCardResponse::getDistanceKm) + // 거리 가까운 순 (null은 맨 뒤로) + return Comparator.comparing( + PartyCardResponse::getDistanceKm, + Comparator.nullsLast(Comparator.naturalOrder()) + ) .thenComparing((a, b) -> b.getCreatedAt().compareTo(a.getCreatedAt())); } else { // 최신순 (createdAt 내림차순) @@ -849,6 +858,9 @@ private PartyCardResponse convertToCardResponseWithDistance( } private String getMyTown(Double pickupLatitude, Double pickupLongitude) { + if (pickupLatitude == null || pickupLongitude == null) { + return null; + } return locationService.getLocation(Double.toString(pickupLatitude), Double.toString(pickupLongitude)); } } diff --git a/src/main/java/ita/tinybite/domain/user/controller/UserController.java b/src/main/java/ita/tinybite/domain/user/controller/UserController.java index 538aa53..170d831 100644 --- a/src/main/java/ita/tinybite/domain/user/controller/UserController.java +++ b/src/main/java/ita/tinybite/domain/user/controller/UserController.java @@ -132,11 +132,17 @@ public APIResponse validateRejoin( }) @GetMapping("/parties/hosting") public ResponseEntity> getHostingParties( - @AuthenticationPrincipal Long userId) { - List response = userService.getHostingParties(userId); + @AuthenticationPrincipal Long userId, + @Parameter(description = "사용자 위도") @RequestParam(required = false) Double latitude, + @Parameter(description = "사용자 경도") @RequestParam(required = false) Double longitude) { + List response = userService.getHostingParties(userId, latitude, longitude); return ResponseEntity.ok(response); } + @Operation( + summary = "참가 중인 파티 목록 조회", + description = "현재 사용자가 참가자로 있는 활성 파티 목록을 조회합니다. (호스트 제외)" + ) @ApiResponses({ @ApiResponse( responseCode = "200", @@ -153,8 +159,10 @@ public ResponseEntity> getHostingParties( }) @GetMapping("/parties/participating") public ResponseEntity> getParticipatingParties( - @AuthenticationPrincipal Long userId) { - List response = userService.getParticipatingParties(userId); + @AuthenticationPrincipal Long userId, + @Parameter(description = "사용자 위도") @RequestParam(required = false) Double latitude, + @Parameter(description = "사용자 경도") @RequestParam(required = false) Double longitude) { + List response = userService.getParticipatingParties(userId, latitude, longitude); return ResponseEntity.ok(response); } diff --git a/src/main/java/ita/tinybite/domain/user/service/UserService.java b/src/main/java/ita/tinybite/domain/user/service/UserService.java index 954ee17..9b09618 100644 --- a/src/main/java/ita/tinybite/domain/user/service/UserService.java +++ b/src/main/java/ita/tinybite/domain/user/service/UserService.java @@ -21,6 +21,7 @@ import ita.tinybite.global.exception.errorcode.AuthErrorCode; import ita.tinybite.global.exception.errorcode.UserErrorCode; import ita.tinybite.global.location.LocationService; +import ita.tinybite.global.util.DistanceCalculator; import lombok.RequiredArgsConstructor; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; @@ -177,7 +178,7 @@ public RejoinValidationResponse validateRejoin(String email) { .build(); } - public List getHostingParties(Long userId) { + public List getHostingParties(Long userId, Double latitude, Double longitude) { List parties = partyRepository.findByHostUserIdAndStatus( userId, PartyStatus.RECRUITING @@ -188,12 +189,14 @@ public List getHostingParties(Long userId) { .map(party -> { int currentParticipants = participantRepository .countByPartyIdAndStatus(party.getId(), ParticipantStatus.APPROVED); - return PartyCardResponse.from(party, currentParticipants); + PartyCardResponse response = PartyCardResponse.from(party, currentParticipants); + addDistanceIfPossible(response, party, latitude, longitude); + return response; }) .collect(Collectors.toList()); } - public List getParticipatingParties(Long userId) { + public List getParticipatingParties(Long userId, Double latitude, Double longitude) { List participants = participantRepository .findActivePartiesByUserIdExcludingHost( userId, @@ -208,11 +211,25 @@ public List getParticipatingParties(Long userId) { Party party = pp.getParty(); int currentParticipants = participantRepository .countByPartyIdAndStatus(party.getId(), ParticipantStatus.APPROVED); - return PartyCardResponse.from(party, currentParticipants); + PartyCardResponse response = PartyCardResponse.from(party, currentParticipants); + addDistanceIfPossible(response, party, latitude, longitude); + return response; }) .collect(Collectors.toList()); } + private void addDistanceIfPossible(PartyCardResponse response, Party party, Double latitude, Double longitude) { + if (latitude != null && longitude != null && party.getPickupLocation() != null) { + double distance = DistanceCalculator.calculateDistance( + latitude, + longitude, + party.getPickupLocation().getPickupLatitude(), + party.getPickupLocation().getPickupLongitude() + ); + response.addDistanceKm(distance); + } + } + @Transactional public void updateProfileImage(Long userId, String image) { User user = userRepository.findById(userId)