From f52c4d000d438a91a0ba24b324743d5ea02519a4 Mon Sep 17 00:00:00 2001 From: seongyonglim Date: Wed, 1 Jul 2026 02:01:50 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=20=ED=83=88?= =?UTF-8?q?=ED=87=B4=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../InternalUserBillDataController.java | 44 ++++++++++++ .../service/UserBillDataCommandService.java | 36 ++++++++++ .../UserBillBookmarkCommandRepository.java | 8 +++ .../UserBillInterestCommandRepository.java | 8 +++ .../UserBillViewCommandRepository.java | 5 ++ .../security/InternalApiKeyFilter.java | 21 ++++-- .../security/SecurityEndpoints.java | 1 + docker-compose.yml | 2 +- k8s/base/services/user-service/configmap.yaml | 9 ++- .../InternalUserNotificationController.java | 24 +++++++ .../api/spec/InternalUserNotificationApi.java | 48 +++++++++++++ .../UserNotificationCommandService.java | 7 ++ .../UserNotificationCommandRepository.java | 5 ++ .../security/AdminApiKeyFilter.java | 18 ++++- .../security/SecurityEndpoints.java | 3 +- .../api/controller/UserController.java | 13 ++++ .../api/dto/UserWithdrawRequest.java | 21 ++++++ .../api/example/AuthApiExamples.java | 6 ++ .../api/example/UserApiExamples.java | 7 ++ .../api/spec/SocialTokenAuthApi.java | 3 + .../userservice/api/spec/UserApi.java | 28 ++++++++ .../application/auth/SocialUserService.java | 71 ++++++++++++++----- .../user/command/UserWithdrawCommand.java | 10 +++ .../command/service/UserCommandService.java | 3 + .../service/UserCommandServiceImpl.java | 25 +++++++ .../service/UserWithdrawalService.java | 34 +++++++++ .../client/BillUserDataClient.java | 64 +++++++++++++++++ .../client/NotificationUserDataClient.java | 61 ++++++++++++++++ .../exception/RejoinBlockedException.java | 13 ++++ .../common/exception/UserErrorCode.java | 3 + .../config/RestTemplateConfig.java | 19 +++++ .../com/polywave/userservice/domain/User.java | 67 +++++++++++++++++ .../userservice/domain/UserStatus.java | 11 +++ .../userservice/domain/WithdrawalReason.java | 21 ++++++ .../command/UserTermsCommandRepository.java | 8 +++ .../query/impl/UserQueryRepositoryImpl.java | 13 ++-- .../src/main/resources/application.properties | 11 ++- .../V8__add_user_withdrawal_columns.sql | 13 ++++ 38 files changed, 732 insertions(+), 32 deletions(-) create mode 100644 bill-service/src/main/java/com/polywave/billservice/api/controller/internal/InternalUserBillDataController.java create mode 100644 bill-service/src/main/java/com/polywave/billservice/application/user/command/service/UserBillDataCommandService.java create mode 100644 notification-service/src/main/java/com/polywave/notificationservice/api/controller/internal/InternalUserNotificationController.java create mode 100644 notification-service/src/main/java/com/polywave/notificationservice/api/spec/InternalUserNotificationApi.java create mode 100644 user-service/src/main/java/com/polywave/userservice/api/dto/UserWithdrawRequest.java create mode 100644 user-service/src/main/java/com/polywave/userservice/application/user/command/UserWithdrawCommand.java create mode 100644 user-service/src/main/java/com/polywave/userservice/application/user/command/service/UserWithdrawalService.java create mode 100644 user-service/src/main/java/com/polywave/userservice/client/BillUserDataClient.java create mode 100644 user-service/src/main/java/com/polywave/userservice/client/NotificationUserDataClient.java create mode 100644 user-service/src/main/java/com/polywave/userservice/common/exception/RejoinBlockedException.java create mode 100644 user-service/src/main/java/com/polywave/userservice/config/RestTemplateConfig.java create mode 100644 user-service/src/main/java/com/polywave/userservice/domain/UserStatus.java create mode 100644 user-service/src/main/java/com/polywave/userservice/domain/WithdrawalReason.java create mode 100644 user-service/src/main/resources/db/migration/V8__add_user_withdrawal_columns.sql diff --git a/bill-service/src/main/java/com/polywave/billservice/api/controller/internal/InternalUserBillDataController.java b/bill-service/src/main/java/com/polywave/billservice/api/controller/internal/InternalUserBillDataController.java new file mode 100644 index 0000000..0e1b3c4 --- /dev/null +++ b/bill-service/src/main/java/com/polywave/billservice/api/controller/internal/InternalUserBillDataController.java @@ -0,0 +1,44 @@ +package com.polywave.billservice.api.controller.internal; + +import com.polywave.billservice.application.user.command.service.UserBillDataCommandService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * user-service 회원 탈퇴 시 호출하는 사용자 파생 데이터 삭제 internal API. + * + * - InternalApiKeyFilter 가 X-Internal-Api-Key 헤더로 가드한다. + * - 북마크/관심사/조회 기록만 삭제하고 표결(user_bill_votes)은 보존한다. 멱등. + */ +@Tag(name = "Internal User Bill Data", description = "[Internal] 회원 탈퇴 시 사용자 의안 파생 데이터 삭제") +@RestController +@RequiredArgsConstructor +@RequestMapping("/internal/users") +public class InternalUserBillDataController { + + private final UserBillDataCommandService userBillDataCommandService; + + @Operation(summary = "[Internal] 사용자 의안 파생 데이터 삭제(북마크/관심사/조회)", description = """ + 회원 탈퇴 시 해당 사용자의 북마크/관심사/조회 기록을 삭제한다. + 표결(투표) 기록은 표결 결과 보존을 위해 삭제하지 않는다. 멱등하게 동작한다. + """) + @DeleteMapping("/{userId}/bill-data") + public ResponseEntity deleteUserBillData( + @Parameter(in = ParameterIn.HEADER, name = "X-Internal-Api-Key", description = "서비스 간 internal 공유 키", required = true) + @RequestHeader(value = "X-Internal-Api-Key", required = false) String internalApiKey, + + @Parameter(description = "탈퇴 사용자 ID") @PathVariable Long userId + ) { + userBillDataCommandService.deleteAllByUser(userId); + return ResponseEntity.noContent().build(); + } +} diff --git a/bill-service/src/main/java/com/polywave/billservice/application/user/command/service/UserBillDataCommandService.java b/bill-service/src/main/java/com/polywave/billservice/application/user/command/service/UserBillDataCommandService.java new file mode 100644 index 0000000..1262cbf --- /dev/null +++ b/bill-service/src/main/java/com/polywave/billservice/application/user/command/service/UserBillDataCommandService.java @@ -0,0 +1,36 @@ +package com.polywave.billservice.application.user.command.service; + +import com.polywave.billservice.repository.command.UserBillBookmarkCommandRepository; +import com.polywave.billservice.repository.command.UserBillInterestCommandRepository; +import com.polywave.billservice.repository.command.UserBillViewCommandRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 회원 탈퇴 시 사용자의 의안 파생 데이터(북마크/관심사/조회 기록)를 일괄 삭제한다. + * + *

