diff --git a/docs/API_SPECIFICATION.md b/docs/API_SPECIFICATION.md index e9598910..7dfd4b62 100644 --- a/docs/API_SPECIFICATION.md +++ b/docs/API_SPECIFICATION.md @@ -5,10 +5,12 @@ | 인덱스 | 기능 | Method | API Path | 설명 | FE 개발 현황 | BE 개발 현황 | 수정일 | JSON Example | |--------|------|--------|----------|------|--------------|--------------|--------|--------------| | **1. 인증 API** | | | | | | | | | -| 1.1 | 카카오 로그인 | POST | `/api/auth/kakao/login` | 카카오 소셜 로그인 (신규 사용자 자동 등록) | 대기 | 대기 | - | [링크](#11-카카오-로그인) | +| 1.1 | 카카오 로그인 | POST | `/api/auth/kakao/login` | 카카오 소셜 로그인 (탈퇴 계정 시 status=WITHDRAWN_PENDING 반환) | 대기 | 완료 | 2026-05-11 | [링크](#11-카카오-로그인) | | 1.2 | 로그아웃 | POST | `/api/auth/logout` | 로그아웃 및 토큰 무효화 | 대기 | 대기 | - | [링크](#12-로그아웃) | | 1.3 | 토큰 갱신 | POST | `/api/auth/refresh` | Access Token 갱신 | 대기 | 대기 | - | [링크](#13-토큰-갱신) | | 1.4 | 회원 탈퇴 | DELETE | `/api/auth/withdraw` | 회원 탈퇴 및 카카오 연결 해제 | 대기 | 완료 | 2026-03-11 | [링크](#14-회원-탈퇴) | +| 1.5 | 탈퇴 계정 복구 | POST | `/api/auth/kakao/restore` | 30일 hard-delete 전 탈퇴 계정 활성화 (탈퇴 취소) | 대기 | 완료 | 2026-05-11 | [링크](#15-탈퇴-계정-복구) | +| 1.6 | 탈퇴 계정 완전삭제 후 재가입 | POST | `/api/auth/kakao/purge-and-register` | 기존 탈퇴 계정 영구 삭제 후 신규 가입 | 대기 | 완료 | 2026-05-11 | [링크](#16-탈퇴-계정-완전삭제-후-재가입) | | **2. 사용자 API** | | | | | | | | | | 2.1 | 내 정보 조회 | GET | `/api/users/me` | 로그인한 사용자 정보 | 대기 | 대기 | - | [링크](#21-내-정보-조회) | | 2.2 | 내 정보 수정 | PUT | `/api/users/me` | 사용자 정보 수정 | 대기 | 대기 | - | [링크](#22-내-정보-수정) | @@ -79,6 +81,8 @@ ### 1.1 카카오 로그인 +탈퇴 상태 사용자가 동일 카카오 계정으로 재로그인하면 예외 대신 `status: "WITHDRAWN_PENDING"`이 반환되어 클라이언트가 [1.5 복구](#15-탈퇴-계정-복구) 또는 [1.6 완전삭제 후 재가입](#16-탈퇴-계정-완전삭제-후-재가입)을 사용자에게 안내할 수 있다. + **Request:** ```json { @@ -86,34 +90,59 @@ } ``` -**Response (고용주):** +**Response (정상 로그인 - 고용주):** ```json { "success": true, "data": { - "id": 1001, - "kakaoId": "123456789", - "name": "김철수", - "userType": "EMPLOYER", + "status": "LOGGED_IN", "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", - "createdAt": "2025-12-10T14:30:00" + "userId": 1001, + "name": "김철수", + "userType": "EMPLOYER" } } ``` -**Response (근로자):** +**Response (정상 로그인 - 근로자):** ```json { "success": true, "data": { - "id": 2001, - "kakaoId": "987654321", - "name": "이영희", - "userType": "WORKER", + "status": "LOGGED_IN", "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", - "createdAt": "2025-12-10T14:30:00" + "userId": 2001, + "name": "이영희", + "userType": "WORKER" + } +} +``` + +**Response (탈퇴 계정 발견 - 30일 hard-delete 전):** +```json +{ + "success": true, + "data": { + "status": "WITHDRAWN_PENDING", + "withdrawnAccount": { + "name": "김철수", + "userType": "EMPLOYER", + "withdrawnAt": "2026-05-07T17:02:00", + "profileImageUrl": "https://..." + } + } +} +``` + +**Error (등록되지 않은 사용자):** +```json +{ + "success": false, + "error": { + "code": "USER_NOT_FOUND", + "message": "등록되지 않은 카카오 계정입니다. 회원가입을 진행해주세요." } } ``` @@ -192,6 +221,94 @@ --- +### 1.5 탈퇴 계정 복구 + +탈퇴 후 30일 hard-delete 스케줄러가 돌기 전에 동일 카카오 계정으로 재로그인 시 [1.1 카카오 로그인](#11-카카오-로그인) 응답이 `status: "WITHDRAWN_PENDING"`이 되며, 사용자가 "기존 계정 복구"를 선택하면 이 엔드포인트를 호출한다. + +`User.deletedAt = null`로 되돌리고 `UserSettings`(탈퇴 시 보존됨)는 이전 값 그대로 유지된다. 비활성화된 사업장/계약/근무기록은 자동 복구하지 않는다 (다른 사용자 데이터 일관성 보호). + +**Request:** +```json +{ + "kakaoAccessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." +} +``` + +**Response (성공):** +```json +{ + "success": true, + "data": { + "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "userId": 1001, + "name": "김철수", + "userType": "EMPLOYER" + } +} +``` + +**Error (탈퇴 상태가 아닌 계정에 호출):** +```json +{ + "success": false, + "error": { + "code": "USER_NOT_WITHDRAWN", + "message": "탈퇴 상태가 아닌 계정입니다." + } +} +``` + +--- + +### 1.6 탈퇴 계정 완전삭제 후 재가입 + +사용자가 탈퇴 후 재로그인 시점에 "기존 데이터를 모두 삭제하고 새 계정으로 가입"을 선택하면 호출한다. 기존 사용자와 모든 산하 데이터(사업장/계약/근무기록/급여/결제/정정요청 등)를 30일 스케줄러와 동일한 경로로 영구 삭제한 뒤 신규 회원가입을 진행한다. + +`hardDelete + register`가 하나의 트랜잭션으로 묶여 있어 register 단계에서 실패하면 hard delete도 함께 롤백된다 (데이터 영구 손실 방지). + +**Request:** +```json +{ + "kakaoAccessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "name": "김철수", + "userType": "WORKER", + "phone": "010-1234-5678", + "bankName": "카카오뱅크", + "accountNumber": "3333123456789", + "profileImageUrl": "https://..." +} +``` + +> `bankName` / `accountNumber`는 `userType=WORKER`일 때 필수. + +**Response (성공):** +```json +{ + "success": true, + "data": { + "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "userId": 1042, + "name": "김철수", + "userType": "WORKER" + } +} +``` + +**Error (정상 계정에 호출):** +```json +{ + "success": false, + "error": { + "code": "DUPLICATE_KAKAO_ACCOUNT", + "message": "이미 가입된 카카오 계정입니다." + } +} +``` + +--- + ### 2.1 내 정보 조회 **Response:** diff --git a/src/main/java/com/example/paycheck/api/auth/AuthController.java b/src/main/java/com/example/paycheck/api/auth/AuthController.java index 3739c1d9..1553d931 100644 --- a/src/main/java/com/example/paycheck/api/auth/AuthController.java +++ b/src/main/java/com/example/paycheck/api/auth/AuthController.java @@ -19,22 +19,39 @@ public class AuthController { private final AuthService authService; - @Operation(summary = "카카오 로그인", description = "카카오 액세스 토큰을 검증하고 자체 JWT를 발급합니다.") + @Operation( + summary = "카카오 로그인", + description = "카카오 액세스 토큰을 검증하고 자체 JWT를 발급합니다. " + + "탈퇴 상태 계정인 경우 status=WITHDRAWN_PENDING으로 응답하여 " + + "클라이언트가 복구(/kakao/restore) 또는 완전 삭제 후 재가입(/kakao/purge-and-register)을 안내할 수 있습니다." + ) @PostMapping("/kakao/login") - public ApiResponse kakaoLogin( + public ApiResponse kakaoLogin( @Valid @RequestBody AuthDto.KakaoLoginRequest request) { - AuthService.LoginResult loginResult = authService.loginWithKakao(request.getKakaoAccessToken()); + return ApiResponse.success(authService.loginWithKakao(request.getKakaoAccessToken())); + } - // Refresh Token을 응답에 포함 (body 방식) - AuthDto.LoginResponse response = AuthDto.LoginResponse.builder() - .accessToken(loginResult.getLoginResponse().getAccessToken()) - .userId(loginResult.getLoginResponse().getUserId()) - .name(loginResult.getLoginResponse().getName()) - .userType(loginResult.getLoginResponse().getUserType()) - .refreshToken(loginResult.getRefreshToken()) - .build(); + @Operation( + summary = "탈퇴 계정 복구", + description = "30일 hard-delete 전 탈퇴 계정을 다시 활성화합니다. " + + "User.deletedAt이 null로 되돌아가며, 탈퇴 시 보존된 UserSettings는 그대로 유지됩니다. " + + "비활성화된 사업장/계약/근무기록은 자동 복구되지 않습니다." + ) + @PostMapping("/kakao/restore") + public ApiResponse kakaoRestore( + @Valid @RequestBody AuthDto.KakaoLoginRequest request) { + return ApiResponse.success(authService.restoreWithKakao(request.getKakaoAccessToken())); + } - return ApiResponse.success(response); + @Operation( + summary = "탈퇴 계정 완전 삭제 후 재가입", + description = "기존 탈퇴 계정과 모든 산하 데이터를 영구 삭제(30일 스케줄러와 동일 경로)한 뒤 " + + "신규 사용자로 회원가입합니다. 정상 계정에 호출 시 차단됩니다." + ) + @PostMapping("/kakao/purge-and-register") + public ApiResponse kakaoPurgeAndRegister( + @Valid @RequestBody AuthDto.KakaoRegisterRequest request) { + return ApiResponse.success(authService.purgeAndRegisterWithKakao(request)); } @Operation(summary = "카카오 회원가입", description = "카카오 프로필 정보를 기반으로 사용자를 등록하고 JWT를 발급합니다.") diff --git a/src/main/java/com/example/paycheck/common/exception/ErrorCode.java b/src/main/java/com/example/paycheck/common/exception/ErrorCode.java index a9cec004..c2d2f2da 100644 --- a/src/main/java/com/example/paycheck/common/exception/ErrorCode.java +++ b/src/main/java/com/example/paycheck/common/exception/ErrorCode.java @@ -8,6 +8,7 @@ public class ErrorCode { // User domain public static final String USER_NOT_FOUND = "USER_NOT_FOUND"; public static final String USER_ALREADY_DELETED = "USER_ALREADY_DELETED"; + public static final String USER_NOT_WITHDRAWN = "USER_NOT_WITHDRAWN"; // Worker domain public static final String WORKER_NOT_FOUND = "WORKER_NOT_FOUND"; diff --git a/src/main/java/com/example/paycheck/domain/auth/dto/AuthDto.java b/src/main/java/com/example/paycheck/domain/auth/dto/AuthDto.java index cca34c6f..1598cdd3 100644 --- a/src/main/java/com/example/paycheck/domain/auth/dto/AuthDto.java +++ b/src/main/java/com/example/paycheck/domain/auth/dto/AuthDto.java @@ -1,6 +1,7 @@ package com.example.paycheck.domain.auth.dto; import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonInclude; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.AssertTrue; import jakarta.validation.constraints.NotBlank; @@ -11,6 +12,8 @@ import lombok.NoArgsConstructor; import org.springframework.util.StringUtils; +import java.time.LocalDateTime; + /** * 인증 관련 DTO 모음 */ @@ -90,6 +93,67 @@ public static class LoginResponse { private String userType; } + /** + * 카카오 로그인 결과 (정상 로그인 또는 탈퇴 계정 발견) + * 정상 로그인 필드는 기존 클라이언트 호환을 위해 top-level에 평탄하게 둔다. + * - status="LOGGED_IN": accessToken/refreshToken/userId/name/userType 채움 + * - status="WITHDRAWN_PENDING": withdrawnAccount만 채움 + */ + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + @JsonInclude(JsonInclude.Include.NON_NULL) + @Schema(name = "AuthKakaoLoginResult") + public static class KakaoLoginResult { + @Schema(description = "로그인 결과 상태", allowableValues = {"LOGGED_IN", "WITHDRAWN_PENDING"}) + private String status; + + // 정상 로그인(LOGGED_IN) 시 채워지는 필드 — 기존 LoginResponse와 동일 경로 유지 + private String accessToken; + private String refreshToken; + private Long userId; + private String name; + private String userType; + + @Schema(description = "탈퇴 계정 발견 시 안내 정보") + private WithdrawnAccountInfo withdrawnAccount; + + public static KakaoLoginResult loggedIn(LoginResponse login) { + return KakaoLoginResult.builder() + .status("LOGGED_IN") + .accessToken(login.getAccessToken()) + .refreshToken(login.getRefreshToken()) + .userId(login.getUserId()) + .name(login.getName()) + .userType(login.getUserType()) + .build(); + } + + public static KakaoLoginResult withdrawnPending(WithdrawnAccountInfo withdrawnAccount) { + return KakaoLoginResult.builder() + .status("WITHDRAWN_PENDING") + .withdrawnAccount(withdrawnAccount) + .build(); + } + } + + /** + * 탈퇴 계정 안내 정보 + * 클라이언트가 사용자에게 "기존 계정을 복구할지 / 완전 삭제 후 새로 가입할지" 선택을 안내할 때 사용. + */ + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + @Schema(name = "AuthWithdrawnAccountInfo") + public static class WithdrawnAccountInfo { + private String name; + private String userType; + private LocalDateTime withdrawnAt; + private String profileImageUrl; + } + @Getter @AllArgsConstructor @Builder diff --git a/src/main/java/com/example/paycheck/domain/auth/service/AuthService.java b/src/main/java/com/example/paycheck/domain/auth/service/AuthService.java index 30c8bbe1..b57e8e7f 100644 --- a/src/main/java/com/example/paycheck/domain/auth/service/AuthService.java +++ b/src/main/java/com/example/paycheck/domain/auth/service/AuthService.java @@ -10,6 +10,7 @@ import com.example.paycheck.domain.user.entity.User; import com.example.paycheck.domain.user.enums.UserType; import com.example.paycheck.domain.user.repository.UserRepository; +import com.example.paycheck.domain.user.service.UserHardDeleteService; import com.example.paycheck.domain.user.service.UserService; import com.example.paycheck.domain.user.service.UserWithdrawService; import com.example.paycheck.domain.worker.entity.Worker; @@ -41,6 +42,7 @@ public class AuthService { private final UserRepository userRepository; private final UserService userService; private final UserWithdrawService userWithdrawService; + private final UserHardDeleteService userHardDeleteService; private final EmployerRepository employerRepository; private final WorkerRepository workerRepository; @@ -68,13 +70,15 @@ public static class RefreshResult { /** * 카카오 계정으로 로그인 + * 탈퇴 상태 사용자는 예외 대신 status="WITHDRAWN_PENDING"으로 응답하여 + * 클라이언트가 복구/재가입을 안내할 수 있게 한다. * * @param kakaoAccessToken 카카오 액세스 토큰 - * @return 로그인 결과 (응답 DTO + Refresh Token) + * @return 로그인 결과 (LOGGED_IN 또는 WITHDRAWN_PENDING) * @throws NotFoundException 등록되지 않은 카카오 계정인 경우 */ @Transactional - public LoginResult loginWithKakao(String kakaoAccessToken) { + public AuthDto.KakaoLoginResult loginWithKakao(String kakaoAccessToken) { // 카카오 사용자 정보 조회 및 검증 KakaoUserInfo userInfo = oAuthService.getKakaoUserInfo(kakaoAccessToken); @@ -85,26 +89,99 @@ public LoginResult loginWithKakao(String kakaoAccessToken) { "등록되지 않은 카카오 계정입니다. 회원가입을 진행해주세요." )); - // 탈퇴한 사용자 로그인 차단 + // 탈퇴한 사용자: 복구/재가입 선택지를 안내하기 위한 정보 반환 if (user.isDeleted()) { - throw new BadRequestException(ErrorCode.USER_ALREADY_DELETED, "탈퇴한 계정입니다. 다시 가입해주세요."); + return AuthDto.KakaoLoginResult.withdrawnPending( + AuthDto.WithdrawnAccountInfo.builder() + .name(user.getName()) + .userType(user.getUserType().name()) + .withdrawnAt(user.getDeletedAt()) + .profileImageUrl(user.getProfileImageUrl()) + .build() + ); } - // 토큰 생성 - TokenService.TokenPair tokenPair = tokenService.generateTokenPair(user.getId()); + // 정상 로그인 + return AuthDto.KakaoLoginResult.loggedIn(buildLoginResponse(user)); + } - // 응답 DTO 생성 - AuthDto.LoginResponse loginResponse = AuthDto.LoginResponse.builder() + /** + * 사용자 + 새로 발급한 토큰으로 LoginResponse 생성 (refreshToken 포함) + */ + private AuthDto.LoginResponse buildLoginResponse(User user) { + TokenService.TokenPair tokenPair = tokenService.generateTokenPair(user.getId()); + return AuthDto.LoginResponse.builder() .accessToken(tokenPair.getAccessToken()) + .refreshToken(tokenPair.getRefreshToken()) .userId(user.getId()) .name(user.getName()) .userType(user.getUserType().name()) .build(); + } - return LoginResult.builder() - .loginResponse(loginResponse) - .refreshToken(tokenPair.getRefreshToken()) - .build(); + /** + * 탈퇴한 카카오 계정 복구 (탈퇴 취소) + * - User.deletedAt = null로 되돌림 + * - UserSettings는 탈퇴 시 보존되었으므로 그대로 사용 (이전 알림 설정 유지) + * - 사업장/계약/근무기록은 자동 복구하지 않음 (다른 사용자 데이터 일관성 보호) + * + * @param kakaoAccessToken 카카오 액세스 토큰 + * @return 로그인 응답 (refreshToken 포함) + */ + @Transactional + public AuthDto.LoginResponse restoreWithKakao(String kakaoAccessToken) { + KakaoUserInfo userInfo = oAuthService.getKakaoUserInfo(kakaoAccessToken); + + User user = userRepository.findByKakaoId(userInfo.kakaoId()) + .orElseThrow(() -> new NotFoundException( + ErrorCode.USER_NOT_FOUND, + "등록되지 않은 카카오 계정입니다." + )); + + if (!user.isDeleted()) { + throw new BadRequestException( + ErrorCode.USER_NOT_WITHDRAWN, + "탈퇴 상태가 아닌 계정입니다." + ); + } + + user.restore(); + + return buildLoginResponse(user); + } + + /** + * 탈퇴한 카카오 계정을 영구 삭제 후 신규 가입 + * - 기존 사용자 hard delete (30일 스케줄러와 동일 경로 재사용) + * - 신규 회원가입 진행 + * + * 트랜잭션: 메서드 전체를 하나의 트랜잭션으로 묶어 hard delete + register 원자성 보장. + * register 단계에서 실패하면 hard delete도 함께 롤백되어 데이터 영구 손실을 방지한다. + * + * @param request 회원가입 요청 (기존 KakaoRegisterRequest 그대로 재사용) + * @return 로그인 응답 (refreshToken 포함) + */ + @Transactional + public AuthDto.LoginResponse purgeAndRegisterWithKakao(AuthDto.KakaoRegisterRequest request) { + KakaoUserInfo userInfo = oAuthService.getKakaoUserInfo(request.getKakaoAccessToken()); + + // 기존 사용자가 탈퇴 상태이면 hard delete, 정상 계정이면 차단 + userRepository.findByKakaoId(userInfo.kakaoId()) + .ifPresent(existing -> { + if (!existing.isDeleted()) { + throw new BadRequestException( + ErrorCode.DUPLICATE_KAKAO_ACCOUNT, + "이미 가입된 카카오 계정입니다." + ); + } + userHardDeleteService.hardDeleteUser(existing.getId()); + // JPA 기본 flush 순서(INSERT → DELETE)로 인해 같은 트랜잭션에서 + // 신규 가입(INSERT)이 기존 사용자 DELETE보다 먼저 실행되면 + // unique kakao_id 제약 충돌이 발생한다. 명시적 flush로 DELETE를 먼저 반영한다. + userRepository.flush(); + }); + + return registerWithKakaoInternal(request, userInfo); } /** @@ -171,6 +248,29 @@ public LoginResult registerWithKakao(AuthDto.KakaoRegisterRequest request) { throw new BadRequestException(ErrorCode.DUPLICATE_KAKAO_ACCOUNT, "이미 가입된 카카오 계정입니다."); } + AuthDto.LoginResponse loginResponse = registerWithKakaoInternal(request, userInfo); + + return LoginResult.builder() + .loginResponse(AuthDto.LoginResponse.builder() + .accessToken(loginResponse.getAccessToken()) + .userId(loginResponse.getUserId()) + .name(loginResponse.getName()) + .userType(loginResponse.getUserType()) + .build()) + .refreshToken(loginResponse.getRefreshToken()) + .build(); + } + + /** + * 카카오 회원가입 내부 로직 (OAuth 검증과 중복 확인은 호출자가 담당) + * registerWithKakao와 purgeAndRegisterWithKakao에서 공유. + * + * @return refreshToken을 포함한 LoginResponse + */ + @Transactional + public AuthDto.LoginResponse registerWithKakaoInternal( + AuthDto.KakaoRegisterRequest request, + KakaoUserInfo userInfo) { // 사용자 타입 파싱 UserType userType = parseUserType(request.getUserType()); @@ -205,18 +305,14 @@ public LoginResult registerWithKakao(AuthDto.KakaoRegisterRequest request) { // 토큰 생성 TokenService.TokenPair tokenPair = tokenService.generateTokenPair(registerResponse.getUserId()); - // 응답 DTO 생성 - AuthDto.LoginResponse loginResponse = AuthDto.LoginResponse.builder() + // 응답 DTO (refreshToken 포함) + return AuthDto.LoginResponse.builder() .accessToken(tokenPair.getAccessToken()) + .refreshToken(tokenPair.getRefreshToken()) .userId(registerResponse.getUserId()) .name(registerResponse.getName()) .userType(registerResponse.getUserType().name()) .build(); - - return LoginResult.builder() - .loginResponse(loginResponse) - .refreshToken(tokenPair.getRefreshToken()) - .build(); } /** diff --git a/src/main/java/com/example/paycheck/domain/user/entity/User.java b/src/main/java/com/example/paycheck/domain/user/entity/User.java index 38df5abc..e8c21c1a 100644 --- a/src/main/java/com/example/paycheck/domain/user/entity/User.java +++ b/src/main/java/com/example/paycheck/domain/user/entity/User.java @@ -55,6 +55,10 @@ public void withdraw() { this.deletedAt = LocalDateTime.now(); } + public void restore() { + this.deletedAt = null; + } + public boolean isDeleted() { return this.deletedAt != null; } diff --git a/src/main/java/com/example/paycheck/domain/user/service/UserWithdrawService.java b/src/main/java/com/example/paycheck/domain/user/service/UserWithdrawService.java index 47601608..89e99167 100644 --- a/src/main/java/com/example/paycheck/domain/user/service/UserWithdrawService.java +++ b/src/main/java/com/example/paycheck/domain/user/service/UserWithdrawService.java @@ -12,7 +12,6 @@ import com.example.paycheck.domain.employer.repository.EmployerRepository; import com.example.paycheck.domain.fcm.repository.FcmTokenRepository; import com.example.paycheck.domain.notification.repository.NotificationRepository; -import com.example.paycheck.domain.settings.repository.UserSettingsRepository; import com.example.paycheck.domain.user.entity.User; import com.example.paycheck.domain.user.enums.UserType; import com.example.paycheck.domain.user.repository.UserRepository; @@ -48,7 +47,6 @@ public class UserWithdrawService { private final RefreshTokenRepository refreshTokenRepository; private final FcmTokenRepository fcmTokenRepository; private final NotificationRepository notificationRepository; - private final UserSettingsRepository userSettingsRepository; private final UserRepository userRepository; /** @@ -143,11 +141,12 @@ private void recalculateTerminationWeekAllowanceAndSalary(WorkerContract contrac /** * 공통 데이터 정리 + * UserSettings는 30일 이내 복구 시 사용자가 이전에 설정한 알림 옵션을 그대로 살리기 위해 + * 탈퇴 시점에는 보존하며, 30일 후 hard delete 시 UserHardDeleteService에서 함께 정리된다. */ private void cleanupCommonData(User user) { refreshTokenRepository.deleteByUserId(user.getId()); fcmTokenRepository.deleteByUserId(user.getId()); notificationRepository.deleteAllByUser(user); - userSettingsRepository.deleteByUserId(user.getId()); } } diff --git a/src/main/resources/db/migration/V20260505__Add_version_column_to_users.sql b/src/main/resources/db/migration/V20260505__Add_version_column_to_users.sql new file mode 100644 index 00000000..eb9e9beb --- /dev/null +++ b/src/main/resources/db/migration/V20260505__Add_version_column_to_users.sql @@ -0,0 +1,2 @@ +-- Issue #181: users 테이블에 version 컬럼 추가 (JPA Optimistic Locking) +ALTER TABLE users ADD COLUMN version BIGINT NOT NULL DEFAULT 0; diff --git a/src/test/java/com/example/paycheck/domain/auth/service/AuthServiceTest.java b/src/test/java/com/example/paycheck/domain/auth/service/AuthServiceTest.java index 3beeb868..499569a3 100644 --- a/src/test/java/com/example/paycheck/domain/auth/service/AuthServiceTest.java +++ b/src/test/java/com/example/paycheck/domain/auth/service/AuthServiceTest.java @@ -7,6 +7,7 @@ import com.example.paycheck.domain.user.entity.User; import com.example.paycheck.domain.user.enums.UserType; import com.example.paycheck.domain.user.repository.UserRepository; +import com.example.paycheck.domain.user.service.UserHardDeleteService; import com.example.paycheck.domain.user.service.UserService; import com.example.paycheck.domain.user.service.UserWithdrawService; import com.example.paycheck.domain.employer.repository.EmployerRepository; @@ -18,11 +19,11 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InOrder; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import java.time.LocalDateTime; import java.util.Optional; import static org.assertj.core.api.Assertions.*; @@ -51,6 +52,9 @@ class AuthServiceTest { @Mock private UserWithdrawService userWithdrawService; + @Mock + private UserHardDeleteService userHardDeleteService; + @Mock private WorkerRepository workerRepository; @@ -85,7 +89,7 @@ void setUp() { } @Test - @DisplayName("카카오 로그인 성공") + @DisplayName("카카오 로그인 성공 - status=LOGGED_IN, 응답 평탄 구조 (클라이언트 호환)") void loginWithKakao_Success() { // given String kakaoAccessToken = "kakao_access_token"; @@ -94,15 +98,18 @@ void loginWithKakao_Success() { when(tokenService.generateTokenPair(testUser.getId())).thenReturn(tokenPair); // when - AuthService.LoginResult result = authService.loginWithKakao(kakaoAccessToken); + AuthDto.KakaoLoginResult result = authService.loginWithKakao(kakaoAccessToken); // then assertThat(result).isNotNull(); - assertThat(result.getLoginResponse().getAccessToken()).isEqualTo("test_access_token"); + assertThat(result.getStatus()).isEqualTo("LOGGED_IN"); + assertThat(result.getWithdrawnAccount()).isNull(); + // 평탄 구조: top-level 필드에서 직접 접근 가능 (기존 클라이언트 호환) + assertThat(result.getAccessToken()).isEqualTo("test_access_token"); assertThat(result.getRefreshToken()).isEqualTo("test_refresh_token"); - assertThat(result.getLoginResponse().getUserId()).isEqualTo(1L); - assertThat(result.getLoginResponse().getName()).isEqualTo("테스트 사용자"); - assertThat(result.getLoginResponse().getUserType()).isEqualTo("WORKER"); + assertThat(result.getUserId()).isEqualTo(1L); + assertThat(result.getName()).isEqualTo("테스트 사용자"); + assertThat(result.getUserType()).isEqualTo("WORKER"); verify(oAuthService).getKakaoUserInfo(kakaoAccessToken); verify(userRepository).findByKakaoId(kakaoUserInfo.kakaoId()); @@ -501,8 +508,44 @@ void withdraw_Success() { } @Test - @DisplayName("카카오 로그인 실패 - 탈퇴한 사용자") - void loginWithKakao_DeletedUser_ThrowsException() { + @DisplayName("카카오 로그인 - 탈퇴한 사용자는 status=WITHDRAWN_PENDING 반환") + void loginWithKakao_DeletedUser_ReturnsWithdrawnPending() { + // given + String kakaoAccessToken = "kakao_access_token"; + User deletedUser = User.builder() + .id(2L) + .kakaoId("test_kakao_id") + .name("탈퇴한 사용자") + .userType(UserType.WORKER) + .profileImageUrl("https://example.com/profile.jpg") + .build(); + deletedUser.withdraw(); + + when(oAuthService.getKakaoUserInfo(kakaoAccessToken)).thenReturn(kakaoUserInfo); + when(userRepository.findByKakaoId(kakaoUserInfo.kakaoId())).thenReturn(Optional.of(deletedUser)); + + // when + AuthDto.KakaoLoginResult result = authService.loginWithKakao(kakaoAccessToken); + + // then + assertThat(result).isNotNull(); + assertThat(result.getStatus()).isEqualTo("WITHDRAWN_PENDING"); + // 평탄 구조: 정상 로그인용 필드는 모두 null + assertThat(result.getAccessToken()).isNull(); + assertThat(result.getRefreshToken()).isNull(); + assertThat(result.getUserId()).isNull(); + assertThat(result.getWithdrawnAccount()).isNotNull(); + assertThat(result.getWithdrawnAccount().getName()).isEqualTo("탈퇴한 사용자"); + assertThat(result.getWithdrawnAccount().getUserType()).isEqualTo("WORKER"); + assertThat(result.getWithdrawnAccount().getWithdrawnAt()).isNotNull(); + assertThat(result.getWithdrawnAccount().getProfileImageUrl()).isEqualTo("https://example.com/profile.jpg"); + + verify(tokenService, never()).generateTokenPair(anyLong()); + } + + @Test + @DisplayName("탈퇴 계정 복구 성공 - User.deletedAt이 null로 되돌아감") + void restoreWithKakao_Success() { // given String kakaoAccessToken = "kakao_access_token"; User deletedUser = User.builder() @@ -515,15 +558,144 @@ void loginWithKakao_DeletedUser_ThrowsException() { when(oAuthService.getKakaoUserInfo(kakaoAccessToken)).thenReturn(kakaoUserInfo); when(userRepository.findByKakaoId(kakaoUserInfo.kakaoId())).thenReturn(Optional.of(deletedUser)); + when(tokenService.generateTokenPair(deletedUser.getId())).thenReturn(tokenPair); + + // when + AuthDto.LoginResponse response = authService.restoreWithKakao(kakaoAccessToken); + + // then + assertThat(deletedUser.isDeleted()).isFalse(); + assertThat(response.getAccessToken()).isEqualTo("test_access_token"); + assertThat(response.getRefreshToken()).isEqualTo("test_refresh_token"); + assertThat(response.getUserId()).isEqualTo(2L); + assertThat(response.getName()).isEqualTo("탈퇴한 사용자"); + assertThat(response.getUserType()).isEqualTo("WORKER"); + } + + @Test + @DisplayName("탈퇴 계정 복구 실패 - 정상 계정에 호출 시 USER_NOT_WITHDRAWN 예외") + void restoreWithKakao_NotWithdrawn_ThrowsException() { + // given + String kakaoAccessToken = "kakao_access_token"; + when(oAuthService.getKakaoUserInfo(kakaoAccessToken)).thenReturn(kakaoUserInfo); + when(userRepository.findByKakaoId(kakaoUserInfo.kakaoId())).thenReturn(Optional.of(testUser)); // when & then - assertThatThrownBy(() -> authService.loginWithKakao(kakaoAccessToken)) + assertThatThrownBy(() -> authService.restoreWithKakao(kakaoAccessToken)) .isInstanceOf(BadRequestException.class) - .hasMessageContaining("탈퇴한 계정입니다"); + .hasMessageContaining("탈퇴 상태가 아닌 계정입니다"); verify(tokenService, never()).generateTokenPair(anyLong()); } + @Test + @DisplayName("탈퇴 계정 완전 삭제 후 재가입 성공 - hardDelete 직후 flush 호출로 unique 제약 회피") + void purgeAndRegisterWithKakao_Success() { + // given + AuthDto.KakaoRegisterRequest request = AuthDto.KakaoRegisterRequest.builder() + .kakaoAccessToken("kakao_access_token") + .name("새이름") + .phone("010-1234-5678") + .userType("WORKER") + .bankName("카카오뱅크") + .accountNumber("3333123456789") + .build(); + + User deletedUser = User.builder() + .id(2L) + .kakaoId("test_kakao_id") + .name("탈퇴한 사용자") + .userType(UserType.WORKER) + .build(); + deletedUser.withdraw(); + + UserDto.RegisterResponse registerResponse = UserDto.RegisterResponse.builder() + .userId(3L) + .name("새이름") + .userType(UserType.WORKER) + .workerCode("WORKER002") + .build(); + + when(oAuthService.getKakaoUserInfo(request.getKakaoAccessToken())).thenReturn(kakaoUserInfo); + when(userRepository.findByKakaoId(kakaoUserInfo.kakaoId())).thenReturn(Optional.of(deletedUser)); + when(userService.register(any(UserDto.RegisterRequest.class))).thenReturn(registerResponse); + when(tokenService.generateTokenPair(3L)).thenReturn(tokenPair); + + // when + AuthDto.LoginResponse response = authService.purgeAndRegisterWithKakao(request); + + // then: hardDelete → flush → register 순서 검증 (JPA flush 순서 이슈 방지) + InOrder inOrder = inOrder(userHardDeleteService, userRepository, userService); + inOrder.verify(userHardDeleteService).hardDeleteUser(deletedUser.getId()); + inOrder.verify(userRepository).flush(); + inOrder.verify(userService).register(any(UserDto.RegisterRequest.class)); + + assertThat(response.getAccessToken()).isEqualTo("test_access_token"); + assertThat(response.getRefreshToken()).isEqualTo("test_refresh_token"); + assertThat(response.getUserId()).isEqualTo(3L); + } + + @Test + @DisplayName("탈퇴 계정 완전 삭제 후 재가입 - register 실패 시 호출자에게 예외 전파 (트랜잭션 롤백 트리거)") + void purgeAndRegisterWithKakao_RegisterFails_PropagatesException() { + // given + AuthDto.KakaoRegisterRequest request = AuthDto.KakaoRegisterRequest.builder() + .kakaoAccessToken("kakao_access_token") + .name("새이름") + .phone("010-1234-5678") + .userType("WORKER") + .bankName("카카오뱅크") + .accountNumber("3333123456789") + .build(); + + User deletedUser = User.builder() + .id(2L) + .kakaoId("test_kakao_id") + .name("탈퇴한 사용자") + .userType(UserType.WORKER) + .build(); + deletedUser.withdraw(); + + when(oAuthService.getKakaoUserInfo(request.getKakaoAccessToken())).thenReturn(kakaoUserInfo); + when(userRepository.findByKakaoId(kakaoUserInfo.kakaoId())).thenReturn(Optional.of(deletedUser)); + when(userService.register(any(UserDto.RegisterRequest.class))) + .thenThrow(new RuntimeException("DB 오류")); + + // when & then: 예외 전파 → @Transactional이 롤백을 트리거함 (실제 롤백은 통합 테스트로 검증) + assertThatThrownBy(() -> authService.purgeAndRegisterWithKakao(request)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("DB 오류"); + + verify(userHardDeleteService).hardDeleteUser(deletedUser.getId()); + verify(userRepository).flush(); + verify(userService).register(any(UserDto.RegisterRequest.class)); + } + + @Test + @DisplayName("탈퇴 계정 완전 삭제 후 재가입 실패 - 정상 계정 차단") + void purgeAndRegisterWithKakao_ActiveUser_ThrowsException() { + // given + AuthDto.KakaoRegisterRequest request = AuthDto.KakaoRegisterRequest.builder() + .kakaoAccessToken("kakao_access_token") + .name("이름") + .phone("010-1234-5678") + .userType("WORKER") + .bankName("카카오뱅크") + .accountNumber("3333123456789") + .build(); + + when(oAuthService.getKakaoUserInfo(request.getKakaoAccessToken())).thenReturn(kakaoUserInfo); + when(userRepository.findByKakaoId(kakaoUserInfo.kakaoId())).thenReturn(Optional.of(testUser)); + + // when & then + assertThatThrownBy(() -> authService.purgeAndRegisterWithKakao(request)) + .isInstanceOf(BadRequestException.class) + .hasMessageContaining("이미 가입된 카카오 계정입니다"); + + verify(userHardDeleteService, never()).hardDeleteUser(anyLong()); + verify(userService, never()).register(any()); + } + @Test @DisplayName("회원가입 실패 - 잘못된 UserType") void registerWithKakao_InvalidUserType() { diff --git a/src/test/java/com/example/paycheck/domain/user/service/UserWithdrawServiceTest.java b/src/test/java/com/example/paycheck/domain/user/service/UserWithdrawServiceTest.java index 7eed7716..1ec12433 100644 --- a/src/test/java/com/example/paycheck/domain/user/service/UserWithdrawServiceTest.java +++ b/src/test/java/com/example/paycheck/domain/user/service/UserWithdrawServiceTest.java @@ -11,7 +11,6 @@ import com.example.paycheck.domain.notification.repository.NotificationRepository; import com.example.paycheck.domain.allowance.repository.WeeklyAllowanceRepository; import com.example.paycheck.domain.allowance.service.WeeklyAllowanceService; -import com.example.paycheck.domain.settings.repository.UserSettingsRepository; import com.example.paycheck.domain.user.entity.User; import com.example.paycheck.domain.user.repository.UserRepository; import com.example.paycheck.domain.user.enums.UserType; @@ -60,8 +59,6 @@ class UserWithdrawServiceTest { @Mock private NotificationRepository notificationRepository; @Mock - private UserSettingsRepository userSettingsRepository; - @Mock private WeeklyAllowanceService weeklyAllowanceService; @Mock private WeeklyAllowanceRepository weeklyAllowanceRepository; @@ -137,7 +134,6 @@ void withdrawEmployer_success() { verify(refreshTokenRepository).deleteByUserId(employer.getId()); verify(fcmTokenRepository).deleteByUserId(employer.getId()); verify(notificationRepository).deleteAllByUser(employer); - verify(userSettingsRepository).deleteByUserId(employer.getId()); } @Test @@ -201,7 +197,7 @@ void withdraw_alreadyDeleted_throwsException() { } @Test - @DisplayName("토큰, 알림, 설정 삭제 확인") + @DisplayName("탈퇴 시 토큰/알림 정리, UserSettings는 보존 (30일 hard delete 시점에 정리됨)") void withdraw_cleanupsCommonData() { // given when(workerRepository.findByUserId(worker.getId())).thenReturn(Optional.empty()); @@ -214,7 +210,7 @@ void withdraw_cleanupsCommonData() { verify(refreshTokenRepository).deleteByUserId(worker.getId()); verify(fcmTokenRepository).deleteByUserId(worker.getId()); verify(notificationRepository).deleteAllByUser(worker); - verify(userSettingsRepository).deleteByUserId(worker.getId()); + // UserSettings는 탈퇴 시점에 보존 (복구 시 사용자 알림 설정 유지) } @Test