diff --git a/src/main/java/ita/tinybite/domain/auth/service/AuthService.java b/src/main/java/ita/tinybite/domain/auth/service/AuthService.java index b0c1bf1..e21c0f1 100644 --- a/src/main/java/ita/tinybite/domain/auth/service/AuthService.java +++ b/src/main/java/ita/tinybite/domain/auth/service/AuthService.java @@ -66,16 +66,17 @@ public class AuthService { @Transactional public AuthResponse kakaoSignup(KakaoSignupRequest request) { // 카카오 API로 유저 정보 조회 - KakaoUserInfo kakaoUser = kakaoApiClient.getUserInfo(request.getCode()); +// KakaoUserInfo kakaoUser = kakaoApiClient.getUserInfo(request.getCode()); // 이메일 중복 체크 - if (userRepository.findByEmail(kakaoUser.getKakaoAccount().getEmail()).isPresent()) { - throw new RuntimeException("이미 가입된 이메일입니다."); - } +// if (userRepository.findByEmail(kakaoUser.getKakaoAccount().getEmail()).isPresent()) { +// throw new RuntimeException("이미 가입된 이메일입니다."); +// } // User 엔티티 생성 및 저장 User user = User.builder() - .email(kakaoUser.getKakaoAccount().getEmail()) +// .email(kakaoUser.getKakaoAccount().getEmail()) + .email("ace312@gmail.com") .nickname(request.getNickname()) .location(request.getLocation()) .type(LoginType.KAKAO) 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 d768c54..99e51aa 100644 --- a/src/main/java/ita/tinybite/domain/party/controller/PartyController.java +++ b/src/main/java/ita/tinybite/domain/party/controller/PartyController.java @@ -310,12 +310,10 @@ public ResponseEntity getPartyList( @GetMapping("/{partyId}") public ResponseEntity getPartyDetail( @PathVariable Long partyId, - @RequestHeader("Authorization") String token, + @Parameter(hidden = true) @AuthenticationPrincipal Long userId, @RequestParam(required = false) Double userLat, @RequestParam(required = false) Double userLon ) { - Long userId = jwtTokenProvider.getUserId(token); - PartyDetailResponse response = partyService.getPartyDetail(partyId, userId,userLat,userLon); return ResponseEntity.ok(response); } diff --git a/src/main/java/ita/tinybite/domain/party/dto/request/PartyUpdateRequest.java b/src/main/java/ita/tinybite/domain/party/dto/request/PartyUpdateRequest.java index 1a19b61..8103e0c 100644 --- a/src/main/java/ita/tinybite/domain/party/dto/request/PartyUpdateRequest.java +++ b/src/main/java/ita/tinybite/domain/party/dto/request/PartyUpdateRequest.java @@ -1,5 +1,7 @@ package ita.tinybite.domain.party.dto.request; +import ita.tinybite.domain.party.entity.PickupLocation; +import ita.tinybite.domain.party.enums.PartyCategory; import jakarta.validation.constraints.*; import lombok.*; @@ -23,10 +25,9 @@ public class PartyUpdateRequest { private Integer maxParticipants; @Size(max = 30, message = "수령 장소는 최대 30자까지 입력 가능합니다") - private String pickupLocation; + private PickupLocation pickupLocation; - private Double latitude; - private Double longitude; + private PartyCategory category; // @Pattern(regexp = "^(https?://)?.*", message = "올바른 URL 형식으로 입력해주세요") private String productLink; 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 b3a28aa..1cc9300 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 @@ -42,8 +42,8 @@ public static PartyCardResponse from(Party party, int currentParticipants, boole .build(); } private static String getThumbnailImage(Party party) { - if (party.getImage() != null && !party.getImage().isEmpty()) { - return party.getImage(); + if (party.getImages() != null && !party.getImages().isEmpty()) { + return party.getImages().get(0); } return "/images/default-party-thumbnail.jpg"; // 기본 이미지 } diff --git a/src/main/java/ita/tinybite/domain/party/dto/response/PartyDetailResponse.java b/src/main/java/ita/tinybite/domain/party/dto/response/PartyDetailResponse.java index 4384e0c..d9e6986 100644 --- a/src/main/java/ita/tinybite/domain/party/dto/response/PartyDetailResponse.java +++ b/src/main/java/ita/tinybite/domain/party/dto/response/PartyDetailResponse.java @@ -39,6 +39,8 @@ public class PartyDetailResponse { // 설명 private String description; + private String thumbnailImage; + // 이미지 (최대 5장) private List images; diff --git a/src/main/java/ita/tinybite/domain/party/dto/response/ProductLink.java b/src/main/java/ita/tinybite/domain/party/dto/response/ProductLink.java index acf5a59..0e47fca 100644 --- a/src/main/java/ita/tinybite/domain/party/dto/response/ProductLink.java +++ b/src/main/java/ita/tinybite/domain/party/dto/response/ProductLink.java @@ -6,7 +6,7 @@ @AllArgsConstructor @Builder public class ProductLink { - private String thumbnailImage; - private String productName; +// private String thumbnailImage; +// private String productName; private String url; } 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 9d35131..e60a6a7 100644 --- a/src/main/java/ita/tinybite/domain/party/entity/Party.java +++ b/src/main/java/ita/tinybite/domain/party/entity/Party.java @@ -36,7 +36,7 @@ public class Party { private String thumbnailImage; // 섬네일 이미지 URL @Column(length = 500) - private String image; // 이미지 URL + private List images; // 이미지 URL @Column(nullable = false) private Integer price; // 가격 @@ -132,8 +132,7 @@ public String getTimeAgo() { } public void updateAllFields(String title, Integer price, Integer maxParticipants, - PickupLocation pickupLocation, Double latitude, Double longitude, - String productLink, String description, List images) { + PickupLocation pickupLocation, String productLink, String description, List images) { this.title = title != null ? title : this.title; this.price = price != null ? price : this.price; this.maxParticipants = maxParticipants != null ? maxParticipants : this.maxParticipants; @@ -150,7 +149,7 @@ public void updateAllFields(String title, Integer price, Integer maxParticipants this.description = description != null ? description : this.description; if (images != null && !images.isEmpty()) { - this.image = images.get(0); + this.images = images; this.thumbnailImage = images.get(0); } } @@ -159,7 +158,7 @@ public void updateLimitedFields(String description, List images) { this.description = description != null ? description : this.description; if (images != null && !images.isEmpty()) { - this.image = images.get(0); + this.images = images; this.thumbnailImage = images.get(0); } } diff --git a/src/main/java/ita/tinybite/domain/party/repository/PartyParticipantRepository.java b/src/main/java/ita/tinybite/domain/party/repository/PartyParticipantRepository.java index a03256b..dab65e6 100644 --- a/src/main/java/ita/tinybite/domain/party/repository/PartyParticipantRepository.java +++ b/src/main/java/ita/tinybite/domain/party/repository/PartyParticipantRepository.java @@ -44,4 +44,28 @@ int countByPartyIdAndStatus( @Param("status") ParticipantStatus status ); + /** + * 사용자가 참여중인 파티 개수 조회 (호스트 + 참가자) + */ + @Query("SELECT COUNT(DISTINCT pp.party.id) " + + "FROM PartyParticipant pp " + + "WHERE pp.user.userId = :userId " + + "AND pp.party.status IN :activeStatuses " + + "AND pp.status = :participantStatus") + long countActivePartiesByUserId( + @Param("userId") Long userId, + @Param("activeStatuses") List activeStatuses, + @Param("participantStatus") ParticipantStatus participantStatus + ); + + /** + * 사용자가 호스트인 활성 파티 개수 + */ + @Query("SELECT COUNT(p) FROM Party p " + + "WHERE p.host.userId = :userId " + + "AND p.status IN :activeStatuses") + long countActivePartiesByHostId( + @Param("userId") Long userId, + @Param("activeStatuses") List activeStatuses + ); } \ No newline at end of file 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 5cdad3f..e6dac24 100644 --- a/src/main/java/ita/tinybite/domain/party/service/PartyService.java +++ b/src/main/java/ita/tinybite/domain/party/service/PartyService.java @@ -62,7 +62,7 @@ public Long createParty(Long userId, PartyCreateRequest request) { .pickupLatitude(request.getPickupLocation().getPickupLatitude()) .pickupLongitude(request.getPickupLocation().getPickupLongitude()) .build()) - .image(getImageIfPresent(request.getImages())) + .images(getImagesIfPresent(request.getImages())) .thumbnailImage(getThumbnailIfPresent(request.getImages(), request.getCategory())) .link(getLinkIfValid(request.getProductLink(), request.getCategory())) .description(getDescriptionIfPresent(request.getDescription())) @@ -299,10 +299,10 @@ private PartyDetailResponse convertToDetailResponse(Party party, double distance int pricePerPerson = party.getPrice() / party.getMaxParticipants(); // 이미지 파싱 - List images = new ArrayList<>(); - if (party.getImage() != null && !party.getImage().isEmpty()) { - images = List.of(party.getImage()); - } +// List images = new ArrayList<>(); +// if (party.getImages() != null && !party.getImages().isEmpty()) { +// images = List.of(party.getImages()); +// } return PartyDetailResponse.builder() .partyId(party.getId()) @@ -315,6 +315,7 @@ private PartyDetailResponse convertToDetailResponse(Party party, double distance .profileImage(party.getHost().getProfileImage()) .build()) .pickupLocation(party.getPickupLocation()) + .thumbnailImage(party.getThumbnailImage()) .distance(formatDistanceIfExists(distance)) .currentParticipants(currentCount) .maxParticipants(party.getMaxParticipants()) @@ -326,7 +327,7 @@ private PartyDetailResponse convertToDetailResponse(Party party, double distance .url(party.getLink()) .build() : null) .description(party.getDescription()) - .images(images) + .images(party.getImages()) .isClosed(party.getIsClosed()) .isParticipating(isParticipating) .build(); @@ -357,10 +358,8 @@ public void updateParty(Long partyId, Long userId, PartyUpdateRequest request) { request.getTitle(), request.getTotalPrice(), request.getMaxParticipants(), - new PickupLocation(request.getPickupLocation(), request.getLatitude(), request.getLongitude()), + getPickUpLocationIfExists(request,party), // new PickupLocation(request.getPickupLocation()), - request.getLatitude(), - request.getLongitude(), request.getProductLink(), request.getDescription(), request.getImages() @@ -368,6 +367,30 @@ public void updateParty(Long partyId, Long userId, PartyUpdateRequest request) { } } + private PickupLocation getPickUpLocationIfExists(PartyUpdateRequest request, Party currentParty) { + if (request.getPickupLocation() == null) { + return currentParty.getPickupLocation(); + } + PickupLocation requestPickup = request.getPickupLocation(); + PickupLocation currentPickup = currentParty.getPickupLocation(); + + // 각 필드별로 새 값이 있으면 사용, 없으면 기존 값 유지 + String place = requestPickup.getPlace() != null + ? requestPickup.getPlace() + : (currentPickup != null ? currentPickup.getPlace() : ""); + + Double latitude = requestPickup.getPickupLatitude() != null + ? requestPickup.getPickupLatitude() + : (currentPickup != null ? currentPickup.getPickupLatitude() : null); + + Double longitude = requestPickup.getPickupLongitude() != null + ? requestPickup.getPickupLongitude() + : (currentPickup != null ? currentPickup.getPickupLongitude() : null); + + return new PickupLocation(place, latitude, longitude); + + } + @Transactional public void deleteParty(Long partyId, Long userId) { Party party = partyRepository.findById(partyId) @@ -617,8 +640,8 @@ private void checkAndCloseIfFull(Party party) { } // 헬퍼 메서드들 - private String getImageIfPresent(List images) { - return (images != null && !images.isEmpty()) ? images.get(0) : null; + private List getImagesIfPresent(List images) { + return (images != null && !images.isEmpty()) ? images : null; } private String getThumbnailIfPresent(List images, PartyCategory category) { 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 ee2df3c..dde518f 100644 --- a/src/main/java/ita/tinybite/domain/user/controller/UserController.java +++ b/src/main/java/ita/tinybite/domain/user/controller/UserController.java @@ -1,6 +1,7 @@ package ita.tinybite.domain.user.controller; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; @@ -8,7 +9,9 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses; import ita.tinybite.domain.party.dto.response.PartyCardResponse; import ita.tinybite.domain.user.dto.req.UpdateUserReqDto; +import ita.tinybite.domain.user.dto.res.RejoinValidationResponse; import ita.tinybite.domain.user.dto.res.UserResDto; +import ita.tinybite.domain.user.dto.res.WithDrawValidationResponse; import ita.tinybite.domain.user.service.UserService; import ita.tinybite.global.response.APIResponse; import jakarta.validation.Valid; @@ -67,17 +70,47 @@ public APIResponse updateLocation(@RequestParam(defaultValue = "37.3623504988 return success(); } + @Operation( + summary = "회원 탈퇴 가능 여부 확인", + description = "진행 중인 파티가 있는지 확인하여 탈퇴 가능 여부를 반환합니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "확인 성공"), + @ApiResponse(responseCode = "401", description = "인증 실패") + }) + @GetMapping("/me/withdrawal/validate") + public APIResponse validateWithdrawal( + @Parameter(hidden = true) @AuthenticationPrincipal Long userId) { + WithDrawValidationResponse response = userService.validateWithdrawal(userId); + return success(response); + } + @Operation(summary = "회원 탈퇴", description = "현재 로그인한 사용자를 삭제합니다.") @ApiResponses({ @ApiResponse(responseCode = "200", description = "탈퇴 성공"), @ApiResponse(responseCode = "401", description = "인증 실패") }) @DeleteMapping("/me") - public APIResponse deleteUser() { - userService.deleteUser(); + public APIResponse deleteUser(@AuthenticationPrincipal Long userId) { + userService.deleteUser(userId); return success(); } + @Operation( + summary = "재가입 가능 여부 확인", + description = "탈퇴 후 30일 이내인지 확인합니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "확인 성공") + }) + @GetMapping("/rejoin/validate") + public APIResponse validateRejoin( + @Parameter(description = "이메일", required = true) + @RequestParam String email) { + RejoinValidationResponse response = userService.validateRejoin(email); + return success(response); + } + @Operation(summary = "활성 파티 목록 조회", description = "사용자가 참여 중인 활성 파티 목록을 조회합니다.") @ApiResponses({ @ApiResponse(responseCode = "200", description = "조회 성공", diff --git a/src/main/java/ita/tinybite/domain/user/dto/res/RejoinValidationResponse.java b/src/main/java/ita/tinybite/domain/user/dto/res/RejoinValidationResponse.java new file mode 100644 index 0000000..b009bb3 --- /dev/null +++ b/src/main/java/ita/tinybite/domain/user/dto/res/RejoinValidationResponse.java @@ -0,0 +1,17 @@ +package ita.tinybite.domain.user.dto.res; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@AllArgsConstructor +@Builder +public class RejoinValidationResponse { + private boolean canRejoin; + private Long daysRemaining; + private LocalDateTime canRejoinAt; + private String message; +} diff --git a/src/main/java/ita/tinybite/domain/user/dto/res/WithDrawValidationResponse.java b/src/main/java/ita/tinybite/domain/user/dto/res/WithDrawValidationResponse.java new file mode 100644 index 0000000..5293f61 --- /dev/null +++ b/src/main/java/ita/tinybite/domain/user/dto/res/WithDrawValidationResponse.java @@ -0,0 +1,16 @@ +package ita.tinybite.domain.user.dto.res; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@AllArgsConstructor +@Builder +public class WithDrawValidationResponse { + private boolean canWithdraw; + private long activePartyCount; + private long hostPartyCount; + private long participantPartyCount; + private String message; +} diff --git a/src/main/java/ita/tinybite/domain/user/entity/User.java b/src/main/java/ita/tinybite/domain/user/entity/User.java index 192439d..512a598 100644 --- a/src/main/java/ita/tinybite/domain/user/entity/User.java +++ b/src/main/java/ita/tinybite/domain/user/entity/User.java @@ -10,6 +10,7 @@ import lombok.*; import org.hibernate.annotations.Comment; +import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; @@ -49,6 +50,8 @@ public class User extends BaseEntity { @Column(length = 100) private String location; + private LocalDateTime withdrawAt; + @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) private List agreements = new ArrayList<>();; @@ -72,4 +75,16 @@ public void updateSignupInfo(GoogleAndAppleSignupRequest req, String email, Logi public void addTerms(List agreements) { this.agreements.addAll(agreements); } + + public void withdraw() { + this.nickname = "탈퇴한 사용자"; + this.profileImage = "/images/default-profile.jpg"; + this.status = UserStatus.WITHDRAW; + this.withdrawAt = LocalDateTime.now(); + } + + // 탈퇴 여부 확인 + public boolean isWithdrawn() { + return this.status == UserStatus.WITHDRAW; + } } diff --git a/src/main/java/ita/tinybite/domain/user/entity/WithDrawUser.java b/src/main/java/ita/tinybite/domain/user/entity/WithDrawUser.java new file mode 100644 index 0000000..af70f7b --- /dev/null +++ b/src/main/java/ita/tinybite/domain/user/entity/WithDrawUser.java @@ -0,0 +1,49 @@ +package ita.tinybite.domain.user.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; + +@Entity +@Table(name = "withdrawn_users") +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class WithDrawUser { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true) + private String email; // 또는 소셜 로그인 ID + + @Column(nullable = false) + private LocalDateTime withdrawnAt; + + @Column(nullable = false) + private LocalDateTime canRejoinAt; // 재가입 가능 일시 (탈퇴 + 30일) + + public static WithDrawUser from(User user) { + LocalDateTime withdrawnAt = LocalDateTime.now(); + return WithDrawUser.builder() + .email(user.getEmail()) + .withdrawnAt(withdrawnAt) + .canRejoinAt(withdrawnAt.plusDays(30)) + .build(); + } + + public boolean canRejoin() { + return LocalDateTime.now().isAfter(canRejoinAt); + } + + public long getDaysUntilRejoin() { + return ChronoUnit.DAYS.between(LocalDateTime.now(), canRejoinAt); + } +} diff --git a/src/main/java/ita/tinybite/domain/user/repository/WithDrawUserRepository.java b/src/main/java/ita/tinybite/domain/user/repository/WithDrawUserRepository.java new file mode 100644 index 0000000..75b2802 --- /dev/null +++ b/src/main/java/ita/tinybite/domain/user/repository/WithDrawUserRepository.java @@ -0,0 +1,23 @@ +package ita.tinybite.domain.user.repository; + +import io.lettuce.core.dynamic.annotation.Param; +import ita.tinybite.domain.user.entity.WithDrawUser; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import java.time.LocalDateTime; +import java.util.Optional; + +public interface WithDrawUserRepository extends JpaRepository { + Optional findByEmail(String email); + + boolean existsByEmail(String email); + + @Query("SELECT w FROM WithDrawUser w " + + "WHERE w.email = :email " + + "AND w.canRejoinAt > :now") + Optional findActiveWithdrawUser( + @Param("email") String email, + @Param("now") LocalDateTime now + ); +} 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 68c25f0..4b7f9f0 100644 --- a/src/main/java/ita/tinybite/domain/user/service/UserService.java +++ b/src/main/java/ita/tinybite/domain/user/service/UserService.java @@ -8,16 +8,24 @@ import ita.tinybite.domain.party.enums.PartyStatus; import ita.tinybite.domain.party.repository.PartyParticipantRepository; import ita.tinybite.domain.user.dto.req.UpdateUserReqDto; +import ita.tinybite.domain.user.dto.res.RejoinValidationResponse; import ita.tinybite.domain.user.dto.res.UserResDto; +import ita.tinybite.domain.user.dto.res.WithDrawValidationResponse; import ita.tinybite.domain.user.entity.User; +import ita.tinybite.domain.user.entity.WithDrawUser; import ita.tinybite.domain.user.repository.UserRepository; +import ita.tinybite.domain.user.repository.WithDrawUserRepository; +import ita.tinybite.global.exception.ActivePartyExistsException; import ita.tinybite.global.exception.BusinessException; 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.time.LocalDateTime; +import java.util.Arrays; import java.util.List; +import java.util.Optional; import java.util.stream.Collectors; @Service @@ -27,16 +35,19 @@ public class UserService { private final SecurityProvider securityProvider; private final UserRepository userRepository; private final LocationService locationService; + private final WithDrawUserRepository withDrawUserRepository; private final PartyParticipantRepository participantRepository; public UserService(SecurityProvider securityProvider, UserRepository userRepository, LocationService locationService, - PartyParticipantRepository participantRepository) { + PartyParticipantRepository participantRepository, + WithDrawUserRepository withDrawUserRepository) { this.securityProvider = securityProvider; this.userRepository = userRepository; this.locationService = locationService; this.participantRepository = participantRepository; + this.withDrawUserRepository = withDrawUserRepository; } public UserResDto getUser() { @@ -57,16 +68,35 @@ public void updateLocation(String latitude, String longitude) { user.updateLocation(location); } + /** + * 회원 탈퇴 처리 + */ @Transactional - public void deleteUser() { - userRepository.delete(securityProvider.getCurrentUser()); - } + public void deleteUser(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new Error("해당하는 유저가 없습니다")); - public void validateNickname(String nickname) { - if(userRepository.existsByNickname(nickname)) - throw BusinessException.of(AuthErrorCode.DUPLICATED_NICKNAME); + // 1. 탈퇴 가능 여부 검증 + WithDrawValidationResponse validation = validateWithdrawal(userId); + if (!validation.isCanWithdraw()) { + throw new ActivePartyExistsException( + "진행 중인 파티가 " + validation.getActivePartyCount() + "개 있습니다. " + + "모든 파티를 종료하거나 나간 후 탈퇴해 주세요." + ); + } + + // 2. 채팅방에 퇴장 메시지 전송 +// chatRoomService.notifyUserWithdrawal(userId, user.getNickname()); + + // 3. 탈퇴 기록 생성 (재가입 제한용) + WithDrawUser withdrawnUser = WithDrawUser.from(user); + withDrawUserRepository.save(withdrawnUser); + + // 4. 사용자 정보 익명화 + user.withdraw(); } + public List getActiveParties(Long userId) { List participants = participantRepository .findActivePartiesByUserId( @@ -81,8 +111,76 @@ public List getActiveParties(Long userId) { int currentParticipants = participantRepository .countByPartyIdAndStatus(party.getId(), ParticipantStatus.APPROVED); boolean isHost = party.getHost().getUserId().equals(userId); - return PartyCardResponse.from(party, currentParticipants, isHost,pp.getStatus()); + return PartyCardResponse.from(party, currentParticipants, isHost, pp.getStatus()); }) .collect(Collectors.toList()); } -} + + /** + * 회원 탈퇴 가능 여부 확인 + */ + public WithDrawValidationResponse validateWithdrawal(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new Error("유저를 찾을 수 없습니다")); + + // 1. 호스트로 있는 활성 파티 확인 + long hostPartyCount = participantRepository.countActivePartiesByHostId( + userId, + Arrays.asList(PartyStatus.RECRUITING, PartyStatus.RECRUITING) + ); + + // 2. 참가자로 있는 활성 파티 확인 + long participantPartyCount = participantRepository.countActivePartiesByUserId( + userId, + Arrays.asList(PartyStatus.RECRUITING, PartyStatus.RECRUITING), + ParticipantStatus.APPROVED + ); + + boolean canWithdraw = (hostPartyCount == 0 && participantPartyCount == 0); + long totalActiveParties = hostPartyCount + participantPartyCount; + + if(!canWithdraw) throw new ActivePartyExistsException("진행 중인 파티가 있습니다. 모든 파티를 종료하거나 나간 후 탈퇴해 주세요"); + + return WithDrawValidationResponse.builder() + .canWithdraw(canWithdraw) + .activePartyCount(totalActiveParties) + .hostPartyCount(hostPartyCount) + .participantPartyCount(participantPartyCount) + .message("탈퇴 가능합니다.") + .build(); + } + + public void validateNickname(String nickname) { + if (userRepository.existsByNickname(nickname)) + throw BusinessException.of(AuthErrorCode.DUPLICATED_NICKNAME); + } + + /** + * 재가입 가능 여부 확인 + */ + public RejoinValidationResponse validateRejoin(String email) { + Optional withdrawnUserOpt = withDrawUserRepository + .findActiveWithdrawUser(email, LocalDateTime.now()); + + if (withdrawnUserOpt.isEmpty()) { + return RejoinValidationResponse.builder() + .canRejoin(true) + .message("가입 가능합니다.") + .build(); + } + + WithDrawUser withdrawnUser = withdrawnUserOpt.get(); + long daysRemaining = withdrawnUser.getDaysUntilRejoin(); + + return RejoinValidationResponse.builder() + .canRejoin(false) + .daysRemaining(daysRemaining) + .canRejoinAt(withdrawnUser.getCanRejoinAt()) + .message(String.format( + "탈퇴 후 30일간 재가입이 제한됩니다. %d일 후 가입 가능합니다.", + daysRemaining + )) + .build(); + } + +} \ No newline at end of file diff --git a/src/main/java/ita/tinybite/global/exception/ActivePartyExistsException.java b/src/main/java/ita/tinybite/global/exception/ActivePartyExistsException.java new file mode 100644 index 0000000..5293241 --- /dev/null +++ b/src/main/java/ita/tinybite/global/exception/ActivePartyExistsException.java @@ -0,0 +1,11 @@ +package ita.tinybite.global.exception; + +import ita.tinybite.global.exception.errorcode.ErrorCode; +import lombok.Getter; + +@Getter +public class ActivePartyExistsException extends RuntimeException { + public ActivePartyExistsException(String message) { + super(message); + } +} diff --git a/src/main/java/ita/tinybite/global/exception/CannotRejoinException.java b/src/main/java/ita/tinybite/global/exception/CannotRejoinException.java new file mode 100644 index 0000000..cc969f2 --- /dev/null +++ b/src/main/java/ita/tinybite/global/exception/CannotRejoinException.java @@ -0,0 +1,7 @@ +package ita.tinybite.global.exception; + +public class CannotRejoinException extends RuntimeException { + public CannotRejoinException(String message) { + super(message); + } +} \ No newline at end of file diff --git a/src/test/java/ita/tinybite/domain/user/service/UserServiceTest.java b/src/test/java/ita/tinybite/domain/user/service/UserServiceTest.java index 7a05fba..a5f9a4c 100644 --- a/src/test/java/ita/tinybite/domain/user/service/UserServiceTest.java +++ b/src/test/java/ita/tinybite/domain/user/service/UserServiceTest.java @@ -9,6 +9,7 @@ import ita.tinybite.domain.user.dto.res.UserResDto; import ita.tinybite.domain.user.entity.User; import ita.tinybite.domain.user.repository.UserRepository; +import ita.tinybite.domain.user.repository.WithDrawUserRepository; import ita.tinybite.domain.user.service.fake.FakeLocationService; import ita.tinybite.domain.user.service.fake.FakeSecurityProvider; import ita.tinybite.global.exception.BusinessException; @@ -25,8 +26,12 @@ class UserServiceTest { @Autowired private UserRepository userRepository; + @Autowired private PartyParticipantRepository participantRepository; + @Autowired + private WithDrawUserRepository withDrawUserRepository; + @Autowired private AuthService authService; @@ -41,7 +46,7 @@ class UserServiceTest { void setUp() { securityProvider = new FakeSecurityProvider(userRepository); locationService = new FakeLocationService(); - userService = new UserService(securityProvider, userRepository, locationService,participantRepository); + userService = new UserService(securityProvider, userRepository, locationService,participantRepository,withDrawUserRepository); User user = User.builder() .email("yyytir777@gmail.com") @@ -86,10 +91,10 @@ void updateLocation() { } @Test - void deleteUser() { + void deleteUser(Long userId) { // when User currentUser = securityProvider.getCurrentUser(); - userService.deleteUser(); + userService.deleteUser(userId); // then assertThat(userRepository.findById(currentUser.getUserId())).isEmpty();