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 78d2adf..6acd6b1 100644 --- a/src/main/java/ita/tinybite/domain/party/controller/PartyController.java +++ b/src/main/java/ita/tinybite/domain/party/controller/PartyController.java @@ -11,6 +11,7 @@ import ita.tinybite.domain.chat.entity.ChatRoom; import ita.tinybite.domain.party.dto.request.PartyCreateRequest; import ita.tinybite.domain.party.dto.request.PartyUpdateRequest; +import ita.tinybite.domain.party.dto.request.UserLocation; import ita.tinybite.domain.party.dto.response.ChatRoomResponse; import ita.tinybite.domain.party.dto.response.PartyDetailResponse; import ita.tinybite.domain.party.dto.response.PartyListResponse; @@ -319,11 +320,12 @@ public ResponseEntity getPartyList( @GetMapping("/{partyId}") public ResponseEntity getPartyDetail( @PathVariable Long partyId, - @RequestHeader("Authorization") String token + @RequestHeader("Authorization") String token, + @RequestBody UserLocation userLocation ) { Long userId = jwtTokenProvider.getUserId(token); - PartyDetailResponse response = partyService.getPartyDetail(partyId, userId); + PartyDetailResponse response = partyService.getPartyDetail(partyId, userId,userLocation); return ResponseEntity.ok(response); } diff --git a/src/main/java/ita/tinybite/domain/party/dto/request/PartyCreateRequest.java b/src/main/java/ita/tinybite/domain/party/dto/request/PartyCreateRequest.java index 1cf1457..f5b95bf 100644 --- a/src/main/java/ita/tinybite/domain/party/dto/request/PartyCreateRequest.java +++ b/src/main/java/ita/tinybite/domain/party/dto/request/PartyCreateRequest.java @@ -34,12 +34,6 @@ public class PartyCreateRequest { @NotNull(message = "수령 장소 정보는 필수입니다") private PickupLocation pickupLocation; -// @NotNull(message = "위도는 필수입니다") -// private Double latitude; -// -// @NotNull(message = "경도는 필수입니다") -// private Double longitude; - @Size(max = 5, message = "이미지는 최대 5장까지 업로드 가능합니다") private List images; diff --git a/src/main/java/ita/tinybite/domain/party/dto/request/UserLocation.java b/src/main/java/ita/tinybite/domain/party/dto/request/UserLocation.java new file mode 100644 index 0000000..bad4880 --- /dev/null +++ b/src/main/java/ita/tinybite/domain/party/dto/request/UserLocation.java @@ -0,0 +1,13 @@ +package ita.tinybite.domain.party.dto.request; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@AllArgsConstructor +@NoArgsConstructor +public class UserLocation { + Double latitude; + Double longitude; +} 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 c05ed69..b3a28aa 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 @@ -1,8 +1,13 @@ package ita.tinybite.domain.party.dto.response; +import ita.tinybite.domain.party.entity.Party; +import ita.tinybite.domain.party.enums.ParticipantStatus; import ita.tinybite.domain.party.enums.PartyCategory; +import ita.tinybite.domain.party.enums.PartyStatus; import lombok.*; +import java.time.Duration; import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; @Getter @NoArgsConstructor @@ -20,4 +25,88 @@ public class PartyCardResponse { private Boolean isClosed; // 마감 여부 private PartyCategory category; private LocalDateTime createdAt; + + public static PartyCardResponse from(Party party, int currentParticipants, boolean isHost, ParticipantStatus status) { + 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) // 거리 계산은 별도 처리 필요 + .timeAgo(calculateTimeAgo(party.getCreatedAt())) + .isClosed(checkIfClosed(party, currentParticipants)) + .category(party.getCategory()) + .createdAt(party.getCreatedAt()) + .build(); + } + private static String getThumbnailImage(Party party) { + if (party.getImage() != null && !party.getImage().isEmpty()) { + return party.getImage(); + } + return "/images/default-party-thumbnail.jpg"; // 기본 이미지 + } + + /** + * 1/N 가격 계산 + */ + private static Integer calculatePricePerPerson(Party party, int currentParticipants) { + if (party.getPrice() == null || currentParticipants == 0) { + return null; + } + return party.getPrice() / currentParticipants; + } + + /** + * 참가자 상태 포맷팅 "1/4명" + */ + private static String formatParticipantStatus(int current, int max) { + return String.format("%d/%d명", current, max); + } + + /** + * 시간 경과 계산 "10분 전", "3시간 전" + */ + private static String calculateTimeAgo(LocalDateTime createdAt) { + if (createdAt == null) { + return ""; + } + + LocalDateTime now = LocalDateTime.now(); + Duration duration = Duration.between(createdAt, now); + + long minutes = duration.toMinutes(); + long hours = duration.toHours(); + long days = duration.toDays(); + + if (minutes < 1) { + return "방금 전"; + } else if (minutes < 60) { + return minutes + "분 전"; + } else if (hours < 24) { + return hours + "시간 전"; + } else if (days < 7) { + return days + "일 전"; + } else { + return createdAt.format(DateTimeFormatter.ofPattern("MM.dd")); + } + } + + /** + * 마감 여부 확인 + */ + private static Boolean checkIfClosed(Party party, int currentParticipants) { + // 1. 파티 상태가 CLOSED인 경우 + if (party.getStatus() == PartyStatus.CLOSED) { + return true; + } + + // 2. 정원이 다 찬 경우 + if (currentParticipants >= party.getMaxParticipants()) { + return true; + } + + return false; + } } \ No newline at end of file diff --git a/src/main/java/ita/tinybite/domain/party/entity/Party.java b/src/main/java/ita/tinybite/domain/party/entity/Party.java index 819d350..9d35131 100644 --- a/src/main/java/ita/tinybite/domain/party/entity/Party.java +++ b/src/main/java/ita/tinybite/domain/party/entity/Party.java @@ -62,12 +62,6 @@ public class Party { @Column(nullable = false) private PartyStatus status; -// @Column(nullable = false) -// private Double latitude; // 위도 (거리 계산용) -// -// @Column(nullable = false) -// private Double longitude; // 경도 (거리 계산용) - @Column(nullable = false) private Boolean isClosed; // 마감 여부 @@ -144,8 +138,6 @@ public void updateAllFields(String title, Integer price, Integer maxParticipants this.price = price != null ? price : this.price; this.maxParticipants = maxParticipants != null ? maxParticipants : this.maxParticipants; this.pickupLocation = pickupLocation != null ? pickupLocation : this.pickupLocation; -// this.latitude = latitude != null ? latitude : this.latitude; -// this.longitude = longitude != null ? longitude : this.longitude; // 상품 링크 검증 if (productLink != null) { diff --git a/src/main/java/ita/tinybite/domain/party/entity/PickupLocation.java b/src/main/java/ita/tinybite/domain/party/entity/PickupLocation.java index 548a93b..9fe01dc 100644 --- a/src/main/java/ita/tinybite/domain/party/entity/PickupLocation.java +++ b/src/main/java/ita/tinybite/domain/party/entity/PickupLocation.java @@ -17,8 +17,8 @@ public class PickupLocation { private String place; // @NotNull(message = "위도는 필수입니다") -// private Double pickupLatitude; + private Double pickupLatitude; // // @NotNull(message = "경도는 필수입니다") -// private Double pickupLongitude; + private Double pickupLongitude; } 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 4080a0d..6639623 100644 --- a/src/main/java/ita/tinybite/domain/party/service/PartyService.java +++ b/src/main/java/ita/tinybite/domain/party/service/PartyService.java @@ -5,6 +5,7 @@ import ita.tinybite.domain.chat.repository.ChatRoomRepository; import ita.tinybite.domain.party.dto.request.PartyCreateRequest; import ita.tinybite.domain.party.dto.request.PartyUpdateRequest; +import ita.tinybite.domain.party.dto.request.UserLocation; import ita.tinybite.domain.party.dto.response.*; import ita.tinybite.domain.party.entity.Party; import ita.tinybite.domain.party.entity.PartyParticipant; @@ -51,9 +52,6 @@ public Long createParty(Long userId, PartyCreateRequest request) { // 카테고리별 유효성 검증 validateProductLink(request.getCategory(), request.getProductLink()); - // 첫 번째 이미지를 썸네일로 사용, 없으면 기본 이미지 - String thumbnailImage = getDefaultImageIfEmpty(request.getImages(), request.getCategory()); - Party party = Party.builder() .title(request.getTitle()) .category(request.getCategory()) @@ -61,6 +59,8 @@ public Long createParty(Long userId, PartyCreateRequest request) { .maxParticipants(request.getMaxParticipants()) .pickupLocation(PickupLocation.builder() .place(request.getPickupLocation().getPlace()) + .pickupLatitude(request.getPickupLocation().getPickupLatitude()) + .pickupLongitude(request.getPickupLocation().getPickupLongitude()) .build()) .image(getImageIfPresent(request.getImages())) .thumbnailImage(getThumbnailIfPresent(request.getImages(), request.getCategory())) @@ -182,7 +182,7 @@ public PartyListResponse getPartyList(Long userId, PartyCategory category) { /** * 파티 상세 조회 */ - public PartyDetailResponse getPartyDetail(Long partyId, Long userId) { + public PartyDetailResponse getPartyDetail(Long partyId, Long userId, UserLocation userLocation) { Party party = partyRepository.findById(partyId) .orElseThrow(() -> new IllegalArgumentException("파티를 찾을 수 없습니다")); @@ -200,18 +200,26 @@ public PartyDetailResponse getPartyDetail(Long partyId, Long userId) { // 거리 계산 (사용자 위치 필요) double distance = 0.0; -// if (user != null) { -// distance = DistanceCalculator.calculateDistance( -// userLat, -// userLon, -// party.getLatitude(), -// party.getLongitude() -// ); -// } + if (validateLocation(user,userLocation,party)) { + distance = DistanceCalculator.calculateDistance( + userLocation.getLatitude(), + userLocation.getLongitude(), + party.getPickupLocation().getPickupLatitude(), + party.getPickupLocation().getPickupLongitude() + ); + } return convertToDetailResponse(party, distance, isParticipating); } + private boolean validateLocation(User user, UserLocation userLocation, Party party) { + return (user != null + && userLocation.getLatitude() != null + && userLocation.getLongitude()!= null + && party.getPickupLocation().getPickupLatitude()!= null + && party.getPickupLocation().getPickupLongitude()!=null); + } + /** * 파티 참여 */ @@ -293,7 +301,7 @@ private PartyDetailResponse convertToDetailResponse(Party party, double distance // 이미지 파싱 List images = new ArrayList<>(); if (party.getImage() != null && !party.getImage().isEmpty()) { - images = List.of(party.getImage().toString()); + images = List.of(party.getImage()); } return PartyDetailResponse.builder() @@ -307,7 +315,7 @@ private PartyDetailResponse convertToDetailResponse(Party party, double distance .profileImage(party.getHost().getProfileImage()) .build()) .pickupLocation(party.getPickupLocation()) -// .distance(DistanceCalculator.formatDistance(distance)) + .distance(formatDistanceIfExists(distance)) .currentParticipants(currentCount) .maxParticipants(party.getMaxParticipants()) .remainingSlots(party.getMaxParticipants() - currentCount) @@ -349,8 +357,8 @@ public void updateParty(Long partyId, Long userId, PartyUpdateRequest request) { request.getTitle(), request.getTotalPrice(), request.getMaxParticipants(), -// new PickupLocation(request.getPickupLocation(), request.getLatitude(), request.getLongitude()), - new PickupLocation(request.getPickupLocation()), + new PickupLocation(request.getPickupLocation(), request.getLatitude(), request.getLongitude()), +// new PickupLocation(request.getPickupLocation()), request.getLatitude(), request.getLongitude(), request.getProductLink(), @@ -631,5 +639,9 @@ private String getLinkIfValid(String link, PartyCategory category) { private String getDescriptionIfPresent(String description) { return (description != null && !description.isBlank()) ? description : null; } + + private String formatDistanceIfExists(Double distance) { + return distance!= null? DistanceCalculator.formatDistance(distance):null; + } } 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 af7f0b5..ee2df3c 100644 --- a/src/main/java/ita/tinybite/domain/user/controller/UserController.java +++ b/src/main/java/ita/tinybite/domain/user/controller/UserController.java @@ -6,9 +6,8 @@ import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; -import ita.tinybite.domain.auth.dto.response.UserDto; +import ita.tinybite.domain.party.dto.response.PartyCardResponse; import ita.tinybite.domain.user.dto.req.UpdateUserReqDto; -import ita.tinybite.domain.user.dto.res.PartyResponse; import ita.tinybite.domain.user.dto.res.UserResDto; import ita.tinybite.domain.user.service.UserService; import ita.tinybite.global.response.APIResponse; @@ -82,13 +81,13 @@ public APIResponse deleteUser() { @Operation(summary = "활성 파티 목록 조회", description = "사용자가 참여 중인 활성 파티 목록을 조회합니다.") @ApiResponses({ @ApiResponse(responseCode = "200", description = "조회 성공", - content = @Content(array = @ArraySchema(schema = @Schema(implementation = PartyResponse.class)))), + content = @Content(array = @ArraySchema(schema = @Schema(implementation = PartyCardResponse.class)))), @ApiResponse(responseCode = "401", description = "인증 실패") }) @GetMapping("/parties/active") - public ResponseEntity> getActiveParties( + public ResponseEntity> getActiveParties( @AuthenticationPrincipal Long userId) { - List response = userService.getActiveParties(userId); + List response = userService.getActiveParties(userId); return ResponseEntity.ok(response); } diff --git a/src/main/java/ita/tinybite/domain/user/dto/res/PartyResponse.java b/src/main/java/ita/tinybite/domain/user/dto/res/PartyResponse.java deleted file mode 100644 index 6fd56df..0000000 --- a/src/main/java/ita/tinybite/domain/user/dto/res/PartyResponse.java +++ /dev/null @@ -1,45 +0,0 @@ -package ita.tinybite.domain.user.dto.res; - -import ita.tinybite.domain.party.entity.Party; -import ita.tinybite.domain.party.enums.ParticipantStatus; -import ita.tinybite.domain.party.enums.PartyStatus; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; - -import java.time.LocalDateTime; - -@Getter -@AllArgsConstructor -@Builder -public class PartyResponse { - private Long id; - private String title; - private String description; - private Integer maxParticipants; - private Integer currentParticipants; - private PartyStatus status; - private String hostUsername; - private LocalDateTime startDate; - private LocalDateTime endDate; - private LocalDateTime createdAt; - private boolean isHost; - private ParticipantStatus participantStatus; - - public static PartyResponse from(Party party, int currentParticipants, boolean isHost, ParticipantStatus participantStatus) { - return PartyResponse.builder() - .id(party.getId()) - .title(party.getTitle()) - .description(party.getDescription()) - .maxParticipants(party.getMaxParticipants()) - .currentParticipants(currentParticipants) - .status(party.getStatus()) - .hostUsername(party.getHost().getNickname()) - .startDate(party.getCreatedAt()) - .endDate(party.getClosedAt()) - .createdAt(party.getCreatedAt()) - .isHost(isHost) - .participantStatus(participantStatus) - .build(); - } -} 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 95ff669..c2ac755 100644 --- a/src/main/java/ita/tinybite/domain/user/service/UserService.java +++ b/src/main/java/ita/tinybite/domain/user/service/UserService.java @@ -1,14 +1,13 @@ package ita.tinybite.domain.user.service; import ita.tinybite.domain.auth.service.SecurityProvider; +import ita.tinybite.domain.party.dto.response.PartyCardResponse; import ita.tinybite.domain.party.entity.Party; import ita.tinybite.domain.party.entity.PartyParticipant; import ita.tinybite.domain.party.enums.ParticipantStatus; import ita.tinybite.domain.party.enums.PartyStatus; import ita.tinybite.domain.party.repository.PartyParticipantRepository; -import ita.tinybite.domain.user.constant.UserStatus; import ita.tinybite.domain.user.dto.req.UpdateUserReqDto; -import ita.tinybite.domain.user.dto.res.PartyResponse; import ita.tinybite.domain.user.dto.res.UserResDto; import ita.tinybite.domain.user.entity.User; import ita.tinybite.domain.user.repository.UserRepository; @@ -16,11 +15,13 @@ import ita.tinybite.global.exception.errorcode.AuthErrorCode; import ita.tinybite.global.location.LocationService; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.util.List; import java.util.stream.Collectors; @Service +@Transactional(readOnly = true) public class UserService { private final SecurityProvider securityProvider; @@ -43,17 +44,20 @@ public UserResDto getUser() { return UserResDto.of(user); } + @Transactional public void updateUser(UpdateUserReqDto req) { User user = securityProvider.getCurrentUser(); user.update(req); } + @Transactional public void updateLocation(String latitude, String longitude) { User user = securityProvider.getCurrentUser(); String location = locationService.getLocation(latitude, longitude); user.updateLocation(location); } + @Transactional public void deleteUser() { userRepository.delete(securityProvider.getCurrentUser()); } @@ -63,7 +67,7 @@ public void validateNickname(String nickname) { throw BusinessException.of(AuthErrorCode.DUPLICATED_NICKNAME); } - public List getActiveParties(Long userId) { + public List getActiveParties(Long userId) { List participants = participantRepository .findActivePartiesByUserId( userId, @@ -77,7 +81,7 @@ public List getActiveParties(Long userId) { int currentParticipants = participantRepository .countByPartyIdAndStatus(party.getId(), ParticipantStatus.APPROVED); boolean isHost = party.getHost().getUserId().equals(userId); - return PartyResponse.from(party, currentParticipants, isHost,pp.getStatus()); + return PartyCardResponse.from(party, currentParticipants, isHost,pp.getStatus()); }) .collect(Collectors.toList()); }