diff --git a/src/main/java/com/divary/domain/member/entity/Member.java b/src/main/java/com/divary/domain/member/entity/Member.java index b0f4f61..c89d913 100644 --- a/src/main/java/com/divary/domain/member/entity/Member.java +++ b/src/main/java/com/divary/domain/member/entity/Member.java @@ -20,12 +20,22 @@ @Setter @NoArgsConstructor @AllArgsConstructor +@Table(uniqueConstraints = { + @UniqueConstraint(columnNames = {"socialId", "socialType"}) +}) public class Member extends BaseEntity { @Column(nullable = false, unique = true) private String email; + @Column(nullable = true) + private String socialId; // Apple sub 또는 Google sub + + @Enumerated(EnumType.STRING) + @Column(nullable = true) + private SocialType socialType; // APPLE, GOOGLE 등 + @Enumerated(EnumType.STRING) @NotNull private Role role; @@ -61,5 +71,10 @@ public void cancelDeletion() { public void updateGroup(String newGroup){ this.memberGroup = newGroup; } - + + // 소셜 정보 업데이트 (기존 회원 마이그레이션용) + public void updateSocialInfo(String socialId, SocialType socialType) { + this.socialId = socialId; + this.socialType = socialType; + } } diff --git a/src/main/java/com/divary/domain/member/repository/MemberRepository.java b/src/main/java/com/divary/domain/member/repository/MemberRepository.java index 497b951..cbf5a19 100644 --- a/src/main/java/com/divary/domain/member/repository/MemberRepository.java +++ b/src/main/java/com/divary/domain/member/repository/MemberRepository.java @@ -1,5 +1,6 @@ package com.divary.domain.member.repository; +import com.divary.common.enums.SocialType; import com.divary.domain.member.entity.Member; import com.divary.domain.member.enums.Status; import org.springframework.data.jpa.repository.JpaRepository; @@ -11,5 +12,6 @@ public interface MemberRepository extends JpaRepository { Optional findByEmail(String email); Optional findById(Long id); + Optional findBySocialIdAndSocialType(String socialId, SocialType socialType); List findByStatusAndDeactivatedAtBefore(Status status, LocalDateTime cutoffDate); } diff --git a/src/main/java/com/divary/domain/member/service/MemberService.java b/src/main/java/com/divary/domain/member/service/MemberService.java index 287a508..47f8b2f 100644 --- a/src/main/java/com/divary/domain/member/service/MemberService.java +++ b/src/main/java/com/divary/domain/member/service/MemberService.java @@ -1,5 +1,6 @@ package com.divary.domain.member.service; +import com.divary.common.enums.SocialType; import com.divary.domain.member.dto.requestDTO.MyPageGroupRequestDTO; import com.divary.domain.member.dto.response.MyPageImageResponseDTO; import com.divary.domain.member.dto.response.MyPageProfileResponseDTO; @@ -16,7 +17,7 @@ public interface MemberService { MyPageImageResponseDTO uploadLicense(MultipartFile image, Long userId); DeactivateResponse requestToDeleteMember(Long memberId); void cancelDeleteMember(Long memberId); - public Member findOrCreateMember(String email); + Member findOrCreateMemberBySocialId(String socialId, SocialType socialType, String email); void updateGroup(Long userId, MyPageGroupRequestDTO requestDTO); diff --git a/src/main/java/com/divary/domain/member/service/MemberServiceImpl.java b/src/main/java/com/divary/domain/member/service/MemberServiceImpl.java index 6f14ce6..fea2c62 100644 --- a/src/main/java/com/divary/domain/member/service/MemberServiceImpl.java +++ b/src/main/java/com/divary/domain/member/service/MemberServiceImpl.java @@ -1,5 +1,6 @@ package com.divary.domain.member.service; +import com.divary.common.enums.SocialType; import com.divary.common.util.EnumValidator; import com.divary.domain.image.dto.request.ImageUploadRequest; import com.divary.domain.image.dto.response.ImageResponse; @@ -135,19 +136,56 @@ public void cancelDeleteMember(Long memberId) { } @Override @Transactional - public Member findOrCreateMember(String email) { - // 1. Optional을 사용하여 회원을 조회합니다. - Optional optionalMember = memberRepository.findByEmail(email); - - // 2. 회원이 존재하면 그대로 반환하고, 존재하지 않으면 새로 생성하여 저장한 뒤 반환합니다. - return optionalMember.orElseGet(() -> { - Member newMember = Member.builder() - .email(email) - .status(Status.ACTIVE) - .role(Role.USER) - .build(); - return memberRepository.save(newMember); - }); + public Member findOrCreateMemberBySocialId(String socialId, SocialType socialType, String email) { + // 1. socialId와 socialType으로 회원 조회 + Optional optionalMember = memberRepository.findBySocialIdAndSocialType(socialId, socialType); + + // 2. 회원이 존재하면 그대로 반환 + if (optionalMember.isPresent()) { + return optionalMember.get(); + } + + // 3. 기존 회원 마이그레이션: email로 기존 회원 찾기 + if (email != null && !email.isEmpty()) { + Optional existingMember = memberRepository.findByEmail(email); + if (existingMember.isPresent()) { + Member member = existingMember.get(); + + // 3-1. 기존 회원의 socialId가 없으면 업데이트 (마이그레이션) + if (member.getSocialId() == null) { + member.updateSocialInfo(socialId, socialType); + return member; // JPA dirty checking으로 자동 저장 + } + + // 3-2. 이미 다른 소셜 타입으로 가입된 경우 + if (!member.getSocialType().equals(socialType)) { + throw new BusinessException( + ErrorCode.ALREADY_REGISTERED_WITH_DIFFERENT_SOCIAL, + "이 이메일은 이미 " + member.getSocialType() + " 계정으로 가입되어 있습니다. " + + member.getSocialType() + " 로그인을 사용해주세요." + ); + } + + // 3-3. 같은 소셜 타입인데 다른 socialId인 경우 (비정상 케이스) + throw new BusinessException(ErrorCode.INVALID_TOKEN, "계정 정보가 일치하지 않습니다."); + } + } + + // 4. 새로운 회원 생성 + // 첫 로그인 시 email이 없으면 예외 발생 + if (email == null || email.isEmpty()) { + throw new BusinessException(ErrorCode.INVALID_TOKEN, + socialType + " 첫 로그인 시 이메일이 필요합니다."); + } + + Member newMember = Member.builder() + .email(email) + .socialId(socialId) + .socialType(socialType) + .status(Status.ACTIVE) + .role(Role.USER) + .build(); + return memberRepository.save(newMember); } @Override @CacheEvict(cacheNames = com.divary.global.config.CacheConfig.CACHE_MEMBER_BY_ID, key = "#userId") diff --git a/src/main/java/com/divary/global/config/jwt/JwtAuthenticationFilter.java b/src/main/java/com/divary/global/config/jwt/JwtAuthenticationFilter.java index 1c47a5c..472b709 100644 --- a/src/main/java/com/divary/global/config/jwt/JwtAuthenticationFilter.java +++ b/src/main/java/com/divary/global/config/jwt/JwtAuthenticationFilter.java @@ -36,8 +36,6 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtResolver jwtResolver; private final TokenBlackListService tokenBlackListService; private final MemberService memberService; - private static final String REACTIVATE_MEMBER_URI = "/api/v1/auth/reactivate"; - private static final String REACTIVATE_MEMBER_METHOD = "POST"; //todo 하드코딩 안하게 변경 @Override protected void doFilterInternal(@NonNull HttpServletRequest request, @@ -58,12 +56,8 @@ protected void doFilterInternal(@NonNull HttpServletRequest request, Member member = memberService.findById(userId); - // 1. 현재 요청이 회원 복구 API인지 확인합니다. - boolean isRecoveryRequest = request.getRequestURI().equals(REACTIVATE_MEMBER_URI) && - request.getMethod().equalsIgnoreCase(REACTIVATE_MEMBER_METHOD); - - // 2. 복구 요청이 아닌 경우에만 비활성화 상태를 체크합니다. - if (!isRecoveryRequest && member.getStatus() == Status.DEACTIVATED) { + // 탈퇴 신청된 계정은 API 접근 차단 + if (member.getStatus() == Status.DEACTIVATED) { throw new BusinessException(ErrorCode.MEMBER_IS_DEACTIVATE); } diff --git a/src/main/java/com/divary/global/exception/ErrorCode.java b/src/main/java/com/divary/global/exception/ErrorCode.java index b6c52a3..29e057d 100644 --- a/src/main/java/com/divary/global/exception/ErrorCode.java +++ b/src/main/java/com/divary/global/exception/ErrorCode.java @@ -44,8 +44,10 @@ public enum ErrorCode { DEVICE_ID_NOT_FOUND(HttpStatus.NOT_FOUND, "DEVICE_001", "디바이스 아이디를 찾을 수 없습니다"), MEMBER_IS_DEACTIVATE(HttpStatus.FORBIDDEN, "MEMBER_004", "탈퇴 예정인 계정입니다."), CONCURRENT_REQUEST_ERROR(HttpStatus.CONFLICT, "MEMBER_005", "탈퇴 요청 처리 중 데이터 충돌이 발생했습니다"), + MEMBER_NOT_DEACTIVATED(HttpStatus.BAD_REQUEST, "MEMBER_006", "탈퇴 신청되지 않은 계정입니다."), //소셜 로그인 관련 GOOGLE_BAD_GATEWAY(HttpStatus.BAD_GATEWAY, "GOOGLE_001", "구글 유저를 찾을 수 없습니다"), + ALREADY_REGISTERED_WITH_DIFFERENT_SOCIAL(HttpStatus.CONFLICT, "AUTH_007", "이미 다른 소셜 계정으로 가입되어 있습니다."), //알림 관련 NOTIFICAITION_NOT_FOUND(HttpStatus.NOT_FOUND, "MEMBER_003", "해당 ID를 가진 사용자의 알림이 존재하지 않습니다"), diff --git a/src/main/java/com/divary/global/oauth/controller/OauthController.java b/src/main/java/com/divary/global/oauth/controller/OauthController.java index 6b62ccb..221eeac 100644 --- a/src/main/java/com/divary/global/oauth/controller/OauthController.java +++ b/src/main/java/com/divary/global/oauth/controller/OauthController.java @@ -94,14 +94,16 @@ public ApiResponse deactivateUser(@AuthenticationPrincipal C return ApiResponse.success(response); } - @PostMapping(value = "/reactivate") - @Operation(summary = "회원 탈퇴를 취소합니다.") + @PostMapping(value = "/{socialLoginType}/reactivate") + @Operation(summary = "소셜 토큰을 사용하여 회원 탈퇴를 취소합니다.") @ApiSuccessResponse(dataType = void.class) - @ApiErrorExamples(value = {ErrorCode.MEMBER_NOT_FOUND}) - public ApiResponse reactivate(@AuthenticationPrincipal CustomUserPrincipal userPrincipal) { - Long userId = userPrincipal.getId(); + @ApiErrorExamples(value = {ErrorCode.EMAIL_NOT_FOUND, ErrorCode.MEMBER_NOT_DEACTIVATED}) + public ApiResponse reactivate(@PathVariable(name = "socialLoginType") SocialType socialLoginType, + @Valid @RequestBody com.divary.global.oauth.dto.request.RecoveryRequestDto recoveryRequestDto) { + String accessToken = recoveryRequestDto.getAccessToken(); + log.info(">> 복구 요청 - 소셜 타입: {}, accessToken: {}", socialLoginType, accessToken); - memberService.cancelDeleteMember(userId); + oauthService.reactivate(socialLoginType, accessToken); return ApiResponse.success("회원 정보 복구에 성공했습니다"); } diff --git a/src/main/java/com/divary/global/oauth/dto/request/RecoveryRequestDto.java b/src/main/java/com/divary/global/oauth/dto/request/RecoveryRequestDto.java new file mode 100644 index 0000000..beeb917 --- /dev/null +++ b/src/main/java/com/divary/global/oauth/dto/request/RecoveryRequestDto.java @@ -0,0 +1,12 @@ +package com.divary.global.oauth.dto.request; + +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class RecoveryRequestDto { + @NotBlank(message = "access 토큰은 필수 입력 값입니다.") + private String accessToken; +} diff --git a/src/main/java/com/divary/global/oauth/infra/AppleJwtParser.java b/src/main/java/com/divary/global/oauth/infra/AppleJwtParser.java index 65dc582..89e7917 100644 --- a/src/main/java/com/divary/global/oauth/infra/AppleJwtParser.java +++ b/src/main/java/com/divary/global/oauth/infra/AppleJwtParser.java @@ -36,7 +36,7 @@ public class AppleJwtParser { /** * Apple Identity Token을 검증하고 사용자 정보를 추출합니다. * @param identityToken 클라이언트로부터 받은 Identity Token - * @return 사용자 정보 (sub: Apple User ID, email: 이메일) + * @return 사용자 정보 (sub: Apple User ID, email: 이메일 또는 빈 문자열) */ public Map parse(String identityToken) { // 1. Apple 공개키 목록을 가져옵니다. (실제 운영에서는 캐싱 필요) @@ -65,7 +65,13 @@ public Map parse(String identityToken) { // 5. 생성된 PublicKey로 토큰의 서명, 발급자, 만료시간 등을 최종 검증합니다. Claims claims = getClaims(identityToken, publicKey); - return Map.of("sub", claims.getSubject(), "email", claims.get("email", String.class)); + String sub = claims.getSubject(); + String email = claims.get("email", String.class); + + return Map.of( + "sub", sub, + "email", email != null ? email : "" + ); } /** diff --git a/src/main/java/com/divary/global/oauth/service/OauthService.java b/src/main/java/com/divary/global/oauth/service/OauthService.java index cb2fae6..6c12c58 100644 --- a/src/main/java/com/divary/global/oauth/service/OauthService.java +++ b/src/main/java/com/divary/global/oauth/service/OauthService.java @@ -56,6 +56,15 @@ public void logout(SocialType socialLoginType, String deviceId, Long userId) { socialOauth.logout(deviceId, userId); } + @Transactional + public void reactivate(SocialType socialLoginType, String accessToken) { + SocialOauth socialOauth = this.findSocialOauthByType(socialLoginType); + if (socialOauth == null) { + throw new BusinessException(ErrorCode.SOCIAL_PROVIDER_NOT_FOUND); + } + socialOauth.reactivate(accessToken); + } + /** * 우리 서비스의 Refresh Token을 사용하여 Access Token과 Refresh Token을 재발급합니다. (RTR) * 이 로직은 모든 소셜 로그인 사용자에게 공통으로 적용됩니다. diff --git a/src/main/java/com/divary/global/oauth/service/social/AppleOauth.java b/src/main/java/com/divary/global/oauth/service/social/AppleOauth.java index 6ddafaa..d0b018e 100644 --- a/src/main/java/com/divary/global/oauth/service/social/AppleOauth.java +++ b/src/main/java/com/divary/global/oauth/service/social/AppleOauth.java @@ -9,6 +9,7 @@ import com.divary.global.config.jwt.JwtTokenProvider; import com.divary.global.config.security.CustomUserPrincipal; import com.divary.global.exception.BusinessException; +import com.divary.global.exception.ErrorCode; import com.divary.global.oauth.dto.response.LoginResponseDTO; import com.divary.global.oauth.infra.AppleJwtParser; import com.divary.global.redis.service.TokenBlackListService; @@ -46,9 +47,19 @@ public class AppleOauth implements SocialOauth { public LoginResponseDTO verifyAndLogin(String identityToken, String deviceId) { // Identity Token을 검증하고 사용자 정보를 추출합니다. Map userInfo = appleJwtParser.parse(identityToken); + + String sub = userInfo.get("sub"); String email = userInfo.get("email"); - Member member = memberService.findOrCreateMember(email); + log.debug("Apple 로그인 - sub: {}, email: {}", sub, email); + + // socialId(sub)와 socialType으로 회원 조회 또는 생성 + Member member = memberService.findOrCreateMemberBySocialId(sub, SocialType.APPLE, email); + + // 탈퇴 신청된 계정은 로그인 차단 + if (member.getStatus() == Status.DEACTIVATED) { + throw new BusinessException(ErrorCode.MEMBER_IS_DEACTIVATE); + } CustomUserPrincipal principal = new CustomUserPrincipal(member); @@ -78,6 +89,28 @@ public void logout(String deviceId, Long userId) { log.debug("Apple 계정 로그아웃 처리 완료. UserId: {}, DeviceId: {}", userId, deviceId); } + + @Override + @Transactional + public void reactivate(String identityToken) { + // Identity Token을 검증하고 사용자 정보를 추출합니다. + Map userInfo = appleJwtParser.parse(identityToken); + String email = userInfo.get("email"); + + // 이메일로 회원 찾기 + Member member = memberService.findMemberByEmail(email); + + // 탈퇴 신청된 계정이 아니면 복구 불가 + if (member.getStatus() != Status.DEACTIVATED) { + throw new BusinessException(ErrorCode.MEMBER_NOT_DEACTIVATED); + } + + // 복구 처리 + memberService.cancelDeleteMember(member.getId()); + + log.debug("Apple 계정 복구 완료. Email: {}, UserId: {}", email, member.getId()); + } + @Override public SocialType getType() { return SocialType.APPLE; diff --git a/src/main/java/com/divary/global/oauth/service/social/GoogleOauth.java b/src/main/java/com/divary/global/oauth/service/social/GoogleOauth.java index 3c18fcd..3e9a107 100644 --- a/src/main/java/com/divary/global/oauth/service/social/GoogleOauth.java +++ b/src/main/java/com/divary/global/oauth/service/social/GoogleOauth.java @@ -72,10 +72,18 @@ private Map requestUserInfo(String accessToken) { public LoginResponseDTO verifyAndLogin(String googleAccessToken, String deviceId) { // accessToken으로 사용자 정보 요청 Map userInfo = requestUserInfo(googleAccessToken); + String sub = (String) userInfo.get("id"); // Google의 고유 사용자 ID String email = (String) userInfo.get("email"); + log.debug("Google 로그인 - sub: {}, email: {}", sub, email); - Member member = memberService.findOrCreateMember(email); + // socialId(sub)와 socialType으로 회원 조회 또는 생성 + Member member = memberService.findOrCreateMemberBySocialId(sub, SocialType.GOOGLE, email); + + // 탈퇴 신청된 계정은 로그인 차단 + if (member.getStatus() == Status.DEACTIVATED) { + throw new BusinessException(ErrorCode.MEMBER_IS_DEACTIVATE); + } CustomUserPrincipal principal = new CustomUserPrincipal(member); @@ -103,6 +111,28 @@ public void logout(String deviceId, Long userId) { log.debug("로그아웃 처리 완료. AccessToken 블랙리스트 추가, RefreshToken 삭제. UserId: {}, DeviceId: {}", userId, deviceId); } + + @Override + @Transactional + public void reactivate(String googleAccessToken) { + // accessToken으로 사용자 정보 요청 + Map userInfo = requestUserInfo(googleAccessToken); + String email = (String) userInfo.get("email"); + + // 이메일로 회원 찾기 + Member member = memberService.findMemberByEmail(email); + + // 탈퇴 신청된 계정이 아니면 복구 불가 + if (member.getStatus() != Status.DEACTIVATED) { + throw new BusinessException(ErrorCode.MEMBER_NOT_DEACTIVATED); + } + + // 복구 처리 + memberService.cancelDeleteMember(member.getId()); + + log.debug("계정 복구 완료. Email: {}, UserId: {}", email, member.getId()); + } + @Override public SocialType getType() { return SocialType.GOOGLE; diff --git a/src/main/java/com/divary/global/oauth/service/social/SocialOauth.java b/src/main/java/com/divary/global/oauth/service/social/SocialOauth.java index 17eac98..9aa4cee 100644 --- a/src/main/java/com/divary/global/oauth/service/social/SocialOauth.java +++ b/src/main/java/com/divary/global/oauth/service/social/SocialOauth.java @@ -6,6 +6,7 @@ public interface SocialOauth { LoginResponseDTO verifyAndLogin(String token, String deviceId); void logout(String deviceId, Long userId); + void reactivate(String token); SocialType getType(); }