표결(user_bill_votes)은 표결 결과 보존을 위해 삭제하지 않는다. + * (사고 방지를 위해 vote repository 는 의도적으로 주입하지 않는다.) + * 멱등하게 동작한다 — 데이터가 없어도 0건 삭제로 정상 종료한다. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class UserBillDataCommandService { + + private final UserBillBookmarkCommandRepository userBillBookmarkCommandRepository; + private final UserBillInterestCommandRepository userBillInterestCommandRepository; + private final UserBillViewCommandRepository userBillViewCommandRepository; + + @Transactional + public void deleteAllByUser(Long userId) { + int bookmarks = userBillBookmarkCommandRepository.deleteAllByUserId(userId); + int interests = userBillInterestCommandRepository.deleteAllByUserId(userId); + int views = userBillViewCommandRepository.deleteAllByUserId(userId); + + log.info("[WITHDRAW] 사용자 의안 데이터 삭제 userId={} bookmarks={} interests={} views={} (votes 보존)", + userId, bookmarks, interests, views); + } +} diff --git a/bill-service/src/main/java/com/polywave/billservice/repository/command/UserBillBookmarkCommandRepository.java b/bill-service/src/main/java/com/polywave/billservice/repository/command/UserBillBookmarkCommandRepository.java index c96f30c..90e2b02 100644 --- a/bill-service/src/main/java/com/polywave/billservice/repository/command/UserBillBookmarkCommandRepository.java +++ b/bill-service/src/main/java/com/polywave/billservice/repository/command/UserBillBookmarkCommandRepository.java @@ -2,8 +2,16 @@ import com.polywave.billservice.domain.UserBillBookmark; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface UserBillBookmarkCommandRepository extends JpaRepository { void deleteByUserIdAndBill_Id(Long userId, Long billId); + + // 회원 탈퇴 시 해당 사용자의 북마크 전체 삭제 (멱등: 0건이어도 정상). + @Modifying + @Query("DELETE FROM UserBillBookmark b WHERE b.userId = :userId") + int deleteAllByUserId(@Param("userId") Long userId); } \ No newline at end of file diff --git a/bill-service/src/main/java/com/polywave/billservice/repository/command/UserBillInterestCommandRepository.java b/bill-service/src/main/java/com/polywave/billservice/repository/command/UserBillInterestCommandRepository.java index f1ab578..39a40de 100644 --- a/bill-service/src/main/java/com/polywave/billservice/repository/command/UserBillInterestCommandRepository.java +++ b/bill-service/src/main/java/com/polywave/billservice/repository/command/UserBillInterestCommandRepository.java @@ -3,6 +3,9 @@ import com.polywave.billservice.domain.UserBillInterest; import java.util.Set; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface UserBillInterestCommandRepository extends JpaRepository { @@ -11,4 +14,9 @@ public interface UserBillInterestCommandRepository extends JpaRepository categoryIds); + + // 회원 탈퇴 시 해당 사용자의 관심사 전체 삭제 (멱등). + @Modifying + @Query("DELETE FROM UserBillInterest i WHERE i.userId = :userId") + int deleteAllByUserId(@Param("userId") Long userId); } diff --git a/bill-service/src/main/java/com/polywave/billservice/repository/command/UserBillViewCommandRepository.java b/bill-service/src/main/java/com/polywave/billservice/repository/command/UserBillViewCommandRepository.java index ba60fb7..9bf878b 100644 --- a/bill-service/src/main/java/com/polywave/billservice/repository/command/UserBillViewCommandRepository.java +++ b/bill-service/src/main/java/com/polywave/billservice/repository/command/UserBillViewCommandRepository.java @@ -31,4 +31,9 @@ ON CONFLICT (user_id, bill_id) WHERE user_bill_views.last_viewed_at <= now() - INTERVAL '24 hours' """, nativeQuery = true) int upsertIfViewCountable(@Param("userId") Long userId, @Param("billId") Long billId); + + // 회원 탈퇴 시 해당 사용자의 조회 기록 전체 삭제 (멱등). + @Modifying + @Query("DELETE FROM UserBillView v WHERE v.userId = :userId") + int deleteAllByUserId(@Param("userId") Long userId); } \ No newline at end of file diff --git a/bill-service/src/main/java/com/polywave/billservice/security/InternalApiKeyFilter.java b/bill-service/src/main/java/com/polywave/billservice/security/InternalApiKeyFilter.java index 8657fd9..02e1400 100644 --- a/bill-service/src/main/java/com/polywave/billservice/security/InternalApiKeyFilter.java +++ b/bill-service/src/main/java/com/polywave/billservice/security/InternalApiKeyFilter.java @@ -13,17 +13,21 @@ import org.springframework.web.filter.OncePerRequestFilter; /** - * /internal/segments/** 경로에 대한 서비스 간 internal API 키 가드. + * /internal/** 경로에 대한 서비스 간 internal API 키 가드. * * - X-Internal-Api-Key 헤더가 설정값(bill-service.internal-api.key)과 일치할 때만 통과. * - 키가 설정되지 않은 환경에서는 fail-closed: 무조건 403. - * - 사용자 JWT 와는 별도 채널. notification-service 스케줄러처럼 사용자 컨텍스트가 없는 호출 전용. + * - 사용자 JWT 와는 별도 채널. notification-service 스케줄러나 user-service 탈퇴처럼 + * 사용자 컨텍스트가 없는 서버-서버 호출 전용. */ @Component public class InternalApiKeyFilter extends OncePerRequestFilter { static final String HEADER_NAME = "X-Internal-Api-Key"; - static final String PROTECTED_PATTERN = "/internal/segments/**"; + static final String[] PROTECTED_PATTERNS = { + "/internal/segments/**", // notification-service 세그먼트 조회 + "/internal/users/**" // user-service 회원 탈퇴 시 파생 데이터 삭제 + }; private final AntPathMatcher pathMatcher = new AntPathMatcher(); private final String configuredKey; @@ -41,7 +45,7 @@ protected void doFilterInternal( // getServletPath() 는 context-path 가 stripped 된 경로를 반환한다. // (getRequestURI() 는 context-path 를 포함하므로 PROTECTED_PATTERN 과 매칭되지 않아 가드가 무력화된다) - if (!pathMatcher.match(PROTECTED_PATTERN, request.getServletPath())) { + if (!isProtected(request.getServletPath())) { filterChain.doFilter(request, response); return; } @@ -60,6 +64,15 @@ protected void doFilterInternal( filterChain.doFilter(request, response); } + private boolean isProtected(String servletPath) { + for (String pattern : PROTECTED_PATTERNS) { + if (pathMatcher.match(pattern, servletPath)) { + return true; + } + } + return false; + } + private void writeForbidden(HttpServletResponse response, String code) throws IOException { response.setStatus(HttpStatus.FORBIDDEN.value()); response.setContentType(MediaType.APPLICATION_JSON_VALUE); diff --git a/bill-service/src/main/java/com/polywave/billservice/security/SecurityEndpoints.java b/bill-service/src/main/java/com/polywave/billservice/security/SecurityEndpoints.java index c6876da..d06b431 100644 --- a/bill-service/src/main/java/com/polywave/billservice/security/SecurityEndpoints.java +++ b/bill-service/src/main/java/com/polywave/billservice/security/SecurityEndpoints.java @@ -9,6 +9,7 @@ private SecurityEndpoints() {} // 서비스 간 internal API: InternalApiKeyFilter 가 별도 가드. "/internal/segments/**", + "/internal/users/**", // Swagger (운영에서는 profile로 끄는 게 안전) "/swagger-ui/**", "/v3/api-docs/**", diff --git a/docker-compose.yml b/docker-compose.yml index be1c34b..73fce24 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -89,7 +89,7 @@ services: build: context: . dockerfile: bill-service/Dockerfile - image: ghcr.io/poly-wave/mypoly-bill-service:0.6 + image: ghcr.io/poly-wave/mypoly-bill-service:0.1 container_name: mypoly-bill-service restart: unless-stopped diff --git a/k8s/base/services/user-service/configmap.yaml b/k8s/base/services/user-service/configmap.yaml index 438c581..befc33c 100644 --- a/k8s/base/services/user-service/configmap.yaml +++ b/k8s/base/services/user-service/configmap.yaml @@ -22,4 +22,11 @@ data: GOOGLE_ALLOWED_AUDIENCES: "714490493102-d26i4c6magpkh6f4brhi2dvml3d0idbd.apps.googleusercontent.com,714490493102-82qd3ftt7lvcnbdau3j7f0ko25ndtmpo.apps.googleusercontent.com,714490493102-trj6kfjoceqhloru93snmhvvsoooa5lg.apps.googleusercontent.com" APPLE_ALLOWED_AUDIENCES: "kr.kro.mypoly.app.dev,kr.kro.mypoly.app" - DB_URL: "jdbc:postgresql://mypoly-postgres:5432/mypoly" \ No newline at end of file + DB_URL: "jdbc:postgresql://mypoly-postgres:5432/mypoly" + + # 회원 탈퇴 시 타 서비스 사용자 데이터 삭제 호출 endpoint (k8s DNS 기반). + # bill-service 는 context-path(/bills)를 박고 있어 prefix 포함, notification-service 는 미사용. + # internal 호출 키(BILL_SERVICE_INTERNAL_API_KEY / NOTIFICATION_ADMIN_API_KEY)는 + # mypoly-internal-api-secret 에서 주입된다(이미 마운트됨). + BILL_SERVICE_URL: "http://bill-service/bills" + NOTIFICATION_SERVICE_URL: "http://notification-service" \ No newline at end of file diff --git a/notification-service/src/main/java/com/polywave/notificationservice/api/controller/internal/InternalUserNotificationController.java b/notification-service/src/main/java/com/polywave/notificationservice/api/controller/internal/InternalUserNotificationController.java new file mode 100644 index 0000000..67c0a03 --- /dev/null +++ b/notification-service/src/main/java/com/polywave/notificationservice/api/controller/internal/InternalUserNotificationController.java @@ -0,0 +1,24 @@ +package com.polywave.notificationservice.api.controller.internal; + +import com.polywave.notificationservice.api.spec.InternalUserNotificationApi; +import com.polywave.notificationservice.application.notification.user.command.service.UserNotificationCommandService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RestController; + +/** + * user-service 회원 탈퇴 시 호출하는 사용자 알림 삭제 internal API. + * AdminApiKeyFilter 가 X-Admin-Api-Key 헤더로 가드한다. + */ +@RestController +@RequiredArgsConstructor +public class InternalUserNotificationController implements InternalUserNotificationApi { + + private final UserNotificationCommandService userNotificationCommandService; + + @Override + public ResponseEntity deleteUserNotifications(Long userId) { + userNotificationCommandService.deleteAllByUser(userId); + return ResponseEntity.noContent().build(); + } +} diff --git a/notification-service/src/main/java/com/polywave/notificationservice/api/spec/InternalUserNotificationApi.java b/notification-service/src/main/java/com/polywave/notificationservice/api/spec/InternalUserNotificationApi.java new file mode 100644 index 0000000..98144a3 --- /dev/null +++ b/notification-service/src/main/java/com/polywave/notificationservice/api/spec/InternalUserNotificationApi.java @@ -0,0 +1,48 @@ +package com.polywave.notificationservice.api.spec; + +import com.polywave.common.dto.ErrorResponse; +import com.polywave.common.example.CommonApiExamples; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +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 io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; + +@Tag(name = "Internal User Notification", description = """ + [Internal] 회원 탈퇴 시 사용자 알림 삭제 API. + + `X-Admin-Api-Key` 헤더가 서버 설정값과 일치해야 호출 가능합니다. + - 운영 배포 시 `NOTIFICATION_ADMIN_API_KEY` 환경변수로 주입 + - 키가 설정되지 않은 환경에서는 무조건 403 (fail-closed) + """) +@SecurityRequirement(name = "adminApiKey") +@RequestMapping("/internal/user-notifications") +public interface InternalUserNotificationApi { + + @Operation(summary = "[Internal] 사용자 알림 전체 삭제", description = """ + 회원 탈퇴 시 해당 사용자의 알림을 모두 물리 삭제한다. 멱등하게 동작한다(없어도 204). + """) + @ApiResponses({ + @ApiResponse(responseCode = "204", description = "삭제 성공"), + @ApiResponse( + responseCode = "500", + description = "서버 오류", + content = @Content( + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject(name = "서버 오류", value = CommonApiExamples.EXAMPLE_INTERNAL_SERVER_ERROR) + ) + ) + }) + @DeleteMapping("/{userId}") + ResponseEntity deleteUserNotifications( + @Parameter(description = "탈퇴 사용자 ID") @PathVariable Long userId + ); +} diff --git a/notification-service/src/main/java/com/polywave/notificationservice/application/notification/user/command/service/UserNotificationCommandService.java b/notification-service/src/main/java/com/polywave/notificationservice/application/notification/user/command/service/UserNotificationCommandService.java index 8fa654a..3a0e25b 100644 --- a/notification-service/src/main/java/com/polywave/notificationservice/application/notification/user/command/service/UserNotificationCommandService.java +++ b/notification-service/src/main/java/com/polywave/notificationservice/application/notification/user/command/service/UserNotificationCommandService.java @@ -111,6 +111,13 @@ public int markAllAsRead(Long userId) { return userNotificationCommandRepository.markAllAsRead(userId, Instant.now()); } + /** 회원 탈퇴 시 해당 사용자의 알림을 모두 삭제한다. 멱등(0건이어도 정상). */ + public int deleteAllByUser(Long userId) { + int deleted = userNotificationCommandRepository.deleteAllByUserId(userId); + log.info("[WITHDRAW] 사용자 알림 삭제 userId={} deleted={}", userId, deleted); + return deleted; + } + /** 치환 후 문자열이 컬럼 한계를 넘으면 말줄임표(…) 를 붙여 자른다. */ private static String truncate(String value, int maxLength) { if (value == null || value.length() <= maxLength) { diff --git a/notification-service/src/main/java/com/polywave/notificationservice/repository/command/UserNotificationCommandRepository.java b/notification-service/src/main/java/com/polywave/notificationservice/repository/command/UserNotificationCommandRepository.java index f0f0778..96db639 100644 --- a/notification-service/src/main/java/com/polywave/notificationservice/repository/command/UserNotificationCommandRepository.java +++ b/notification-service/src/main/java/com/polywave/notificationservice/repository/command/UserNotificationCommandRepository.java @@ -24,4 +24,9 @@ public interface UserNotificationCommandRepository extends JpaRepository getMe(@LoginUser Long userId) { @@ -107,6 +110,16 @@ public ResponseEntity updateOnboardingStatus( return ResponseEntity.ok().build(); } + @Override + public ResponseEntity withdraw( + UserWithdrawRequest request, + @LoginUser Long userId) { + + UserWithdrawCommand command = new UserWithdrawCommand(request.reasons(), request.etcText()); + userWithdrawalService.withdraw(userId, command); + return ResponseEntity.noContent().build(); + } + @Override public ResponseEntity searchAddress(AddressSearchRequest request) { AddressSearchResult result = addressQueryService.searchAddress( diff --git a/user-service/src/main/java/com/polywave/userservice/api/dto/UserWithdrawRequest.java b/user-service/src/main/java/com/polywave/userservice/api/dto/UserWithdrawRequest.java new file mode 100644 index 0000000..08a45a1 --- /dev/null +++ b/user-service/src/main/java/com/polywave/userservice/api/dto/UserWithdrawRequest.java @@ -0,0 +1,21 @@ +package com.polywave.userservice.api.dto; + +import com.polywave.userservice.domain.WithdrawalReason; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Size; +import java.util.List; + +@Schema(description = "회원 탈퇴 요청") +public record UserWithdrawRequest( + @Schema( + description = "탈퇴 사유(중복 선택). 가능 값: " + + "INFREQUENT_USE, MISSING_FEATURE, HARD_TO_USE, LOW_QUALITY, USING_ALTERNATIVE, ETC", + example = "[\"INFREQUENT_USE\", \"ETC\"]" + ) + List reasons, + + @Schema(description = "기타(ETC) 선택 시 직접 입력한 사유. 최대 200자.", example = "원하는 정치인 정보가 부족해요") + @Size(max = 200, message = "기타 사유는 200자 이하여야 합니다.") + String etcText +) { +} diff --git a/user-service/src/main/java/com/polywave/userservice/api/example/AuthApiExamples.java b/user-service/src/main/java/com/polywave/userservice/api/example/AuthApiExamples.java index 5c09df4..3e7fa29 100644 --- a/user-service/src/main/java/com/polywave/userservice/api/example/AuthApiExamples.java +++ b/user-service/src/main/java/com/polywave/userservice/api/example/AuthApiExamples.java @@ -85,6 +85,12 @@ private AuthApiExamples() { } """; + public static final String EXAMPLE_REJOIN_BLOCKED = """ + { + "code": "REJOIN_BLOCKED" + } + """; + public static final String EXAMPLE_DEV_AUTH_KEY_NOT_SET = """ { "code": "FORBIDDEN" diff --git a/user-service/src/main/java/com/polywave/userservice/api/example/UserApiExamples.java b/user-service/src/main/java/com/polywave/userservice/api/example/UserApiExamples.java index e2558d0..fcd3af0 100644 --- a/user-service/src/main/java/com/polywave/userservice/api/example/UserApiExamples.java +++ b/user-service/src/main/java/com/polywave/userservice/api/example/UserApiExamples.java @@ -135,4 +135,11 @@ public final class UserApiExamples { "code": "NICKNAME_DATA_NOT_FOUND" } """; + + public static final String EXAMPLE_WITHDRAW_REQUEST = """ + { + "reasons": ["INFREQUENT_USE", "ETC"], + "etcText": "원하는 정치인 정보가 부족해요" + } + """; } \ No newline at end of file diff --git a/user-service/src/main/java/com/polywave/userservice/api/spec/SocialTokenAuthApi.java b/user-service/src/main/java/com/polywave/userservice/api/spec/SocialTokenAuthApi.java index 03a66e3..067cebd 100644 --- a/user-service/src/main/java/com/polywave/userservice/api/spec/SocialTokenAuthApi.java +++ b/user-service/src/main/java/com/polywave/userservice/api/spec/SocialTokenAuthApi.java @@ -52,6 +52,9 @@ ResponseEntity loginWithToken( })), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "소셜 토큰 검증 실패", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = { @ExampleObject(name = "인증 실패", value = CommonApiExamples.EXAMPLE_UNAUTHORIZED) + })), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "탈퇴 후 7일 재가입 제한 기간 내 동일 소셜 계정 재가입 시도", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = { + @ExampleObject(name = "재가입 차단", value = AuthApiExamples.EXAMPLE_REJOIN_BLOCKED) })) }) @PostMapping("/token/{provider}/signup") diff --git a/user-service/src/main/java/com/polywave/userservice/api/spec/UserApi.java b/user-service/src/main/java/com/polywave/userservice/api/spec/UserApi.java index 62c37ac..16b76d0 100644 --- a/user-service/src/main/java/com/polywave/userservice/api/spec/UserApi.java +++ b/user-service/src/main/java/com/polywave/userservice/api/spec/UserApi.java @@ -10,6 +10,7 @@ import com.polywave.userservice.api.dto.UserMeResponse; import com.polywave.userservice.api.dto.UserUpdateBasicProfileRequest; import com.polywave.userservice.api.dto.UserUpdateProfileRequest; +import com.polywave.userservice.api.dto.UserWithdrawRequest; import com.polywave.common.dto.ErrorResponse; import com.polywave.common.example.CommonApiExamples; @@ -27,6 +28,7 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @Tag(name = "User", description = "사용자 정보(닉네임/프로필/주소) 관련 API") @@ -199,4 +201,30 @@ ResponseEntity updateOnboardingStatus( @GetMapping("/{userId}/onboarding-status") ResponseEntity getOnboardingStatus( @Parameter(description = "사용자 ID") @PathVariable Long userId); + + @Operation(summary = "회원 탈퇴", description = """ + 로그인한 사용자를 즉시 탈퇴 처리합니다. + + 처리 내용 + - user-service 의 계정/소셜 연동/약관 동의 등 개인정보가 즉시 삭제됩니다. + - 닉네임은 즉시 말소되어 다른 사용자가 사용할 수 있습니다(소셜 신원 기준 차단이며 닉네임 기준이 아닙니다). + - 탈퇴 후 7일간 동일 소셜 계정으로는 재가입할 수 없습니다(가입 시 REJOIN_BLOCKED). + - 탈퇴 사유(중복 선택)와 기타 텍스트(최대 200자)는 통계 목적으로 저장됩니다. 사유 입력은 선택값입니다. + + 인증 + - JWT 인증이 필요합니다. + - Swagger 우측 상단 Authorize에 `Bearer {jwt}` 입력 후 호출하세요. + """) + @io.swagger.v3.oas.annotations.responses.ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "204", description = "탈퇴 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "요청 값 검증 실패(기타 사유 200자 초과 등)", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject(name = "요청 값 검증 실패", value = UserApiExamples.EXAMPLE_VALIDATION_ERROR))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "인증 필요(JWT 누락/만료/위조)", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject(name = "인증 필요", value = CommonApiExamples.EXAMPLE_UNAUTHORIZED))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "사용자 없음", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject(name = "사용자를 찾을 수 없음", value = UserApiExamples.EXAMPLE_USER_NOT_FOUND))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "500", description = "서버 오류", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject(name = "서버 오류", value = CommonApiExamples.EXAMPLE_INTERNAL_SERVER_ERROR))) + }) + @PostMapping("/me/withdraw") + ResponseEntity withdraw( + @io.swagger.v3.oas.annotations.parameters.RequestBody(description = "탈퇴 사유(선택)", content = @Content(mediaType = "application/json", schema = @Schema(implementation = UserWithdrawRequest.class), examples = @ExampleObject(name = "요청 예시", value = UserApiExamples.EXAMPLE_WITHDRAW_REQUEST))) + @RequestBody @Valid UserWithdrawRequest request, + @Parameter(hidden = true) Long userId); } \ No newline at end of file diff --git a/user-service/src/main/java/com/polywave/userservice/application/auth/SocialUserService.java b/user-service/src/main/java/com/polywave/userservice/application/auth/SocialUserService.java index 43635f1..18bdd0f 100644 --- a/user-service/src/main/java/com/polywave/userservice/application/auth/SocialUserService.java +++ b/user-service/src/main/java/com/polywave/userservice/application/auth/SocialUserService.java @@ -2,11 +2,15 @@ import com.polywave.userservice.application.auth.command.SocialLoginCommand; import com.polywave.userservice.application.auth.result.SocialUserResult; +import com.polywave.userservice.common.exception.RejoinBlockedException; import com.polywave.userservice.domain.User; import com.polywave.userservice.domain.UserOauth; import com.polywave.userservice.repository.command.UserCommandRepository; import com.polywave.userservice.repository.command.UserOauthCommandRepository; import com.polywave.userservice.repository.query.UserOauthQueryRepository; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Optional; import lombok.RequiredArgsConstructor; import com.polywave.common.exception.BusinessException; import com.polywave.userservice.common.exception.UserErrorCode; @@ -18,6 +22,9 @@ @RequiredArgsConstructor public class SocialUserService { + /** 탈퇴 후 동일 소셜 계정의 재가입을 차단하는 기간(일). */ + private static final long REJOIN_BLOCK_DAYS = 7; + private final UserCommandRepository userCommandRepository; private final UserOauthCommandRepository userOauthCommandRepository; private final UserOauthQueryRepository userOauthQueryRepository; @@ -30,31 +37,52 @@ public SocialUserResult login(SocialLoginCommand command) { User user = userOauth.getUser(); - return new SocialUserResult( - user.getId(), - userOauth.getProvider(), - userOauth.getProviderUserId(), - user.getNickname(), - user.getProfileImageUrl()); + // 탈퇴(WITHDRAWN) 회원은 로그인 불가 — 재가입(signup) 경로로 유도한다. + if (user.isWithdrawn()) { + throw new BusinessException(UserErrorCode.USER_NOT_FOUND); + } + + return toResult(user, userOauth); } @Transactional public SocialUserResult register(SocialLoginCommand command) { - userOauthQueryRepository - .findByProviderAndProviderUserId(command.provider(), command.providerUserId()) - .ifPresent(u -> { - throw new BusinessException(UserErrorCode.USER_ALREADY_EXISTS); - }); + Optional existing = userOauthQueryRepository + .findByProviderAndProviderUserId(command.provider(), command.providerUserId()); + + if (existing.isPresent()) { + return registerExisting(existing.get(), command); + } UserOauth userOauth = createSocialUser(command); + return toResult(userOauth.getUser(), userOauth); + } + + /** + * 이미 (provider, providerUserId) 매핑이 존재하는 경우의 가입 처리. + * - ACTIVE 회원 : 이미 가입됨 → USER_ALREADY_EXISTS + * - WITHDRAWN 회원: 탈퇴 후 7일 이내 → REJOIN_BLOCKED / 7일 경과 → 같은 uid 재활성 + */ + private SocialUserResult registerExisting(UserOauth userOauth, SocialLoginCommand command) { User user = userOauth.getUser(); - return new SocialUserResult( - user.getId(), - userOauth.getProvider(), - userOauth.getProviderUserId(), - user.getNickname(), - user.getProfileImageUrl()); + if (!user.isWithdrawn()) { + throw new BusinessException(UserErrorCode.USER_ALREADY_EXISTS); + } + + if (isWithinRejoinBlock(user)) { + throw new RejoinBlockedException(); + } + + // 7일 경과 — 같은 uid 재활성(온보딩은 signup 흐름에서 처음부터 다시 진행). + user.reactivate(command.nickname()); + return toResult(user, userOauth); + } + + private boolean isWithinRejoinBlock(User user) { + Instant withdrawnAt = user.getWithdrawnAt(); + return withdrawnAt != null + && withdrawnAt.isAfter(Instant.now().minus(REJOIN_BLOCK_DAYS, ChronoUnit.DAYS)); } private UserOauth createSocialUser(SocialLoginCommand command) { @@ -79,4 +107,13 @@ private UserOauth createSocialUser(SocialLoginCommand command) { .orElseThrow(() -> e); } } + + private SocialUserResult toResult(User user, UserOauth userOauth) { + return new SocialUserResult( + user.getId(), + userOauth.getProvider(), + userOauth.getProviderUserId(), + user.getNickname(), + user.getProfileImageUrl()); + } } diff --git a/user-service/src/main/java/com/polywave/userservice/application/user/command/UserWithdrawCommand.java b/user-service/src/main/java/com/polywave/userservice/application/user/command/UserWithdrawCommand.java new file mode 100644 index 0000000..aee0da5 --- /dev/null +++ b/user-service/src/main/java/com/polywave/userservice/application/user/command/UserWithdrawCommand.java @@ -0,0 +1,10 @@ +package com.polywave.userservice.application.user.command; + +import com.polywave.userservice.domain.WithdrawalReason; +import java.util.List; + +public record UserWithdrawCommand( + List reasons, + String etcText +) { +} diff --git a/user-service/src/main/java/com/polywave/userservice/application/user/command/service/UserCommandService.java b/user-service/src/main/java/com/polywave/userservice/application/user/command/service/UserCommandService.java index 01f670f..fe2fadc 100644 --- a/user-service/src/main/java/com/polywave/userservice/application/user/command/service/UserCommandService.java +++ b/user-service/src/main/java/com/polywave/userservice/application/user/command/service/UserCommandService.java @@ -2,6 +2,7 @@ import com.polywave.userservice.application.user.command.UserUpdateBasicProfileCommand; import com.polywave.userservice.application.user.command.UserUpdateProfileCommand; +import com.polywave.userservice.application.user.command.UserWithdrawCommand; import com.polywave.userservice.domain.OnBoardingStatus; public interface UserCommandService { @@ -12,4 +13,6 @@ public interface UserCommandService { void updateUserOnboardingStatus(Long userId, OnBoardingStatus onBoardingStatus); void deleteUser(Long userId); + + void withdraw(Long userId, UserWithdrawCommand command); } \ No newline at end of file diff --git a/user-service/src/main/java/com/polywave/userservice/application/user/command/service/UserCommandServiceImpl.java b/user-service/src/main/java/com/polywave/userservice/application/user/command/service/UserCommandServiceImpl.java index a9b29cd..1594ac1 100644 --- a/user-service/src/main/java/com/polywave/userservice/application/user/command/service/UserCommandServiceImpl.java +++ b/user-service/src/main/java/com/polywave/userservice/application/user/command/service/UserCommandServiceImpl.java @@ -4,6 +4,7 @@ import com.polywave.userservice.application.nickname.policy.NicknamePolicyService; import com.polywave.userservice.application.user.command.UserUpdateBasicProfileCommand; import com.polywave.userservice.application.user.command.UserUpdateProfileCommand; +import com.polywave.userservice.application.user.command.UserWithdrawCommand; import com.polywave.userservice.common.exception.DuplicateNicknameException; import com.polywave.userservice.common.exception.ForbiddenNicknameException; import com.polywave.userservice.common.exception.InvalidOnboardingStatusException; @@ -11,6 +12,7 @@ import com.polywave.userservice.domain.OnBoardingStatus; import com.polywave.userservice.domain.User; import com.polywave.userservice.repository.command.UserCommandRepository; +import com.polywave.userservice.repository.command.UserTermsCommandRepository; import com.polywave.userservice.repository.query.UserQueryRepository; import java.time.Instant; import lombok.RequiredArgsConstructor; @@ -25,6 +27,7 @@ public class UserCommandServiceImpl implements UserCommandService { private final UserCommandRepository userCommandRepository; private final UserQueryRepository userQueryRepository; private final NicknamePolicyService nicknamePolicyService; + private final UserTermsCommandRepository userTermsCommandRepository; @Override public void updateUserProfile(Long userId, UserUpdateProfileCommand command) { @@ -106,6 +109,28 @@ public void deleteUser(Long userId) { userCommandRepository.delete(user); } + /** + * 회원 탈퇴의 user-service DB 작업(soft-delete)만 담당한다. + * 타 서비스 데이터 삭제(북마크/관심사/조회/알림)는 트랜잭션 밖에서 {@code UserWithdrawalService} 가 선행 호출한다. + */ + @Override + public void withdraw(Long userId, UserWithdrawCommand command) { + User user = userCommandRepository.findById(userId) + .orElseThrow(UserNotFoundException::new); + + if (user.isWithdrawn()) { + // 이미 탈퇴한 계정 — 멱등하게 처리(없는 사용자 취급). + throw new UserNotFoundException(); + } + + // 약관 동의 내역 말소(재가입 시 재동의로 채워짐). + userTermsCommandRepository.deleteAllByUserId(userId); + + // soft-delete: PII 말소 + WITHDRAWN + 세션 무효화 + 사유 기록. + // user 행/uid 와 user_oauths 매핑은 보존되어 7일 후 재가입 시 같은 uid 로 재활성된다. + user.withdraw(command.reasons(), command.etcText()); + } + private void validateBasicProfileUpdatable(User user) { if (user.getOnboardingStatus() != OnBoardingStatus.COMPLETE) { throw new InvalidOnboardingStatusException(); diff --git a/user-service/src/main/java/com/polywave/userservice/application/user/command/service/UserWithdrawalService.java b/user-service/src/main/java/com/polywave/userservice/application/user/command/service/UserWithdrawalService.java new file mode 100644 index 0000000..bd77f8a --- /dev/null +++ b/user-service/src/main/java/com/polywave/userservice/application/user/command/service/UserWithdrawalService.java @@ -0,0 +1,34 @@ +package com.polywave.userservice.application.user.command.service; + +import com.polywave.userservice.application.user.command.UserWithdrawCommand; +import com.polywave.userservice.client.BillUserDataClient; +import com.polywave.userservice.client.NotificationUserDataClient; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +/** + * 회원 탈퇴 오케스트레이션. + * + *

의도적으로 {@code @Transactional} 을 두지 않는다 — 타 서비스 HTTP 삭제를 DB 트랜잭션 밖에서 수행하여 + * 네트워크 대기(connect 3s / read 5s) 동안 DB 커넥션을 점유하지 않게 한다(커넥션 풀 고갈 방지). + * + *

순서: (1) 타 서비스 사용자 데이터 삭제(HTTP, 멱등) → (2) user-service soft-delete(짧은 트랜잭션). + * 부분 실패 시 (2)가 커밋되지 않아 사용자는 ACTIVE 로 남고, 동일 요청 재시도로 복구된다(모든 삭제가 멱등). + */ +@Service +@RequiredArgsConstructor +public class UserWithdrawalService { + + private final BillUserDataClient billUserDataClient; + private final NotificationUserDataClient notificationUserDataClient; + private final UserCommandService userCommandService; + + public void withdraw(Long userId, UserWithdrawCommand command) { + // 1) 타 서비스 사용자 파생 데이터 삭제 (북마크/관심사/조회, 알림). 표결은 보존. (DB 트랜잭션 밖) + billUserDataClient.deleteUserBillData(userId); + notificationUserDataClient.deleteUserNotifications(userId); + + // 2) user-service soft-delete (DB 작업만, 짧은 트랜잭션) + userCommandService.withdraw(userId, command); + } +} diff --git a/user-service/src/main/java/com/polywave/userservice/client/BillUserDataClient.java b/user-service/src/main/java/com/polywave/userservice/client/BillUserDataClient.java new file mode 100644 index 0000000..b0513d5 --- /dev/null +++ b/user-service/src/main/java/com/polywave/userservice/client/BillUserDataClient.java @@ -0,0 +1,64 @@ +package com.polywave.userservice.client; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +/** + * 회원 탈퇴 시 bill-service 의 사용자 파생 데이터(북마크/관심사/조회 기록)를 삭제하는 internal 호출 클라이언트. + * + *

표결(user_bill_votes)은 삭제 대상이 아니며 bill-service 측에서 보존한다. + * + *

조회용 segment 클라이언트와 달리 삭제는 실패를 삼키지 않고 예외를 전파한다. + * 호출이 실패하면 탈퇴 트랜잭션이 롤백되어 사용자가 재시도할 수 있다(삭제는 멱등). + */ +@Slf4j +@Component +public class BillUserDataClient { + + private static final String DELETE_PATH = "/internal/users/{userId}/bill-data"; + + private final RestTemplate restTemplate; + private final String billServiceUrl; + private final String internalApiKey; + + public BillUserDataClient( + RestTemplate restTemplate, + @Value("${bill-service.url}") String billServiceUrl, + @Value("${bill-service.internal-api.key:}") String internalApiKey + ) { + this.restTemplate = restTemplate; + this.billServiceUrl = stripTrailingSlash(billServiceUrl); + this.internalApiKey = internalApiKey; + } + + public void deleteUserBillData(Long userId) { + if (internalApiKey == null || internalApiKey.isBlank()) { + throw new IllegalStateException( + "bill-service.internal-api.key 가 설정되지 않아 탈퇴(bill 데이터 삭제)를 진행할 수 없습니다."); + } + + String url = UriComponentsBuilder.fromHttpUrl(billServiceUrl + DELETE_PATH) + .buildAndExpand(userId) + .toUriString(); + + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Internal-Api-Key", internalApiKey); + + // 2xx 가 아니면 RestTemplate 가 예외를 던져 호출부(탈퇴 트랜잭션)로 전파된다. + restTemplate.exchange(url, HttpMethod.DELETE, new HttpEntity<>(headers), Void.class); + log.info("bill-service 사용자 데이터 삭제 완료 userId={}", userId); + } + + private static String stripTrailingSlash(String url) { + if (url == null) { + return ""; + } + return url.endsWith("/") ? url.substring(0, url.length() - 1) : url; + } +} diff --git a/user-service/src/main/java/com/polywave/userservice/client/NotificationUserDataClient.java b/user-service/src/main/java/com/polywave/userservice/client/NotificationUserDataClient.java new file mode 100644 index 0000000..190f515 --- /dev/null +++ b/user-service/src/main/java/com/polywave/userservice/client/NotificationUserDataClient.java @@ -0,0 +1,61 @@ +package com.polywave.userservice.client; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +/** + * 회원 탈퇴 시 notification-service 의 사용자 알림 데이터를 삭제하는 internal 호출 클라이언트. + * + *

notification-service 는 X-Admin-Api-Key 헤더로 가드된다(bill-service 의 X-Internal-Api-Key 와 다름). + * 삭제 실패 시 예외를 전파하여 탈퇴 트랜잭션이 롤백되게 한다(삭제는 멱등). + */ +@Slf4j +@Component +public class NotificationUserDataClient { + + private static final String DELETE_PATH = "/internal/user-notifications/{userId}"; + + private final RestTemplate restTemplate; + private final String notificationServiceUrl; + private final String adminApiKey; + + public NotificationUserDataClient( + RestTemplate restTemplate, + @Value("${notification-service.url}") String notificationServiceUrl, + @Value("${notification-service.admin-api.key:}") String adminApiKey + ) { + this.restTemplate = restTemplate; + this.notificationServiceUrl = stripTrailingSlash(notificationServiceUrl); + this.adminApiKey = adminApiKey; + } + + public void deleteUserNotifications(Long userId) { + if (adminApiKey == null || adminApiKey.isBlank()) { + throw new IllegalStateException( + "notification-service.admin-api.key 가 설정되지 않아 탈퇴(알림 삭제)를 진행할 수 없습니다."); + } + + String url = UriComponentsBuilder.fromHttpUrl(notificationServiceUrl + DELETE_PATH) + .buildAndExpand(userId) + .toUriString(); + + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Admin-Api-Key", adminApiKey); + + restTemplate.exchange(url, HttpMethod.DELETE, new HttpEntity<>(headers), Void.class); + log.info("notification-service 사용자 알림 삭제 완료 userId={}", userId); + } + + private static String stripTrailingSlash(String url) { + if (url == null) { + return ""; + } + return url.endsWith("/") ? url.substring(0, url.length() - 1) : url; + } +} diff --git a/user-service/src/main/java/com/polywave/userservice/common/exception/RejoinBlockedException.java b/user-service/src/main/java/com/polywave/userservice/common/exception/RejoinBlockedException.java new file mode 100644 index 0000000..6eb1ae9 --- /dev/null +++ b/user-service/src/main/java/com/polywave/userservice/common/exception/RejoinBlockedException.java @@ -0,0 +1,13 @@ +package com.polywave.userservice.common.exception; + +import com.polywave.common.exception.BusinessException; + +/** + * 탈퇴 후 재가입 제한 기간(7일) 내에 동일 소셜 계정으로 재가입을 시도한 경우. + */ +public class RejoinBlockedException extends BusinessException { + + public RejoinBlockedException() { + super(UserErrorCode.REJOIN_BLOCKED); + } +} diff --git a/user-service/src/main/java/com/polywave/userservice/common/exception/UserErrorCode.java b/user-service/src/main/java/com/polywave/userservice/common/exception/UserErrorCode.java index cb743e3..9514fbe 100644 --- a/user-service/src/main/java/com/polywave/userservice/common/exception/UserErrorCode.java +++ b/user-service/src/main/java/com/polywave/userservice/common/exception/UserErrorCode.java @@ -18,6 +18,9 @@ public enum UserErrorCode implements ErrorCode { /** 소셜 로그인 제공자 및 고유 ID로 조회 시 이미 가입된 사용자인 경우 */ USER_ALREADY_EXISTS(HttpStatus.BAD_REQUEST, "USER_ALREADY_EXISTS"), + /** 탈퇴 후 재가입 제한 기간(7일) 내에 동일 소셜 계정으로 재가입을 시도한 경우 */ + REJOIN_BLOCKED(HttpStatus.CONFLICT, "REJOIN_BLOCKED"), + /** 닉네임 설정 시 이미 다른 사용자가 사용 중인 경우 중복 에러 */ DUPLICATE_NICKNAME(HttpStatus.CONFLICT, "DUPLICATE_NICKNAME"), diff --git a/user-service/src/main/java/com/polywave/userservice/config/RestTemplateConfig.java b/user-service/src/main/java/com/polywave/userservice/config/RestTemplateConfig.java new file mode 100644 index 0000000..5cd04d6 --- /dev/null +++ b/user-service/src/main/java/com/polywave/userservice/config/RestTemplateConfig.java @@ -0,0 +1,19 @@ +package com.polywave.userservice.config; + +import java.time.Duration; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; + +@Configuration +public class RestTemplateConfig { + + @Bean + public RestTemplate restTemplate(RestTemplateBuilder builder) { + return builder + .setConnectTimeout(Duration.ofSeconds(3)) + .setReadTimeout(Duration.ofSeconds(5)) + .build(); + } +} diff --git a/user-service/src/main/java/com/polywave/userservice/domain/User.java b/user-service/src/main/java/com/polywave/userservice/domain/User.java index 1519cf8..1422da1 100644 --- a/user-service/src/main/java/com/polywave/userservice/domain/User.java +++ b/user-service/src/main/java/com/polywave/userservice/domain/User.java @@ -3,6 +3,8 @@ import com.polywave.common.domain.BaseEntity; import jakarta.persistence.*; import java.time.Instant; +import java.util.List; +import java.util.stream.Collectors; import lombok.*; @Getter @@ -69,6 +71,23 @@ public class User extends BaseEntity { @Column(name = "profile_completed_at") private Instant profileCompletedAt; + /** 계정 상태. 탈퇴(soft-delete) 시 WITHDRAWN 으로 전이된다. */ + @Enumerated(EnumType.STRING) + @Column(length = 10, nullable = false) + @Builder.Default + private UserStatus status = UserStatus.ACTIVE; + + @Column(name = "withdrawn_at") + private Instant withdrawnAt; + + /** 탈퇴 사유(다중 선택)를 enum 이름 콤마 조합으로 저장. */ + @Column(name = "withdrawal_reasons", columnDefinition = "TEXT") + private String withdrawalReasons; + + /** 기타(ETC) 사유 직접 입력. 최대 200자. */ + @Column(name = "reason_etc", length = 200) + private String reasonEtc; + public void changeNickname(String nickname) { this.nickname = nickname; } @@ -111,4 +130,52 @@ public void markProfileCompletedAtIfAbsent(Instant at) { this.profileCompletedAt = at; } } + + public boolean isWithdrawn() { + return this.status == UserStatus.WITHDRAWN; + } + + /** + * 회원 탈퇴(soft-delete). 행과 id 는 보존하되 PII 를 말소하고 WITHDRAWN 으로 전이한다. + * - 닉네임 NULL 처리로 슬롯을 해제(타인이 재사용 가능). + * - authSessionId NULL 로 잔존 토큰을 무효화. + * - 온보딩 마일스톤 초기화로 재가입 시 온보딩을 처음부터 진행. + * - 표결(user_bill_votes)은 user-service 밖이라 여기서 다루지 않으며, uid 보존으로 그대로 연결 유지된다. + */ + public void withdraw(List reasons, String etcText) { + this.status = UserStatus.WITHDRAWN; + this.withdrawnAt = Instant.now(); + this.withdrawalReasons = (reasons == null || reasons.isEmpty()) + ? null + : reasons.stream().map(Enum::name).collect(Collectors.joining(",")); + this.reasonEtc = (etcText == null || etcText.isBlank()) ? null : etcText.strip(); + + this.nickname = null; + this.gender = null; + this.birthDate = null; + this.profileImageUrl = null; + this.sido = null; + this.sigungu = null; + this.emdName = null; + this.address = null; + + this.authSessionId = null; + + this.nicknameSetAt = null; + this.categorySetAt = null; + this.profileCompletedAt = null; + } + + /** + * 탈퇴 후 7일이 지난 같은 소셜 계정의 재가입 — 동일 uid 를 재활성한다. + * 온보딩을 처음부터 다시 진행하도록 상태를 SIGNUP 으로 리셋한다. + */ + public void reactivate(String nickname) { + this.status = UserStatus.ACTIVE; + this.withdrawnAt = null; + this.withdrawalReasons = null; + this.reasonEtc = null; + this.nickname = nickname; + this.onboardingStatus = OnBoardingStatus.SIGNUP; + } } \ No newline at end of file diff --git a/user-service/src/main/java/com/polywave/userservice/domain/UserStatus.java b/user-service/src/main/java/com/polywave/userservice/domain/UserStatus.java new file mode 100644 index 0000000..7292dd7 --- /dev/null +++ b/user-service/src/main/java/com/polywave/userservice/domain/UserStatus.java @@ -0,0 +1,11 @@ +package com.polywave.userservice.domain; + +/** + * 사용자 계정 상태. + * WITHDRAWN 은 탈퇴(soft-delete) 상태로, PII 가 말소되고 7일 재가입 차단 판정 대상이 된다. + * 같은 uid 재활성을 위해 행 자체는 보존된다. + */ +public enum UserStatus { + ACTIVE, + WITHDRAWN +} diff --git a/user-service/src/main/java/com/polywave/userservice/domain/WithdrawalReason.java b/user-service/src/main/java/com/polywave/userservice/domain/WithdrawalReason.java new file mode 100644 index 0000000..e62468d --- /dev/null +++ b/user-service/src/main/java/com/polywave/userservice/domain/WithdrawalReason.java @@ -0,0 +1,21 @@ +package com.polywave.userservice.domain; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * 회원 탈퇴 사유. 탈퇴 화면에서 다중 선택 가능하다. + * description 은 화면 노출 문구와 일치시킨다. + */ +@Getter +@RequiredArgsConstructor +public enum WithdrawalReason { + INFREQUENT_USE("사용하는 빈도가 낮아요"), + MISSING_FEATURE("원하는 기능이 없어요"), + HARD_TO_USE("사용방법이 어렵고 불편해요"), + LOW_QUALITY("결과물 품질이 기대와 달라요"), + USING_ALTERNATIVE("다른 유사 서비스를 이용해요"), + ETC("기타"); + + private final String description; +} diff --git a/user-service/src/main/java/com/polywave/userservice/repository/command/UserTermsCommandRepository.java b/user-service/src/main/java/com/polywave/userservice/repository/command/UserTermsCommandRepository.java index 19063e4..fbb4741 100644 --- a/user-service/src/main/java/com/polywave/userservice/repository/command/UserTermsCommandRepository.java +++ b/user-service/src/main/java/com/polywave/userservice/repository/command/UserTermsCommandRepository.java @@ -4,10 +4,18 @@ import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface UserTermsCommandRepository extends JpaRepository { Optional findByUserIdAndTermsId(Long userId, Long termsId); List findByUserIdAndTermsIdIn(Long userId, List termsIds); + + // 회원 탈퇴 시 약관 동의 내역 말소. (재가입 시 약관 재동의로 다시 채워진다) + @Modifying + @Query("DELETE FROM UserTerms ut WHERE ut.user.id = :userId") + int deleteAllByUserId(@Param("userId") Long userId); } diff --git a/user-service/src/main/java/com/polywave/userservice/repository/query/impl/UserQueryRepositoryImpl.java b/user-service/src/main/java/com/polywave/userservice/repository/query/impl/UserQueryRepositoryImpl.java index 6eb6cc8..4a75f80 100644 --- a/user-service/src/main/java/com/polywave/userservice/repository/query/impl/UserQueryRepositoryImpl.java +++ b/user-service/src/main/java/com/polywave/userservice/repository/query/impl/UserQueryRepositoryImpl.java @@ -5,6 +5,7 @@ import com.polywave.userservice.domain.OnBoardingStatus; import com.polywave.userservice.domain.QUser; import com.polywave.userservice.domain.QUserOauth; +import com.polywave.userservice.domain.UserStatus; import com.polywave.userservice.repository.query.UserQueryRepository; import com.querydsl.core.Tuple; import com.querydsl.jpa.impl.JPAQueryFactory; @@ -34,7 +35,10 @@ public OnBoardingStatus findOnboardingStatusByUserId(Long userId) { return jpaQueryFactory .select(QUser.user.onboardingStatus) .from(QUser.user) - .where(QUser.user.id.eq(userId)) + .where( + QUser.user.id.eq(userId), + QUser.user.status.eq(UserStatus.ACTIVE) + ) .fetchOne(); } @@ -60,7 +64,7 @@ public UserMeResult findUserMeByUserId(Long userId) { ) .from(u) .leftJoin(o).on(o.user.id.eq(u.id)) - .where(u.id.eq(userId)) + .where(u.id.eq(userId), u.status.eq(UserStatus.ACTIVE)) .fetchFirst(); if (t == null) { @@ -94,7 +98,8 @@ public boolean existsValidSession(Long userId, String sessionId) { .from(QUser.user) .where( QUser.user.id.eq(userId), - QUser.user.authSessionId.eq(sessionId) + QUser.user.authSessionId.eq(sessionId), + QUser.user.status.eq(UserStatus.ACTIVE) ) .fetchFirst(); @@ -110,7 +115,7 @@ public List findNicknamesByUserIds(Collection userIds) List rows = jpaQueryFactory .select(u.id, u.nickname) .from(u) - .where(u.id.in(userIds)) + .where(u.id.in(userIds), u.status.eq(UserStatus.ACTIVE)) .fetch(); return rows.stream() diff --git a/user-service/src/main/resources/application.properties b/user-service/src/main/resources/application.properties index 4c68e38..321f0d8 100644 --- a/user-service/src/main/resources/application.properties +++ b/user-service/src/main/resources/application.properties @@ -67,4 +67,13 @@ user.dev-delete.enabled=false # notification-service 가 user-service 의 internal API 호출 시 사용하는 공유 키. # - notification-service 의 NOTIFICATION_INTERNAL_API_KEY 와 동일한 값이어야 한다. # - 비어있으면 /internal/segments/**, /internal/lookup/** 경로는 무조건 403 (fail-closed). -user-service.internal-api.key=${USER_SERVICE_INTERNAL_API_KEY:} \ No newline at end of file +user-service.internal-api.key=${USER_SERVICE_INTERNAL_API_KEY:} + +# 회원 탈퇴 시 user-service 가 호출하는 타 서비스 internal 삭제 API. +# - bill-service: 북마크/관심사/조회 기록 삭제 (X-Internal-Api-Key). 표결은 보존. +# - notification-service: 알림 삭제 (X-Admin-Api-Key). +# - 키가 비어 있으면 탈퇴 시 예외(fail-closed) — 운영에서는 시크릿으로 주입한다. +bill-service.url=${BILL_SERVICE_URL:http://bill-service/bills} +bill-service.internal-api.key=${BILL_SERVICE_INTERNAL_API_KEY:} +notification-service.url=${NOTIFICATION_SERVICE_URL:http://notification-service} +notification-service.admin-api.key=${NOTIFICATION_ADMIN_API_KEY:} \ No newline at end of file diff --git a/user-service/src/main/resources/db/migration/V8__add_user_withdrawal_columns.sql b/user-service/src/main/resources/db/migration/V8__add_user_withdrawal_columns.sql new file mode 100644 index 0000000..173e32f --- /dev/null +++ b/user-service/src/main/resources/db/migration/V8__add_user_withdrawal_columns.sql @@ -0,0 +1,13 @@ +SET search_path TO user_service; + +-- 회원 탈퇴(soft-delete) 지원 컬럼. +-- 탈퇴 시 users 행은 보존하되 PII 를 말소하고 status=WITHDRAWN + withdrawn_at 를 기록한다. +-- 같은 uid 재활성(재가입 시 동일 user_id 재사용)과 탈퇴 후 7일 재가입 차단 판정에 사용한다. +-- (V6/V7 의 ADD COLUMN IF NOT EXISTS 멱등 패턴 준수) +ALTER TABLE users ADD COLUMN IF NOT EXISTS status VARCHAR(10) NOT NULL DEFAULT 'ACTIVE'; +ALTER TABLE users ADD COLUMN IF NOT EXISTS withdrawn_at TIMESTAMPTZ; +ALTER TABLE users ADD COLUMN IF NOT EXISTS withdrawal_reasons TEXT; +ALTER TABLE users ADD COLUMN IF NOT EXISTS reason_etc VARCHAR(200); + +-- 7일 재가입 차단/탈퇴자 조회용 +CREATE INDEX IF NOT EXISTS idx_users_status_withdrawn_at ON users (status, withdrawn_at); From cbcc4177091ebc762e9bd76ed2f259fb6ebfa3b1 Mon Sep 17 00:00:00 2001 From: seongyonglim Date: Sat, 11 Jul 2026 12:05:58 +0900 Subject: [PATCH 2/2] =?UTF-8?q?docs(user-service):=20Swagger=EC=97=90=20?= =?UTF-8?q?=ED=83=88=ED=87=B4=20=EC=82=AC=EC=9C=A0=20=EC=BD=94=EB=93=9C?= =?UTF-8?q?=EA=B0=92=C2=B7=EC=84=A4=EB=AA=85=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../userservice/api/dto/UserWithdrawRequest.java | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/user-service/src/main/java/com/polywave/userservice/api/dto/UserWithdrawRequest.java b/user-service/src/main/java/com/polywave/userservice/api/dto/UserWithdrawRequest.java index 08a45a1..7b47854 100644 --- a/user-service/src/main/java/com/polywave/userservice/api/dto/UserWithdrawRequest.java +++ b/user-service/src/main/java/com/polywave/userservice/api/dto/UserWithdrawRequest.java @@ -8,8 +8,14 @@ @Schema(description = "회원 탈퇴 요청") public record UserWithdrawRequest( @Schema( - description = "탈퇴 사유(중복 선택). 가능 값: " - + "INFREQUENT_USE, MISSING_FEATURE, HARD_TO_USE, LOW_QUALITY, USING_ALTERNATIVE, ETC", + description = """ + 탈퇴 사유(중복 선택). 코드 값: + - INFREQUENT_USE: 사용하는 빈도가 낮아요 + - MISSING_FEATURE: 원하는 기능이 없어요 + - HARD_TO_USE: 사용방법이 어렵고 불편해요 + - LOW_QUALITY: 결과물 품질이 기대와 달라요 + - USING_ALTERNATIVE: 다른 유사 서비스를 이용해요 + - ETC: 기타""", example = "[\"INFREQUENT_USE\", \"ETC\"]" ) List reasons,