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/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/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();