66import com .leets .tdd .global .storage .ImageStorageService ;
77import com .leets .tdd .global .storage .dto .PresignedUploadResponse ;
88import com .leets .tdd .auth .service .EmailVerificationService ;
9+ import com .leets .tdd .party .domain .PartyParticipant ;
10+ import com .leets .tdd .party .domain .PartyParticipantStatus ;
11+ import com .leets .tdd .party .domain .PartyStatus ;
12+ import com .leets .tdd .party .repository .DeliveryPartyRepository ;
13+ import com .leets .tdd .party .repository .PartyParticipantRepository ;
14+ import com .leets .tdd .settlement .domain .SettlementStatus ;
915import com .leets .tdd .user .domain .Dormitory ;
1016import com .leets .tdd .user .domain .User ;
1117import com .leets .tdd .user .dto .DormVerificationConfirmRequest ;
2430import com .leets .tdd .user .dto .ProfileUpdateResponse ;
2531import com .leets .tdd .user .dto .PushSettingRequest ;
2632import com .leets .tdd .user .dto .PushSettingResponse ;
33+ import com .leets .tdd .user .dto .PushSubscriptionRequest ;
2734import com .leets .tdd .user .dto .WithdrawalRequest ;
2835import com .leets .tdd .user .exception .UserErrorCode ;
2936import com .leets .tdd .user .exception .UserException ;
3542import org .springframework .transaction .annotation .Transactional ;
3643
3744import java .time .LocalDateTime ;
45+ import java .util .EnumSet ;
46+ import java .util .List ;
47+ import java .util .Set ;
48+ import java .util .stream .Collectors ;
3849
3950/**
4051 * 마이페이지 조회 + 계정등록(회원가입 완료) API.
@@ -55,8 +66,16 @@ public class UserService {
5566 private final EmailVerificationService emailVerificationService ;
5667 private final PasswordEncoder passwordEncoder ;
5768 private final NicknameGenerator nicknameGenerator ;
69+ private final DeliveryPartyRepository deliveryPartyRepository ;
70+ private final PartyParticipantRepository partyParticipantRepository ;
5871 private final ImageStorageService imageStorageService ;
5972
73+ // 탈퇴 제한: 진행 중인 배달 팟(모집중/마감/주문완료) 또는 완료됐지만 정산이 안 끝난 팟이 있으면 막는다.
74+ private static final Set <PartyStatus > ONGOING_PARTY_STATUSES =
75+ EnumSet .of (PartyStatus .RECRUITING , PartyStatus .CLOSED , PartyStatus .ORDERED );
76+ private static final Set <SettlementStatus > SETTLEMENT_TERMINAL_STATUSES =
77+ EnumSet .of (SettlementStatus .COMPLETED , SettlementStatus .CANCELED );
78+
6079 @ Transactional
6180 public MyPageResponse getMyPage (Long userId ) {
6281 User user = userRepository .findById (userId )
@@ -274,6 +293,22 @@ public PushSettingResponse updatePushSetting(Long userId, PushSettingRequest req
274293 return new PushSettingResponse (user .isPushEnabled ());
275294 }
276295
296+ /**
297+ * 마이페이지 > 알림 구독 등록. 브라우저 PushManager.subscribe()로 발급받은 endpoint/키를
298+ * 저장한다(Web Push 방식, FCM 아님 - User 엔티티 컬럼이 이미 endpoint/p256dh/auth 구조).
299+ * 기존 구독이 있어도 그냥 덮어쓴다(기기 교체/브라우저 재설치 시 재구독하는 흔한 케이스라
300+ * 별도 중복 에러 없이 최신 값으로 갱신). pushEnabled는 건드리지 않는다(updatePushSetting의
301+ * 별도 관심사).
302+ */
303+ @ Transactional
304+ public void registerPushSubscription (Long userId , PushSubscriptionRequest request ) {
305+ User user = userRepository .findById (userId )
306+ .orElseThrow (() -> new UserException (UserErrorCode .USER_NOT_FOUND ));
307+
308+ user .updatePushSubscription (request .endpoint (), request .p256dhKey (), request .authKey ());
309+ userRepository .save (user );
310+ }
311+
277312 /**
278313 * 마이페이지 > 비밀번호 수정. 현재 비밀번호로 본인 확인 후 새 비밀번호로 바꾸고,
279314 * 기존 refresh token을 무효화한다(clearRefreshToken - 로그아웃/탈퇴와 동일한 관례).
@@ -303,10 +338,10 @@ public void changePassword(Long userId, ChangePasswordRequest request) {
303338 * suspendedUntil/noShowApprovedCount/mannerTemperature는 softDelete()가 건드리지 않으므로
304339 * 그대로 유지된다(정지 우회 방지 + 재가입 시 이력 복원).
305340 * <p>
306- * TODO: "진행 중인 배달팟(정산 미완료) 여부 확인" 단계는 의도적으로 뺐다. party 도메인
307- * 엔티티(DeliveryParty/PartyParticipant) 자체는 이미 있지만, 이 기능을 넣으려면 user 도메인이
308- * party/settlement 도메인에 의존하게 돼서 이 PR(로그인/로그아웃/탈퇴) 범위를 벗어난다.
309- * 별도 이슈로 분리해서 처리한다(명세 실패 케이스: "진행 중인 배달팟이 있어 탈퇴할 수 없습니다.") .
341+ * 방장/참여자 구분 없이 본인이 속한 팟 중 진행 중(RECRUITING/CLOSED/ORDERED)인 팟이 있으면
342+ * ACTIVE_POT_EXISTS로, COMPLETED인데 정산이 아직 안 끝난(SettlementStatus가 COMPLETED/CANCELED가
343+ * 아닌) 팟이 있으면 UNSETTLED_POT_EXISTS로 탈퇴를 막는다. 방장 탈퇴로 정산 트리거가 사라지는 문제,
344+ * 참여자가 노쇼 신고 전에 탈퇴로 회피하는 문제를 둘 다 막기 위함이다 .
310345 */
311346 @ Transactional
312347 public void withdraw (Long userId , WithdrawalRequest request ) {
@@ -321,11 +356,53 @@ public void withdraw(Long userId, WithdrawalRequest request) {
321356 throw new UserException (UserErrorCode .PASSWORD_MISMATCH );
322357 }
323358
359+ List <Long > joinedPartyIds = joinedPartyIds (userId );
360+
361+ if (hasOngoingDeliveryParty (userId , joinedPartyIds )) {
362+ throw new UserException (UserErrorCode .ACTIVE_POT_EXISTS );
363+ }
364+ if (hasUnsettledDeliveryParty (userId , joinedPartyIds )) {
365+ throw new UserException (UserErrorCode .UNSETTLED_POT_EXISTS );
366+ }
367+
324368 user .softDelete ();
325369 user .clearRefreshToken ();
326370 userRepository .save (user );
327371 }
328372
373+ // 참여를 취소하지 않은(JOINED) 팟 id 목록 - 참여자(participant) 기준 체크에 재사용
374+ private List <Long > joinedPartyIds (Long userId ) {
375+ return partyParticipantRepository
376+ .findAllByUserIdAndStatus (userId , PartyParticipantStatus .JOINED )
377+ .stream ()
378+ .map (PartyParticipant ::getPartyId )
379+ .collect (Collectors .toList ());
380+ }
381+
382+ // RECRUITING/CLOSED/ORDERED로 진행 중인 팟이 있는지 (방장 + 참여자 기준)
383+ private boolean hasOngoingDeliveryParty (Long userId , List <Long > joinedPartyIds ) {
384+ if (deliveryPartyRepository .existsByCreatorIdAndStatusIn (userId , ONGOING_PARTY_STATUSES )) {
385+ return true ;
386+ }
387+ if (joinedPartyIds .isEmpty ()) {
388+ return false ;
389+ }
390+ return deliveryPartyRepository .existsByIdInAndStatusIn (joinedPartyIds , ONGOING_PARTY_STATUSES );
391+ }
392+
393+ // COMPLETED인데 정산이 아직 안 끝난 팟이 있는지 (방장 + 참여자 기준)
394+ private boolean hasUnsettledDeliveryParty (Long userId , List <Long > joinedPartyIds ) {
395+ if (deliveryPartyRepository .existsByCreatorIdAndStatusAndSettlementStatusNotIn (
396+ userId , PartyStatus .COMPLETED , SETTLEMENT_TERMINAL_STATUSES )) {
397+ return true ;
398+ }
399+ if (joinedPartyIds .isEmpty ()) {
400+ return false ;
401+ }
402+ return deliveryPartyRepository .existsByIdInAndStatusAndSettlementStatusNotIn (
403+ joinedPartyIds , PartyStatus .COMPLETED , SETTLEMENT_TERMINAL_STATUSES );
404+ }
405+
329406 private record IssuedTokens (String accessToken , String refreshToken , LocalDateTime refreshTokenExpiresAt ) {
330407 }
331408
0 commit comments