Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -27,7 +27,7 @@ public interface UserApi {
summary = "추가 정보가 필요한 사용자의 정보를 받아 회원가입을 진행한다.",
description = """
추가 정보를 입력받아 회원가입을 완료합니다.

- `INVALID_SIGNUP_TOKEN` (401): 회원가입 토큰이 없거나 올바르지 않은 경우
- `INVALID_REQUEST_BODY` (400): 요청 본문의 형식이 올바르지 않거나 필수 값이 누락된 경우
- `DUPLICATE_STUDENT_NUMBER` (409): 동일 대학교 + 학번 조합이 이미 존재하는 경우
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions src/main/java/gg/agit/konect/domain/user/model/User.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
package gg.agit.konect.domain.user.repository;

import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;

import org.springframework.data.jpa.repository.Query;
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<User, Integer> {

Expand Down Expand Up @@ -143,4 +144,24 @@ List<User> findUserIdsByUniversityAndStudentYear(
AND u.deletedAt IS NULL
""")
List<User> findAllByIdIn(@Param("ids") List<Integer> 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<User> findByProviderAndDeletedAtBefore(
@Param("provider") Provider provider,
@Param("threshold") LocalDateTime threshold
);

@Query("""
SELECT u
FROM User u
WHERE u.id = :id
""")
Optional<User> findByIdIncludingDeleted(@Param("id") Integer id);
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
Original file line number Diff line number Diff line change
@@ -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<User> 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
);
}
}
Original file line number Diff line number Diff line change
@@ -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<User> 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);
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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);
Expand Down