diff --git a/src/main/java/gg/agit/konect/domain/user/controller/UserApi.java b/src/main/java/gg/agit/konect/domain/user/controller/UserApi.java index 208631a3..22734e55 100644 --- a/src/main/java/gg/agit/konect/domain/user/controller/UserApi.java +++ b/src/main/java/gg/agit/konect/domain/user/controller/UserApi.java @@ -7,8 +7,8 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; -import gg.agit.konect.domain.user.dto.SignupRequest; import gg.agit.konect.domain.user.dto.SignupPrefillResponse; +import gg.agit.konect.domain.user.dto.SignupRequest; import gg.agit.konect.domain.user.dto.UserAccessTokenResponse; import gg.agit.konect.domain.user.dto.UserInfoResponse; import gg.agit.konect.global.auth.annotation.PublicApi; @@ -27,7 +27,7 @@ public interface UserApi { summary = "추가 정보가 필요한 사용자의 정보를 받아 회원가입을 진행한다.", description = """ 추가 정보를 입력받아 회원가입을 완료합니다. - + - `INVALID_SIGNUP_TOKEN` (401): 회원가입 토큰이 없거나 올바르지 않은 경우 - `INVALID_REQUEST_BODY` (400): 요청 본문의 형식이 올바르지 않거나 필수 값이 누락된 경우 - `DUPLICATE_STUDENT_NUMBER` (409): 동일 대학교 + 학번 조합이 이미 존재하는 경우 diff --git a/src/main/java/gg/agit/konect/domain/user/controller/UserController.java b/src/main/java/gg/agit/konect/domain/user/controller/UserController.java index 67225601..b7f7fbed 100644 --- a/src/main/java/gg/agit/konect/domain/user/controller/UserController.java +++ b/src/main/java/gg/agit/konect/domain/user/controller/UserController.java @@ -5,17 +5,17 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import gg.agit.konect.domain.user.dto.SignupRequest; import gg.agit.konect.domain.user.dto.SignupPrefillResponse; +import gg.agit.konect.domain.user.dto.SignupRequest; import gg.agit.konect.domain.user.dto.UserAccessTokenResponse; import gg.agit.konect.domain.user.dto.UserInfoResponse; import gg.agit.konect.domain.user.service.RefreshTokenService; import gg.agit.konect.domain.user.service.SignupTokenService; import gg.agit.konect.domain.user.service.UserActivityService; import gg.agit.konect.domain.user.service.UserService; -import gg.agit.konect.global.auth.jwt.JwtProvider; import gg.agit.konect.global.auth.annotation.PublicApi; import gg.agit.konect.global.auth.annotation.UserId; +import gg.agit.konect.global.auth.jwt.JwtProvider; import gg.agit.konect.global.auth.web.AuthCookieService; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; diff --git a/src/main/java/gg/agit/konect/domain/user/model/User.java b/src/main/java/gg/agit/konect/domain/user/model/User.java index 32ea169c..6e16c509 100644 --- a/src/main/java/gg/agit/konect/domain/user/model/User.java +++ b/src/main/java/gg/agit/konect/domain/user/model/User.java @@ -214,4 +214,8 @@ public void restore() { public boolean canRestore(LocalDateTime now, long restoreWindowDays) { return deletedAt != null && deletedAt.isAfter(now.minusDays(restoreWindowDays)); } + + public void clearAppleRefreshToken() { + this.appleRefreshToken = null; + } } diff --git a/src/main/java/gg/agit/konect/domain/user/repository/UserRepository.java b/src/main/java/gg/agit/konect/domain/user/repository/UserRepository.java index 7b06a292..d91c1012 100644 --- a/src/main/java/gg/agit/konect/domain/user/repository/UserRepository.java +++ b/src/main/java/gg/agit/konect/domain/user/repository/UserRepository.java @@ -1,5 +1,6 @@ package gg.agit.konect.domain.user.repository; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; @@ -7,11 +8,11 @@ import org.springframework.data.repository.Repository; import org.springframework.data.repository.query.Param; -import gg.agit.konect.global.code.ApiResponseCode; -import gg.agit.konect.global.exception.CustomException; import gg.agit.konect.domain.user.enums.Provider; import gg.agit.konect.domain.user.enums.UserRole; import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; public interface UserRepository extends Repository { @@ -143,4 +144,24 @@ List findUserIdsByUniversityAndStudentYear( AND u.deletedAt IS NULL """) List findAllByIdIn(@Param("ids") List ids); + + @Query(""" + SELECT u + FROM User u + WHERE u.provider = :provider + AND u.deletedAt IS NOT NULL + AND u.deletedAt < :threshold + AND u.appleRefreshToken IS NOT NULL + """) + List findByProviderAndDeletedAtBefore( + @Param("provider") Provider provider, + @Param("threshold") LocalDateTime threshold + ); + + @Query(""" + SELECT u + FROM User u + WHERE u.id = :id + """) + Optional findByIdIncludingDeleted(@Param("id") Integer id); } diff --git a/src/main/java/gg/agit/konect/domain/user/scheduler/UserScheduler.java b/src/main/java/gg/agit/konect/domain/user/scheduler/UserScheduler.java new file mode 100644 index 00000000..328123c0 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/user/scheduler/UserScheduler.java @@ -0,0 +1,32 @@ +package gg.agit.konect.domain.user.scheduler; + +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import gg.agit.konect.domain.user.service.UserSchedulerService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class UserScheduler { + + private final UserSchedulerService userSchedulerService; + + /** + * 매일 자정(서버 기본 시간대 기준 00:00)에 실행되어 7일 경과한 Apple 사용자 토큰을 revoke합니다. + * cron 표현식: 초 분 시 일 월 요일 + * 0 0 0 * * *: 매일 00:00:00 실행 + */ + @Scheduled(cron = "0 0 0 * * *") + public void revokeAppleTokensAfterRestoreWindow() { + try { + log.info("Starting Apple token revocation task for users withdrawn more than 7 days ago"); + userSchedulerService.revokeAppleTokensAfterRestoreWindow(); + log.info("Successfully completed Apple token revocation task"); + } catch (Exception e) { + log.error("Failed to revoke Apple tokens for withdrawn users", e); + } + } +} diff --git a/src/main/java/gg/agit/konect/domain/user/service/UserSchedulerService.java b/src/main/java/gg/agit/konect/domain/user/service/UserSchedulerService.java new file mode 100644 index 00000000..51cb42d2 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/user/service/UserSchedulerService.java @@ -0,0 +1,61 @@ +package gg.agit.konect.domain.user.service; + +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.stereotype.Service; + +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.infrastructure.oauth.AppleTokenRevocationService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class UserSchedulerService { + + private static final int REVOKE_AFTER_DAYS = 7; + + private final UserSchedulerTxService userSchedulerTxService; + private final AppleTokenRevocationService appleTokenRevocationService; + + /** + * 7일 이상 경과한 Apple 사용자의 토큰을 revoke합니다. + * - 7일 복구 정책: 탈퇴 후 7일 이내 복구 가능하므로 즉시 revoke하지 않음 + * - 7일 경과 후: 복구 불가 시점이므로 Apple 토큰 영구 폐기 + */ + public void revokeAppleTokensAfterRestoreWindow() { + LocalDateTime threshold = LocalDateTime.now().minusDays(REVOKE_AFTER_DAYS); + List usersToRevoke = userSchedulerTxService.findUsersToRevoke(threshold); + + if (usersToRevoke.isEmpty()) { + log.info("No Apple users to revoke (threshold={})", threshold); + return; + } + + int successCount = 0; + int failureCount = 0; + + for (User user : usersToRevoke) { + try { + String refreshToken = user.getAppleRefreshToken(); + if (refreshToken == null) { + continue; + } + + appleTokenRevocationService.revoke(refreshToken); + userSchedulerTxService.clearAppleRefreshTokenIfMatches(user.getId(), refreshToken); + successCount++; + } catch (Exception e) { + failureCount++; + log.error("Failed to revoke Apple token for userId={}", user.getId(), e); + } + } + + log.info( + "Apple token revoke task finished: total={}, success={}, failure={}" + , usersToRevoke.size(), successCount, failureCount + ); + } +} diff --git a/src/main/java/gg/agit/konect/domain/user/service/UserSchedulerTxService.java b/src/main/java/gg/agit/konect/domain/user/service/UserSchedulerTxService.java new file mode 100644 index 00000000..76f4da79 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/user/service/UserSchedulerTxService.java @@ -0,0 +1,31 @@ +package gg.agit.konect.domain.user.service; + +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import gg.agit.konect.domain.user.enums.Provider; +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.domain.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class UserSchedulerTxService { + + private final UserRepository userRepository; + + @Transactional(readOnly = true) + public List findUsersToRevoke(LocalDateTime threshold) { + return userRepository.findByProviderAndDeletedAtBefore(Provider.APPLE, threshold); + } + + @Transactional + public void clearAppleRefreshTokenIfMatches(Integer userId, String expectedRefreshToken) { + userRepository.findByIdIncludingDeleted(userId) + .filter(user -> expectedRefreshToken.equals(user.getAppleRefreshToken())) + .ifPresent(User::clearAppleRefreshToken); + } +} diff --git a/src/main/java/gg/agit/konect/domain/user/service/UserService.java b/src/main/java/gg/agit/konect/domain/user/service/UserService.java index 58214ebf..aca83ebe 100644 --- a/src/main/java/gg/agit/konect/domain/user/service/UserService.java +++ b/src/main/java/gg/agit/konect/domain/user/service/UserService.java @@ -1,7 +1,7 @@ package gg.agit.konect.domain.user.service; -import static gg.agit.konect.domain.club.enums.ClubPosition.PRESIDENT; import static gg.agit.konect.domain.club.enums.ClubPosition.MANAGERS; +import static gg.agit.konect.domain.club.enums.ClubPosition.PRESIDENT; import static gg.agit.konect.global.code.ApiResponseCode.CANNOT_DELETE_CLUB_PRESIDENT; import java.time.LocalDateTime; @@ -216,9 +216,8 @@ public void deleteUser(Integer userId) { validateNotClubPresident(userId); - if (user.getProvider() == Provider.APPLE) { - appleTokenRevocationService.revoke(user.getAppleRefreshToken()); - } + // Apple 토큰은 7일 복구 정책을 위해 즉시 revoke하지 않음 + // 스케줄러가 7일 경과 후에 revoke 처리 user.withdraw(LocalDateTime.now()); userRepository.save(user);