From 00f30dc6fff8c41c1505c3e04d02d3a41a57e69d Mon Sep 17 00:00:00 2001 From: JanooGwan Date: Sat, 14 Feb 2026 14:03:46 +0900 Subject: [PATCH 01/13] =?UTF-8?q?refactor:=20=EB=8F=99=EC=95=84=EB=A6=AC?= =?UTF-8?q?=20=EC=83=81=EC=84=B8=20=EC=A0=95=EB=B3=B4=20Response=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gg/agit/konect/domain/club/dto/ClubDetailResponse.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/java/gg/agit/konect/domain/club/dto/ClubDetailResponse.java b/src/main/java/gg/agit/konect/domain/club/dto/ClubDetailResponse.java index a278a3f84..b6021f29f 100644 --- a/src/main/java/gg/agit/konect/domain/club/dto/ClubDetailResponse.java +++ b/src/main/java/gg/agit/konect/domain/club/dto/ClubDetailResponse.java @@ -49,6 +49,9 @@ public record ClubDetailResponse( @Schema(description = "동아리 모집 정보", requiredMode = REQUIRED) InnerRecruitment recruitment, + @Schema(description = "동아리 회장 ID", example = "1", requiredMode = REQUIRED) + Integer presidentUserId, + @Schema(description = "동아리 회장 이름", example = "김철수", requiredMode = REQUIRED) String presidentName, @@ -96,6 +99,7 @@ public static ClubDetailResponse of( club.getClubCategory().getDescription(), memberCount, InnerRecruitment.from(clubRecruitment), + president.getUser().getId(), president.getUser().getName(), isMember, isApplied From fa7567b2ea3b8dc31e6fe8b69e2c372c11c11bf9 Mon Sep 17 00:00:00 2001 From: JanooGwan Date: Sat, 14 Feb 2026 14:18:35 +0900 Subject: [PATCH 02/13] =?UTF-8?q?refactor:=20ClubDetailResponse=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gg/agit/konect/domain/club/dto/ClubDetailResponse.java | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/main/java/gg/agit/konect/domain/club/dto/ClubDetailResponse.java b/src/main/java/gg/agit/konect/domain/club/dto/ClubDetailResponse.java index b6021f29f..fee61c59d 100644 --- a/src/main/java/gg/agit/konect/domain/club/dto/ClubDetailResponse.java +++ b/src/main/java/gg/agit/konect/domain/club/dto/ClubDetailResponse.java @@ -9,6 +9,7 @@ import gg.agit.konect.domain.club.model.Club; import gg.agit.konect.domain.club.model.ClubMember; import gg.agit.konect.domain.club.model.ClubRecruitment; +import gg.agit.konect.domain.user.model.User; import io.swagger.v3.oas.annotations.media.Schema; public record ClubDetailResponse( @@ -89,6 +90,8 @@ public static ClubDetailResponse of( Boolean isMember, Boolean isApplied ) { + User presidentUser = president.getUser(); + return new ClubDetailResponse( club.getId(), club.getName(), @@ -99,8 +102,8 @@ public static ClubDetailResponse of( club.getClubCategory().getDescription(), memberCount, InnerRecruitment.from(clubRecruitment), - president.getUser().getId(), - president.getUser().getName(), + presidentUser.getId(), + presidentUser.getName(), isMember, isApplied ); From 0c3083f13499c95f2cdc29508e68c9a7b7899726 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EA=B4=80=EA=B7=9C?= Date: Sat, 14 Feb 2026 23:30:31 +0900 Subject: [PATCH 03/13] =?UTF-8?q?fix:=20=EC=96=B4=EB=93=9C=EB=AF=BC=20?= =?UTF-8?q?=EA=B6=8C=ED=95=9C=20=EC=B2=B4=ED=81=AC=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../club/service/ClubPermissionValidator.java | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/main/java/gg/agit/konect/domain/club/service/ClubPermissionValidator.java b/src/main/java/gg/agit/konect/domain/club/service/ClubPermissionValidator.java index c9d21a56c..2a84da2ac 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/ClubPermissionValidator.java +++ b/src/main/java/gg/agit/konect/domain/club/service/ClubPermissionValidator.java @@ -21,19 +21,31 @@ public class ClubPermissionValidator { private final UserRepository userRepository; public void validatePresidentAccess(Integer clubId, Integer userId) { - if (!isAdmin(userId) || !hasAccess(clubId, userId, PRESIDENT_ONLY)) { + if (isAdmin(userId)) { + return ; + } + + if (!hasAccess(clubId, userId, PRESIDENT_ONLY)) { throw CustomException.of(FORBIDDEN_CLUB_MANAGER_ACCESS); } } public void validateLeaderAccess(Integer clubId, Integer userId) { - if (!isAdmin(userId) || !hasAccess(clubId, userId, LEADERS)) { + if (isAdmin(userId)) { + return ; + } + + if (!hasAccess(clubId, userId, LEADERS)) { throw CustomException.of(FORBIDDEN_CLUB_MANAGER_ACCESS); } } public void validateManagerAccess(Integer clubId, Integer userId) { - if (!isAdmin(userId) || !hasAccess(clubId, userId, MANAGERS)) { + if (isAdmin(userId)) { + return ; + } + + if (!hasAccess(clubId, userId, MANAGERS)) { throw CustomException.of(FORBIDDEN_CLUB_MANAGER_ACCESS); } } From 897dbfcc6563f719502e35d1b66eeb1226482f36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <64298482+dh2906@users.noreply.github.com> Date: Sun, 15 Feb 2026 20:17:10 +0900 Subject: [PATCH 04/13] =?UTF-8?q?feat:=20=EC=A4=80=EB=8F=99=EC=95=84?= =?UTF-8?q?=EB=A6=AC=20=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#226)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/gg/agit/konect/domain/club/enums/ClubCategory.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/gg/agit/konect/domain/club/enums/ClubCategory.java b/src/main/java/gg/agit/konect/domain/club/enums/ClubCategory.java index 2c4eaa708..aa5fbf010 100644 --- a/src/main/java/gg/agit/konect/domain/club/enums/ClubCategory.java +++ b/src/main/java/gg/agit/konect/domain/club/enums/ClubCategory.java @@ -11,6 +11,7 @@ public enum ClubCategory { HOBBY("취미"), RELIGION("종교"), PERFORMANCE("공연"), + JUNIOR("준동아리") ; private final String description; From fe2764507a867d7f38b7f06135b2c75c9e0f4a85 Mon Sep 17 00:00:00 2001 From: JanooGwan <103417427+JanooGwan@users.noreply.github.com> Date: Sun, 15 Feb 2026 00:58:36 +0900 Subject: [PATCH 05/13] =?UTF-8?q?refactor:=20=EB=AA=A8=EB=93=A0=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=EA=B0=80=20=EB=8F=99=EC=95=84?= =?UTF-8?q?=EB=A6=AC=20=ED=9A=8C=EB=B9=84=20=EC=A0=95=EB=B3=B4=EC=97=90=20?= =?UTF-8?q?=EC=A0=91=EA=B7=BC=ED=95=A0=20=EC=88=98=20=EC=9E=88=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refactor: 모든 사용자가 동아리 회비 정보에 접근할 수 있도록 변경 --- .../club/controller/ClubApplicationApi.java | 11 +++++------ .../controller/ClubApplicationController.java | 5 ++--- .../club/service/ClubApplicationService.java | 16 +--------------- 3 files changed, 8 insertions(+), 24 deletions(-) diff --git a/src/main/java/gg/agit/konect/domain/club/controller/ClubApplicationApi.java b/src/main/java/gg/agit/konect/domain/club/controller/ClubApplicationApi.java index 68bf9b955..3cc6ed484 100644 --- a/src/main/java/gg/agit/konect/domain/club/controller/ClubApplicationApi.java +++ b/src/main/java/gg/agit/konect/domain/club/controller/ClubApplicationApi.java @@ -18,6 +18,7 @@ import gg.agit.konect.domain.club.dto.ClubApplyRequest; import gg.agit.konect.domain.club.dto.ClubFeeInfoReplaceRequest; import gg.agit.konect.domain.club.dto.ClubFeeInfoResponse; +import gg.agit.konect.global.auth.annotation.PublicApi; import gg.agit.konect.global.auth.annotation.UserId; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -144,17 +145,15 @@ ResponseEntity replaceApplyQuestions( ); @Operation(summary = "동아리 회비 정보를 조회한다.", description = """ - 동아리 가입 신청을 완료했거나 동아리 관리자 권한이 있는 사용자만 회비 계좌 정보를 조회할 수 있습니다. - + 동아리의 회비 계좌 정보를 조회합니다. + ## 에러 - NOT_FOUND_CLUB (404): 동아리를 찾을 수 없습니다. - - NOT_FOUND_USER (404): 유저를 찾을 수 없습니다. - - FORBIDDEN_CLUB_FEE_INFO (403): 회비 정보 조회 권한이 없습니다. """) + @PublicApi @GetMapping("/{clubId}/fee") ResponseEntity getFeeInfo( - @PathVariable(name = "clubId") Integer clubId, - @UserId Integer userId + @PathVariable(name = "clubId") Integer clubId ); @Operation(summary = "동아리 회비 정보를 덮어써서 대체한다.", description = """ diff --git a/src/main/java/gg/agit/konect/domain/club/controller/ClubApplicationController.java b/src/main/java/gg/agit/konect/domain/club/controller/ClubApplicationController.java index 43dc11a08..9f2f4433c 100644 --- a/src/main/java/gg/agit/konect/domain/club/controller/ClubApplicationController.java +++ b/src/main/java/gg/agit/konect/domain/club/controller/ClubApplicationController.java @@ -103,10 +103,9 @@ public ResponseEntity replaceApplyQuestions( @Override public ResponseEntity getFeeInfo( - @PathVariable(name = "clubId") Integer clubId, - @UserId Integer userId + @PathVariable(name = "clubId") Integer clubId ) { - ClubFeeInfoResponse response = clubApplicationService.getFeeInfo(clubId, userId); + ClubFeeInfoResponse response = clubApplicationService.getFeeInfo(clubId); return ResponseEntity.ok(response); } diff --git a/src/main/java/gg/agit/konect/domain/club/service/ClubApplicationService.java b/src/main/java/gg/agit/konect/domain/club/service/ClubApplicationService.java index 46a96a7b3..3c6710464 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/ClubApplicationService.java +++ b/src/main/java/gg/agit/konect/domain/club/service/ClubApplicationService.java @@ -1,6 +1,5 @@ package gg.agit.konect.domain.club.service; -import static gg.agit.konect.domain.club.enums.ClubPosition.MANAGERS; import static gg.agit.konect.domain.club.enums.ClubPosition.MEMBER; import static gg.agit.konect.global.code.ApiResponseCode.*; @@ -297,21 +296,8 @@ private Page findApplicationsByRecruitmentPeriod( ); } - public ClubFeeInfoResponse getFeeInfo(Integer clubId, Integer userId) { + public ClubFeeInfoResponse getFeeInfo(Integer clubId) { Club club = clubRepository.getById(clubId); - userRepository.getById(userId); - - boolean isApplied = clubApplyRepository.existsByClubIdAndUserId(clubId, userId); - boolean isManager = clubMemberRepository.existsByClubIdAndUserIdAndPositionIn( - clubId, - userId, - MANAGERS - ); - - if (!isApplied && !isManager) { - throw CustomException.of(FORBIDDEN_CLUB_FEE_INFO); - } - return ClubFeeInfoResponse.from(club); } From f0b5a75dff26220bd00e94f752a9cd2cde9ab0eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EA=B4=80=EA=B7=9C?= Date: Mon, 16 Feb 2026 16:15:56 +0900 Subject: [PATCH 06/13] =?UTF-8?q?feat:=20=EC=95=A0=ED=94=8C=20=ED=86=A0?= =?UTF-8?q?=ED=81=B0=20revoke=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?(#235)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: flyway 추가 * feat: 애플 리프레쉬 토큰 관련 로직 추가 * feat: 애플 revoke 로직 추가 --- .../domain/user/model/UnRegisteredUser.java | 7 ++ .../agit/konect/domain/user/model/User.java | 12 +++- .../domain/user/service/UserService.java | 7 ++ .../auth/oauth/OAuth2LoginSuccessHandler.java | 64 +++++++++++++++++++ .../oauth/AppleTokenRevocationService.java | 56 ++++++++++++++++ .../V24__add_apple_refresh_token_columns.sql | 2 + 6 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 src/main/java/gg/agit/konect/infrastructure/oauth/AppleTokenRevocationService.java create mode 100644 src/main/resources/db/migration/V24__add_apple_refresh_token_columns.sql diff --git a/src/main/java/gg/agit/konect/domain/user/model/UnRegisteredUser.java b/src/main/java/gg/agit/konect/domain/user/model/UnRegisteredUser.java index 20e70a106..6146a00f5 100644 --- a/src/main/java/gg/agit/konect/domain/user/model/UnRegisteredUser.java +++ b/src/main/java/gg/agit/konect/domain/user/model/UnRegisteredUser.java @@ -50,6 +50,9 @@ public class UnRegisteredUser extends BaseEntity { @Column(name = "provider_id", length = 255) private String providerId; + @Column(name = "apple_refresh_token", length = 1024) + private String appleRefreshToken; + @Builder private UnRegisteredUser(Integer id, String email, Provider provider, String providerId) { this.id = id; @@ -57,4 +60,8 @@ private UnRegisteredUser(Integer id, String email, Provider provider, String pro this.provider = provider; this.providerId = providerId; } + + public void updateAppleRefreshToken(String appleRefreshToken) { + this.appleRefreshToken = appleRefreshToken; + } } 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 47ac21a83..d185a8317 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 @@ -87,6 +87,9 @@ public class User extends BaseEntity { @Column(name = "image_url") private String imageUrl; + @Column(name = "apple_refresh_token", length = 1024) + private String appleRefreshToken; + @Builder private User( Integer id, @@ -99,7 +102,8 @@ private User( String providerId, UserRole role, Boolean isMarketingAgreement, - String imageUrl + String imageUrl, + String appleRefreshToken ) { this.id = id; this.university = university; @@ -112,6 +116,7 @@ private User( this.role = role == null ? UserRole.USER : role; this.isMarketingAgreement = isMarketingAgreement; this.imageUrl = imageUrl; + this.appleRefreshToken = appleRefreshToken; } public static User of( @@ -131,6 +136,7 @@ public static User of( .providerId(tempUser.getProviderId()) .isMarketingAgreement(isMarketingAgreement) .imageUrl(imageUrl) + .appleRefreshToken(tempUser.getAppleRefreshToken()) .build(); } @@ -161,4 +167,8 @@ public String getStudentNumberYear() { public boolean isAdmin() { return this.role.equals(UserRole.ADMIN); } + + public void updateAppleRefreshToken(String appleRefreshToken) { + this.appleRefreshToken = appleRefreshToken; + } } 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 58d88a30f..00643eae8 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 @@ -35,6 +35,7 @@ import gg.agit.konect.domain.user.repository.UserRepository; import gg.agit.konect.global.code.ApiResponseCode; import gg.agit.konect.global.exception.CustomException; +import gg.agit.konect.infrastructure.oauth.AppleTokenRevocationService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -56,6 +57,7 @@ public class UserService { private final CouncilNoticeReadRepository councilNoticeReadRepository; private final StudyTimeQueryService studyTimeQueryService; private final ApplicationEventPublisher applicationEventPublisher; + private final AppleTokenRevocationService appleTokenRevocationService; @Transactional public Integer signup(String email, String providerId, Provider provider, SignupRequest request) { @@ -186,6 +188,11 @@ public void deleteUser(Integer userId) { validateNotClubPresident(userId); validatePaidFees(userId); + + if (user.getProvider() == Provider.APPLE) { + appleTokenRevocationService.revoke(user.getAppleRefreshToken()); + } + userRepository.delete(user); applicationEventPublisher.publishEvent(UserWithdrawnEvent.from(user.getEmail())); diff --git a/src/main/java/gg/agit/konect/global/auth/oauth/OAuth2LoginSuccessHandler.java b/src/main/java/gg/agit/konect/global/auth/oauth/OAuth2LoginSuccessHandler.java index f92e871fd..b61607f1c 100644 --- a/src/main/java/gg/agit/konect/global/auth/oauth/OAuth2LoginSuccessHandler.java +++ b/src/main/java/gg/agit/konect/global/auth/oauth/OAuth2LoginSuccessHandler.java @@ -7,14 +7,20 @@ import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClient; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService; import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.security.oauth2.core.OAuth2RefreshToken; import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.security.web.authentication.AuthenticationSuccessHandler; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; import gg.agit.konect.domain.user.enums.Provider; +import gg.agit.konect.domain.user.model.UnRegisteredUser; import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.domain.user.repository.UnRegisteredUserRepository; +import gg.agit.konect.domain.user.repository.UserRepository; import gg.agit.konect.domain.user.service.RefreshTokenService; import gg.agit.konect.domain.user.service.SignupTokenService; import gg.agit.konect.global.auth.web.AuthCookieService; @@ -24,7 +30,9 @@ import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpSession; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +@Slf4j @Component @RequiredArgsConstructor public class OAuth2LoginSuccessHandler implements AuthenticationSuccessHandler { @@ -37,6 +45,9 @@ public class OAuth2LoginSuccessHandler implements AuthenticationSuccessHandler { private final SignupTokenService signupTokenService; private final RefreshTokenService refreshTokenService; private final AuthCookieService authCookieService; + private final OAuth2AuthorizedClientService authorizedClientService; + private final UserRepository userRepository; + private final UnRegisteredUserRepository unRegisteredUserRepository; @Override public void onAuthenticationSuccess( @@ -60,6 +71,8 @@ public void onAuthenticationSuccess( } } + String appleRefreshTokenValue = extractAppleRefreshToken(oauthToken); + user = oauthLoginHelper.findUserByProvider(provider, email, providerId); if (user.isEmpty()) { @@ -71,10 +84,12 @@ public void onAuthenticationSuccess( } } + saveAppleRefreshTokenForUnRegisteredUser(provider, providerId, email, appleRefreshTokenValue); sendAdditionalInfoRequiredResponse(request, response, email, provider, providerId); return; } + saveAppleRefreshTokenForUser(provider, user.get(), appleRefreshTokenValue); sendLoginSuccessResponse(request, response, user.get()); } @@ -158,4 +173,53 @@ private String extractProviderId(OAuth2User oauthUser) { return providerId; } + + private String extractAppleRefreshToken(OAuth2AuthenticationToken oauthToken) { + String registrationId = oauthToken.getAuthorizedClientRegistrationId(); + + if (!Provider.APPLE.name().equalsIgnoreCase(registrationId)) { + return null; + } + + OAuth2AuthorizedClient client = authorizedClientService.loadAuthorizedClient( + registrationId, oauthToken.getName() + ); + + if (client == null) { + return null; + } + + OAuth2RefreshToken refreshToken = client.getRefreshToken(); + return refreshToken != null ? refreshToken.getTokenValue() : null; + } + + private void saveAppleRefreshTokenForUser(Provider provider, User user, String appleRefreshToken) { + if (provider != Provider.APPLE || !StringUtils.hasText(appleRefreshToken)) { + return; + } + + user.updateAppleRefreshToken(appleRefreshToken); + userRepository.save(user); + } + + private void saveAppleRefreshTokenForUnRegisteredUser( + Provider provider, String providerId, String email, String appleRefreshToken + ) { + if (provider != Provider.APPLE || !StringUtils.hasText(appleRefreshToken)) { + return; + } + + Optional unRegisteredUser; + + if (StringUtils.hasText(providerId)) { + unRegisteredUser = unRegisteredUserRepository.findByProviderIdAndProvider(providerId, Provider.APPLE); + } else { + unRegisteredUser = unRegisteredUserRepository.findByEmailAndProvider(email, Provider.APPLE); + } + + unRegisteredUser.ifPresent(u -> { + u.updateAppleRefreshToken(appleRefreshToken); + unRegisteredUserRepository.save(u); + }); + } } diff --git a/src/main/java/gg/agit/konect/infrastructure/oauth/AppleTokenRevocationService.java b/src/main/java/gg/agit/konect/infrastructure/oauth/AppleTokenRevocationService.java new file mode 100644 index 000000000..5d6778815 --- /dev/null +++ b/src/main/java/gg/agit/konect/infrastructure/oauth/AppleTokenRevocationService.java @@ -0,0 +1,56 @@ +package gg.agit.konect.infrastructure.oauth; + +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.util.StringUtils; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestTemplate; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AppleTokenRevocationService { + + private static final String APPLE_REVOKE_URL = "https://appleid.apple.com/auth/revoke"; + + private final RestTemplate restTemplate; + private final AppleClientSecretProvider appleClientSecretProvider; + private final AppleOAuthProperties appleOAuthProperties; + + public void revoke(String appleRefreshToken) { + if (!StringUtils.hasText(appleRefreshToken)) { + log.warn("Apple refresh token이 없어 revoke를 건너뜁니다."); + return; + } + + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("client_id", appleOAuthProperties.getClientId()); + params.add("client_secret", appleClientSecretProvider.getClientSecret()); + params.add("token", appleRefreshToken); + params.add("token_type_hint", "refresh_token"); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + + HttpEntity> request = new HttpEntity<>(params, headers); + + try { + ResponseEntity response = restTemplate.postForEntity(APPLE_REVOKE_URL, request, String.class); + + if (response.getStatusCode().is2xxSuccessful()) { + log.info("Apple 토큰 revoke 완료"); + } + } catch (HttpClientErrorException e) { + log.error("Apple 토큰 revoke 실패. status={}, body={}", e.getStatusCode(), e.getResponseBodyAsString()); + throw new IllegalStateException("Apple 토큰 revoke에 실패했습니다.", e); + } + } +} diff --git a/src/main/resources/db/migration/V24__add_apple_refresh_token_columns.sql b/src/main/resources/db/migration/V24__add_apple_refresh_token_columns.sql new file mode 100644 index 000000000..d21b38972 --- /dev/null +++ b/src/main/resources/db/migration/V24__add_apple_refresh_token_columns.sql @@ -0,0 +1,2 @@ +ALTER TABLE users ADD COLUMN apple_refresh_token VARCHAR(1024) NULL; +ALTER TABLE unregistered_user ADD COLUMN apple_refresh_token VARCHAR(1024) NULL; From ff1a18e77c5cbe82b5ae4bb791a13d049e2dab8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EA=B4=80=EA=B7=9C?= Date: Mon, 16 Feb 2026 18:01:22 +0900 Subject: [PATCH 07/13] =?UTF-8?q?fix:=20=ED=99=98=EA=B2=BD=EB=B3=80?= =?UTF-8?q?=EC=88=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Conflicts: # src/main/resources/application.yml --- src/main/resources/application.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 3a2d073d4..056264fa1 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -39,3 +39,11 @@ server: tomcat: mbeanregistry: enabled: true + + servlet: + session: + cookie: + domain: agit.gg + same-site: none + secure: true + http-only: true From 0b6521706a9e5092923eb53c81459162b1dfab2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <2dh2@naver.com> Date: Tue, 17 Feb 2026 14:33:08 +0900 Subject: [PATCH 08/13] =?UTF-8?q?hotfix:=20pre-member=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?,=20=EC=82=AD=EC=A0=9C=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/club/controller/ClubMemberApi.java | 30 +++++++++++++ .../club/controller/ClubMemberController.java | 20 +++++++++ .../club/dto/ClubPreMembersResponse.java | 45 +++++++++++++++++++ .../repository/ClubPreMemberRepository.java | 36 +++++++++++++++ .../service/ClubMemberManagementService.java | 22 ++++++++- .../konect/global/code/ApiResponseCode.java | 1 + 6 files changed, 153 insertions(+), 1 deletion(-) create mode 100644 src/main/java/gg/agit/konect/domain/club/dto/ClubPreMembersResponse.java diff --git a/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberApi.java b/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberApi.java index c6fe58fb6..c3d48442c 100644 --- a/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberApi.java +++ b/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberApi.java @@ -2,6 +2,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; +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; @@ -12,6 +13,7 @@ import gg.agit.konect.domain.club.dto.ClubPreMemberAddResponse; import gg.agit.konect.domain.club.dto.ClubMemberChangesResponse; import gg.agit.konect.domain.club.dto.ClubMemberResponse; +import gg.agit.konect.domain.club.dto.ClubPreMembersResponse; import gg.agit.konect.domain.club.dto.MemberPositionChangeRequest; import gg.agit.konect.domain.club.dto.PresidentTransferRequest; import gg.agit.konect.domain.club.dto.VicePresidentChangeRequest; @@ -105,6 +107,34 @@ ResponseEntity addPreMember( @UserId Integer userId ); + @Operation(summary = "동아리 사전 등록 회원 리스트를 조회한다.", description = """ + 동아리 회장 또는 부회장만 사전 등록 회원 리스트를 조회할 수 있습니다. + + ## 에러 + - FORBIDDEN_CLUB_MANAGER_ACCESS (403): 동아리 매니저 권한이 없습니다. + - NOT_FOUND_CLUB (404): 동아리를 찾을 수 없습니다. + """) + @GetMapping("/{clubId}/pre-members") + ResponseEntity getPreMembers( + @PathVariable(name = "clubId") Integer clubId, + @UserId Integer userId + ); + + @Operation(summary = "동아리 사전 등록 회원을 삭제한다.", description = """ + 동아리 회장 또는 부회장만 사전 등록 회원을 삭제할 수 있습니다. + + ## 에러 + - FORBIDDEN_CLUB_MANAGER_ACCESS (403): 동아리 매니저 권한이 없습니다. + - NOT_FOUND_CLUB (404): 동아리를 찾을 수 없습니다. + - NOT_FOUND_CLUB_PRE_MEMBER (404): 사전 등록 회원을 찾을 수 없습니다. + """) + @DeleteMapping("/{clubId}/pre-members/{preMemberId}") + ResponseEntity removePreMember( + @PathVariable(name = "clubId") Integer clubId, + @PathVariable(name = "preMemberId") Integer preMemberId, + @UserId Integer requesterId + ); + @Operation(summary = "동아리 회원을 강제 탈퇴시킨다.", description = """ 동아리 회장 또는 부회장만 회원을 강제 탈퇴시킬 수 있습니다. 일반회원만 강제 탈퇴 가능하며, 부회장이나 운영진은 먼저 직책을 변경한 후 탈퇴시켜야 합니다. diff --git a/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberController.java b/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberController.java index a6b328701..65953a85f 100644 --- a/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberController.java +++ b/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberController.java @@ -12,6 +12,7 @@ import gg.agit.konect.domain.club.dto.ClubPreMemberAddResponse; import gg.agit.konect.domain.club.dto.ClubMemberChangesResponse; import gg.agit.konect.domain.club.dto.ClubMemberResponse; +import gg.agit.konect.domain.club.dto.ClubPreMembersResponse; import gg.agit.konect.domain.club.dto.MemberPositionChangeRequest; import gg.agit.konect.domain.club.dto.PresidentTransferRequest; import gg.agit.konect.domain.club.dto.VicePresidentChangeRequest; @@ -75,6 +76,25 @@ public ResponseEntity addPreMember( return ResponseEntity.ok(response); } + @Override + public ResponseEntity getPreMembers( + @PathVariable(name = "clubId") Integer clubId, + @UserId Integer userId + ) { + ClubPreMembersResponse response = clubMemberManagementService.getPreMembers(clubId, userId); + return ResponseEntity.ok(response); + } + + @Override + public ResponseEntity removePreMember( + @PathVariable(name = "clubId") Integer clubId, + @PathVariable(name = "preMemberId") Integer preMemberId, + @UserId Integer requesterId + ) { + clubMemberManagementService.removePreMember(clubId, preMemberId, requesterId); + return ResponseEntity.noContent().build(); + } + @Override public ResponseEntity removeMember( @PathVariable(name = "clubId") Integer clubId, diff --git a/src/main/java/gg/agit/konect/domain/club/dto/ClubPreMembersResponse.java b/src/main/java/gg/agit/konect/domain/club/dto/ClubPreMembersResponse.java new file mode 100644 index 000000000..a2e8af731 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/dto/ClubPreMembersResponse.java @@ -0,0 +1,45 @@ +package gg.agit.konect.domain.club.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.util.List; + +import gg.agit.konect.domain.club.enums.ClubPosition; +import gg.agit.konect.domain.club.model.ClubPreMember; +import io.swagger.v3.oas.annotations.media.Schema; + +public record ClubPreMembersResponse( + @Schema(description = "동아리 사전 회원 리스트", requiredMode = REQUIRED) + List preMembers +) { + public record InnerClubPreMember( + @Schema(description = "사전 회원 ID", example = "1", requiredMode = REQUIRED) + Integer preMemberId, + + @Schema(description = "학번", example = "2021136089", requiredMode = REQUIRED) + String studentNumber, + + @Schema(description = "이름", example = "홍길동", requiredMode = REQUIRED) + String name, + + @Schema(description = "가입 직책", example = "MEMBER", requiredMode = REQUIRED) + ClubPosition clubPosition + ) { + public static InnerClubPreMember from(ClubPreMember preMember) { + return new InnerClubPreMember( + preMember.getId(), + preMember.getStudentNumber(), + preMember.getName(), + preMember.getClubPosition() + ); + } + } + + public static ClubPreMembersResponse from(List preMembers) { + return new ClubPreMembersResponse( + preMembers.stream() + .map(InnerClubPreMember::from) + .toList() + ); + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/repository/ClubPreMemberRepository.java b/src/main/java/gg/agit/konect/domain/club/repository/ClubPreMemberRepository.java index 27f132b76..b8ace676c 100644 --- a/src/main/java/gg/agit/konect/domain/club/repository/ClubPreMemberRepository.java +++ b/src/main/java/gg/agit/konect/domain/club/repository/ClubPreMemberRepository.java @@ -1,16 +1,50 @@ package gg.agit.konect.domain.club.repository; 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.domain.club.model.ClubPreMember; +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; // TODO. 초기 회원 처리 완료 후 제거 예정 public interface ClubPreMemberRepository extends Repository { + @Query(""" + SELECT cpm + FROM ClubPreMember cpm + WHERE cpm.club.id = :clubId + ORDER BY + CASE cpm.clubPosition + WHEN gg.agit.konect.domain.club.enums.ClubPosition.PRESIDENT THEN 0 + WHEN gg.agit.konect.domain.club.enums.ClubPosition.VICE_PRESIDENT THEN 1 + WHEN gg.agit.konect.domain.club.enums.ClubPosition.MANAGER THEN 2 + WHEN gg.agit.konect.domain.club.enums.ClubPosition.MEMBER THEN 3 + END ASC, + cpm.name ASC + """) + List findAllByClubId(@Param("clubId") Integer clubId); + + @Query(""" + SELECT cpm + FROM ClubPreMember cpm + WHERE cpm.id = :preMemberId + AND cpm.club.id = :clubId + """) + Optional findByIdAndClubId( + @Param("preMemberId") Integer preMemberId, + @Param("clubId") Integer clubId + ); + + default ClubPreMember getByIdAndClubId(Integer preMemberId, Integer clubId) { + return findByIdAndClubId(preMemberId, clubId) + .orElseThrow(() -> CustomException.of(ApiResponseCode.NOT_FOUND_CLUB_PRE_MEMBER)); + } + @Query(""" SELECT cpm FROM ClubPreMember cpm @@ -31,5 +65,7 @@ List findAllByUniversityIdAndStudentNumberAndName( void deleteByClubIdAndStudentNumber(Integer clubId, String studentNumber); + void delete(ClubPreMember preMember); + ClubPreMember save(ClubPreMember preMember); } diff --git a/src/main/java/gg/agit/konect/domain/club/service/ClubMemberManagementService.java b/src/main/java/gg/agit/konect/domain/club/service/ClubMemberManagementService.java index d26fc2c72..10ee73400 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/ClubMemberManagementService.java +++ b/src/main/java/gg/agit/konect/domain/club/service/ClubMemberManagementService.java @@ -12,6 +12,7 @@ import gg.agit.konect.domain.club.dto.ClubPreMemberAddRequest; import gg.agit.konect.domain.club.dto.ClubPreMemberAddResponse; +import gg.agit.konect.domain.club.dto.ClubPreMembersResponse; import gg.agit.konect.domain.club.dto.MemberPositionChangeRequest; import gg.agit.konect.domain.club.dto.PresidentTransferRequest; import gg.agit.konect.domain.club.dto.VicePresidentChangeRequest; @@ -81,7 +82,7 @@ public ClubPreMemberAddResponse addPreMember( ) { Club club = clubRepository.getById(clubId); - clubPermissionValidator.validateLeaderAccess(clubId, requesterId); + clubPermissionValidator.validateManagerAccess(clubId, requesterId); String studentNumber = request.studentNumber(); String name = request.name(); @@ -143,6 +144,25 @@ private ClubPreMemberAddResponse addPreMemberInternal( return ClubPreMemberAddResponse.from(savedPreMember); } + public ClubPreMembersResponse getPreMembers(Integer clubId, Integer requesterId) { + clubRepository.getById(clubId); + + clubPermissionValidator.validateManagerAccess(clubId, requesterId); + + List preMembers = clubPreMemberRepository.findAllByClubId(clubId); + return ClubPreMembersResponse.from(preMembers); + } + + @Transactional + public void removePreMember(Integer clubId, Integer preMemberId, Integer requesterId) { + clubRepository.getById(clubId); + + clubPermissionValidator.validateManagerAccess(clubId, requesterId); + + ClubPreMember preMember = clubPreMemberRepository.getByIdAndClubId(preMemberId, clubId); + clubPreMemberRepository.delete(preMember); + } + @Transactional public List transferPresident( Integer clubId, diff --git a/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java b/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java index e921013df..960c0f206 100644 --- a/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java +++ b/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java @@ -73,6 +73,7 @@ public enum ApiResponseCode { NOT_FOUND_CHAT_ROOM(HttpStatus.NOT_FOUND, "채팅방을 찾을 수 없습니다."), NOT_FOUND_CLUB(HttpStatus.NOT_FOUND, "동아리를 찾을 수 없습니다."), NOT_FOUND_CLUB_MEMBER(HttpStatus.NOT_FOUND, "해당하는 동아리 원을 찾을 수 없습니다."), + NOT_FOUND_CLUB_PRE_MEMBER(HttpStatus.NOT_FOUND, "사전 등록 회원을 찾을 수 없습니다."), NOT_FOUND_CLUB_PRESIDENT(HttpStatus.NOT_FOUND, "동아리 회장을 찾을 수 없습니다."), NOT_FOUND_CLUB_POSITION(HttpStatus.NOT_FOUND, "동아리 직책을 찾을 수 없습니다."), NOT_FOUND_CLUB_RECRUITMENT(HttpStatus.NOT_FOUND, "동아리 모집 공고를 찾을 수 없습니다."), From c22f1ee6e00c53613fae19930c917ffe9ed5a587 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <2dh2@naver.com> Date: Tue, 17 Feb 2026 14:42:13 +0900 Subject: [PATCH 09/13] =?UTF-8?q?docs:=20=EB=AA=85=EC=84=B8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../agit/konect/domain/club/controller/ClubMemberApi.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberApi.java b/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberApi.java index c3d48442c..bd8f6eff3 100644 --- a/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberApi.java +++ b/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberApi.java @@ -82,8 +82,8 @@ ResponseEntity changeVicePresident( ); @Operation(summary = "학번으로 회원을 동아리에 등록한다.", description = """ - 동아리 회장 또는 부회장만 회원을 등록할 수 있습니다. - + 운영진 이상만 사전 등록 회원 리스트를 조회할 수 있습니다. + ## 로직 - 해당 학번의 사용자가 이미 서비스에 가입한 경우: - 동아리 회원(ClubMember)에 지정한 직책(clubPosition)으로 직접 추가됩니다. @@ -108,7 +108,7 @@ ResponseEntity addPreMember( ); @Operation(summary = "동아리 사전 등록 회원 리스트를 조회한다.", description = """ - 동아리 회장 또는 부회장만 사전 등록 회원 리스트를 조회할 수 있습니다. + 운영진 이상만 사전 등록 회원을 삭제할 수 있습니다. ## 에러 - FORBIDDEN_CLUB_MANAGER_ACCESS (403): 동아리 매니저 권한이 없습니다. From 16f34f5a4ade2951716d8c6c56fa6c56cb89e2d0 Mon Sep 17 00:00:00 2001 From: JanooGwan Date: Wed, 18 Feb 2026 17:19:30 +0900 Subject: [PATCH 10/13] =?UTF-8?q?Revert=20"refactor:=20=ED=9A=8C=EB=B9=84?= =?UTF-8?q?=20=EB=82=A9=EB=B6=80=20=EC=97=AC=EB=B6=80=20=EA=B4=80=EB=A0=A8?= =?UTF-8?q?=20=EC=88=98=EC=A0=95,=20=EB=82=A9=EB=B6=80=20=EA=B8=B0?= =?UTF-8?q?=ED=95=9C=20=EC=82=AD=EC=A0=9C"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit af75cb23f5dc3110c3a734cdb6d58f48ae3fac8a, reversing changes made to 66e37f929cdf0a8e5f46aa91b8ad7a3ab70acfa8. --- .../club/controller/ClubRecruitmentApi.java | 5 ++- .../club/dto/ClubFeeInfoReplaceRequest.java | 11 +++--- .../domain/club/dto/ClubFeeInfoResponse.java | 11 ++++-- .../club/dto/ClubRecruitmentResponse.java | 4 +++ .../dto/ClubRecruitmentUpsertRequest.java | 4 +++ .../agit/konect/domain/club/model/Club.java | 36 +++++++++++-------- .../domain/club/model/ClubRecruitment.java | 9 +++++ .../club/service/ClubApplicationService.java | 8 +++-- .../club/service/ClubRecruitmentService.java | 15 ++++---- .../V25__drop_club_fee_deadline_column.sql | 13 ------- 10 files changed, 68 insertions(+), 48 deletions(-) delete mode 100644 src/main/resources/db/migration/V25__drop_club_fee_deadline_column.sql diff --git a/src/main/java/gg/agit/konect/domain/club/controller/ClubRecruitmentApi.java b/src/main/java/gg/agit/konect/domain/club/controller/ClubRecruitmentApi.java index 2a1a5c2fb..6bff888a7 100644 --- a/src/main/java/gg/agit/konect/domain/club/controller/ClubRecruitmentApi.java +++ b/src/main/java/gg/agit/konect/domain/club/controller/ClubRecruitmentApi.java @@ -20,10 +20,10 @@ public interface ClubRecruitmentApi { @Operation(summary = "동아리 모집 정보를 조회한다.", description = """ 동아리의 모집 공고 상세 정보를 조회합니다. - + - status는 모집 기간에 따라 BEFORE(모집 전), ONGOING(모집 중), CLOSED(모집 마감)으로 반환됩니다. - 동아리 멤버이거나 지원 이력이 존재할 경우 isApplied는 true로 반환됩니다. - + ## 에러 - NOT_FOUND_CLUB (404): 동아리를 찾을 수 없습니다. - NOT_FOUND_USER (404): 유저를 찾을 수 없습니다. @@ -39,7 +39,6 @@ ResponseEntity getRecruitments( 요청 값을 기준으로 동아리 모집 공고를 저장합니다. - 모집 공고가 없으면 생성 - 모집 공고가 있으면 수정 - ## 에러 - INVALID_RECRUITMENT_DATE_NOT_ALLOWED (400): 상시 모집인 경우 모집 시작일과 마감일을 지정할 수 없습니다. - INVALID_RECRUITMENT_DATE_REQUIRED (400): 상시 모집이 아닐 경우 모집 시작일과 마감일이 필수입니다. diff --git a/src/main/java/gg/agit/konect/domain/club/dto/ClubFeeInfoReplaceRequest.java b/src/main/java/gg/agit/konect/domain/club/dto/ClubFeeInfoReplaceRequest.java index 91656adf8..1dd0ce56a 100644 --- a/src/main/java/gg/agit/konect/domain/club/dto/ClubFeeInfoReplaceRequest.java +++ b/src/main/java/gg/agit/konect/domain/club/dto/ClubFeeInfoReplaceRequest.java @@ -1,7 +1,10 @@ package gg.agit.konect.domain.club.dto; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; -import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.time.LocalDate; + +import com.fasterxml.jackson.annotation.JsonFormat; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotNull; @@ -25,8 +28,8 @@ public record ClubFeeInfoReplaceRequest( @Schema(description = "예금주", example = "BCSD", requiredMode = NOT_REQUIRED) String accountHolder, - @NotNull(message = "회비 납부 필요 여부는 필수로 입력해야 합니다.") - @Schema(description = "회비 납부 필요 여부", example = "true", requiredMode = REQUIRED) - Boolean isFeeRequired + @Schema(description = "납부 기한", example = "2025.12.31", requiredMode = NOT_REQUIRED) + @JsonFormat(pattern = "yyyy.MM.dd") + LocalDate deadLine ) { } diff --git a/src/main/java/gg/agit/konect/domain/club/dto/ClubFeeInfoResponse.java b/src/main/java/gg/agit/konect/domain/club/dto/ClubFeeInfoResponse.java index 1a8231623..fba4d81d0 100644 --- a/src/main/java/gg/agit/konect/domain/club/dto/ClubFeeInfoResponse.java +++ b/src/main/java/gg/agit/konect/domain/club/dto/ClubFeeInfoResponse.java @@ -2,6 +2,10 @@ import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; +import java.time.LocalDate; + +import com.fasterxml.jackson.annotation.JsonFormat; + import gg.agit.konect.domain.club.model.Club; import io.swagger.v3.oas.annotations.media.Schema; @@ -18,8 +22,9 @@ public record ClubFeeInfoResponse( @Schema(description = "예금주", example = "BCSD", requiredMode = REQUIRED) String accountHolder, - @Schema(description = "회비 납부 필요 여부", example = "true", requiredMode = REQUIRED) - Boolean isFeeRequired + @Schema(description = "납부 기한", example = "2025.12.31", requiredMode = REQUIRED) + @JsonFormat(pattern = "yyyy.MM.dd") + LocalDate deadLine ) { public static ClubFeeInfoResponse from(Club club) { return new ClubFeeInfoResponse( @@ -27,7 +32,7 @@ public static ClubFeeInfoResponse from(Club club) { club.getFeeBank(), club.getFeeAccountNumber(), club.getFeeAccountHolder(), - club.getIsFeeRequired() + club.getFeeDeadline() ); } } diff --git a/src/main/java/gg/agit/konect/domain/club/dto/ClubRecruitmentResponse.java b/src/main/java/gg/agit/konect/domain/club/dto/ClubRecruitmentResponse.java index b93d43005..6a3f68fc7 100644 --- a/src/main/java/gg/agit/konect/domain/club/dto/ClubRecruitmentResponse.java +++ b/src/main/java/gg/agit/konect/domain/club/dto/ClubRecruitmentResponse.java @@ -37,6 +37,9 @@ public record ClubRecruitmentResponse( @Schema(description = "모집 공고 이미지 리스트", requiredMode = REQUIRED) List images, + @Schema(description = "회비 납부 필요 여부", example = "true", requiredMode = REQUIRED) + Boolean isFeeRequired, + @Schema(description = "지원 여부", example = "false", requiredMode = REQUIRED) Boolean isApplied ) { @@ -60,6 +63,7 @@ public static ClubRecruitmentResponse of(ClubRecruitment recruitment, Boolean is recruitment.getImages().stream() .map(InnerRecruitmentImage::from) .toList(), + recruitment.getIsFeeRequired(), isApplied ); } diff --git a/src/main/java/gg/agit/konect/domain/club/dto/ClubRecruitmentUpsertRequest.java b/src/main/java/gg/agit/konect/domain/club/dto/ClubRecruitmentUpsertRequest.java index c807a28f5..558126857 100644 --- a/src/main/java/gg/agit/konect/domain/club/dto/ClubRecruitmentUpsertRequest.java +++ b/src/main/java/gg/agit/konect/domain/club/dto/ClubRecruitmentUpsertRequest.java @@ -26,6 +26,10 @@ public record ClubRecruitmentUpsertRequest( @Schema(description = "상시 모집 여부", example = "false", requiredMode = REQUIRED) Boolean isAlwaysRecruiting, + @NotNull(message = "회비 납부 필요 여부는 필수 입력입니다.") + @Schema(description = "회비 납부 필요 여부", example = "true", requiredMode = REQUIRED) + Boolean isFeeRequired, + @NotEmpty(message = "모집 공고 내용은 필수 입력입니다.") @Schema(description = "모집 공고 내용", example = "BCSD 2025학년도 2학기 신입 부원 모집...", requiredMode = REQUIRED) String content, diff --git a/src/main/java/gg/agit/konect/domain/club/model/Club.java b/src/main/java/gg/agit/konect/domain/club/model/Club.java index c93d2f45b..769d9d2b9 100644 --- a/src/main/java/gg/agit/konect/domain/club/model/Club.java +++ b/src/main/java/gg/agit/konect/domain/club/model/Club.java @@ -7,6 +7,8 @@ import static jakarta.persistence.GenerationType.IDENTITY; import static lombok.AccessLevel.PROTECTED; +import java.time.LocalDate; + import org.springframework.util.StringUtils; import gg.agit.konect.domain.club.dto.ClubCreateRequest; @@ -75,8 +77,8 @@ public class Club extends BaseEntity { @Column(name = "fee_account_holder", length = 100) private String feeAccountHolder; - @Column(name = "is_fee_required", columnDefinition = "TINYINT(1)") - private Boolean isFeeRequired; + @Column(name = "fee_deadline") + private LocalDate feeDeadline; @OneToOne(mappedBy = "club", fetch = LAZY, cascade = ALL, orphanRemoval = true) private ClubRecruitment clubRecruitment; @@ -95,7 +97,7 @@ private Club( String feeBank, String feeAccountNumber, String feeAccountHolder, - Boolean isFeeRequired, + LocalDate feeDeadline, ClubRecruitment clubRecruitment ) { this.id = id; @@ -110,7 +112,7 @@ private Club( this.feeBank = feeBank; this.feeAccountNumber = feeAccountNumber; this.feeAccountHolder = feeAccountHolder; - this.isFeeRequired = isFeeRequired; + this.feeDeadline = feeDeadline; this.clubRecruitment = clubRecruitment; } @@ -131,18 +133,18 @@ public void replaceFeeInfo( String feeBank, String feeAccountNumber, String feeAccountHolder, - Boolean isFeeRequired + LocalDate feeDeadline ) { - if (isFeeInfoEmpty(feeAmount, feeBank, feeAccountNumber, feeAccountHolder)) { + if (isFeeInfoEmpty(feeAmount, feeBank, feeAccountNumber, feeAccountHolder, feeDeadline)) { clearFeeInfo(); return; } - if (!isFeeInfoComplete(feeAmount, feeBank, feeAccountNumber, feeAccountHolder)) { + if (!isFeeInfoComplete(feeAmount, feeBank, feeAccountNumber, feeAccountHolder, feeDeadline)) { throw CustomException.of(INVALID_REQUEST_BODY); } - updateFeeInfo(feeAmount, feeBank, feeAccountNumber, feeAccountHolder, isFeeRequired); + updateFeeInfo(feeAmount, feeBank, feeAccountNumber, feeAccountHolder, feeDeadline); } public void updateInfo(String description, String imageUrl, String location, String introduce) { @@ -161,24 +163,28 @@ private boolean isFeeInfoEmpty( Integer feeAmount, String feeBank, String feeAccountNumber, - String feeAccountHolder + String feeAccountHolder, + LocalDate feeDeadline ) { return feeAmount == null && feeBank == null && feeAccountNumber == null - && feeAccountHolder == null; + && feeAccountHolder == null + && feeDeadline == null; } private boolean isFeeInfoComplete( Integer feeAmount, String feeBank, String feeAccountNumber, - String feeAccountHolder + String feeAccountHolder, + LocalDate feeDeadline ) { return feeAmount != null && StringUtils.hasText(feeBank) && StringUtils.hasText(feeAccountNumber) - && StringUtils.hasText(feeAccountHolder); + && StringUtils.hasText(feeAccountHolder) + && feeDeadline != null; } private void updateFeeInfo( @@ -186,13 +192,13 @@ private void updateFeeInfo( String feeBank, String feeAccountNumber, String feeAccountHolder, - Boolean isFeeRequired + LocalDate feeDeadline ) { this.feeAmount = feeAmount; this.feeBank = feeBank; this.feeAccountNumber = feeAccountNumber; this.feeAccountHolder = feeAccountHolder; - this.isFeeRequired = isFeeRequired; + this.feeDeadline = feeDeadline; } private void clearFeeInfo() { @@ -200,6 +206,6 @@ private void clearFeeInfo() { this.feeBank = null; this.feeAccountNumber = null; this.feeAccountHolder = null; - this.isFeeRequired = null; + this.feeDeadline = null; } } diff --git a/src/main/java/gg/agit/konect/domain/club/model/ClubRecruitment.java b/src/main/java/gg/agit/konect/domain/club/model/ClubRecruitment.java index 22e39283f..4244fb1ad 100644 --- a/src/main/java/gg/agit/konect/domain/club/model/ClubRecruitment.java +++ b/src/main/java/gg/agit/konect/domain/club/model/ClubRecruitment.java @@ -56,6 +56,9 @@ public class ClubRecruitment extends BaseEntity { @Column(name = "is_always_recruiting", columnDefinition = "TINYINT(1)") private Boolean isAlwaysRecruiting; + @Column(name = "is_fee_required", nullable = false, columnDefinition = "TINYINT(1)") + private Boolean isFeeRequired; + @OneToOne(fetch = LAZY) @JoinColumn(name = "club_id", nullable = false, updatable = false) private Club club; @@ -70,6 +73,7 @@ private ClubRecruitment( LocalDate endDate, String content, Boolean isAlwaysRecruiting, + Boolean isFeeRequired, Club club ) { this.id = id; @@ -77,6 +81,7 @@ private ClubRecruitment( this.endDate = endDate; this.content = content; this.isAlwaysRecruiting = isAlwaysRecruiting; + this.isFeeRequired = isFeeRequired; this.club = club; } @@ -84,6 +89,7 @@ public static ClubRecruitment of( LocalDate startDate, LocalDate endDate, Boolean isAlwaysRecruiting, + Boolean isFeeRequired, String content, Club club ) { @@ -100,6 +106,7 @@ public static ClubRecruitment of( .content(content) .club(club) .isAlwaysRecruiting(isAlwaysRecruiting) + .isFeeRequired(isFeeRequired) .build(); } @@ -129,6 +136,7 @@ public void update( LocalDate startDate, LocalDate endDate, Boolean isAlwaysRecruiting, + Boolean isFeeRequired, String content ) { if (isAlwaysRecruiting) { @@ -141,6 +149,7 @@ public void update( this.startDate = startDate; this.endDate = endDate; this.isAlwaysRecruiting = isAlwaysRecruiting; + this.isFeeRequired = isFeeRequired; this.content = content; } } diff --git a/src/main/java/gg/agit/konect/domain/club/service/ClubApplicationService.java b/src/main/java/gg/agit/konect/domain/club/service/ClubApplicationService.java index f21d26782..3209c67d9 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/ClubApplicationService.java +++ b/src/main/java/gg/agit/konect/domain/club/service/ClubApplicationService.java @@ -188,7 +188,11 @@ public ClubFeeInfoResponse applyClub(Integer clubId, Integer userId, ClubApplyRe } private void validateFeePaymentImage(Club club, String feePaymentImageUrl) { - if (Boolean.TRUE.equals(club.getIsFeeRequired()) + ClubRecruitment recruitment = clubRecruitmentRepository.findByClubId(club.getId()) + .orElse(null); + + if (recruitment != null + && Boolean.TRUE.equals(recruitment.getIsFeeRequired()) && !StringUtils.hasText(feePaymentImageUrl)) { throw CustomException.of(FEE_PAYMENT_IMAGE_REQUIRED); } @@ -337,7 +341,7 @@ public ClubFeeInfoResponse replaceFeeInfo(Integer clubId, Integer userId, ClubFe bankName, request.accountNumber(), request.accountHolder(), - request.isFeeRequired() + request.deadLine() ); return ClubFeeInfoResponse.from(club); diff --git a/src/main/java/gg/agit/konect/domain/club/service/ClubRecruitmentService.java b/src/main/java/gg/agit/konect/domain/club/service/ClubRecruitmentService.java index 4218fbf4b..4e5670404 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/ClubRecruitmentService.java +++ b/src/main/java/gg/agit/konect/domain/club/service/ClubRecruitmentService.java @@ -52,6 +52,7 @@ public void upsertRecruitment(Integer clubId, Integer userId, ClubRecruitmentUps request.startDate(), request.endDate(), request.isAlwaysRecruiting(), + request.isFeeRequired(), request.content(), club )); @@ -61,20 +62,14 @@ public void upsertRecruitment(Integer clubId, Integer userId, ClubRecruitmentUps request.startDate(), request.endDate(), request.isAlwaysRecruiting(), + request.isFeeRequired(), request.content() ); clubRecruitment.getImages().clear(); } - addImages(clubRecruitment, request.getImageUrls()); - - if (clubRecruitment.getId() == null) { - clubRecruitmentRepository.save(clubRecruitment); - } - } - - private void addImages(ClubRecruitment clubRecruitment, List imageUrls) { + List imageUrls = request.getImageUrls(); for (int index = 0; index < imageUrls.size(); index++) { ClubRecruitmentImage image = ClubRecruitmentImage.of( imageUrls.get(index), @@ -83,5 +78,9 @@ private void addImages(ClubRecruitment clubRecruitment, List imageUrls) ); clubRecruitment.addImage(image); } + + if (clubRecruitment.getId() == null) { + clubRecruitmentRepository.save(clubRecruitment); + } } } diff --git a/src/main/resources/db/migration/V25__drop_club_fee_deadline_column.sql b/src/main/resources/db/migration/V25__drop_club_fee_deadline_column.sql deleted file mode 100644 index 5e6e9f534..000000000 --- a/src/main/resources/db/migration/V25__drop_club_fee_deadline_column.sql +++ /dev/null @@ -1,13 +0,0 @@ --- 회비 납부 기한 컬럼 삭제 -ALTER TABLE club DROP COLUMN fee_deadline; - --- 회비 납부 필요 여부를 club 테이블로 이동 -ALTER TABLE club ADD COLUMN is_fee_required TINYINT(1) NULL; - --- 기존 데이터 마이그레이션 (club_recruitment -> club) -UPDATE club c - INNER JOIN club_recruitment cr ON c.id = cr.club_id -SET c.is_fee_required = cr.is_fee_required; - --- club_recruitment에서 is_fee_required 컬럼 삭제 -ALTER TABLE club_recruitment DROP COLUMN is_fee_required; From 28b83f2b2b1097d281b3c90359ed2c9131aa92d9 Mon Sep 17 00:00:00 2001 From: JanooGwan Date: Wed, 18 Feb 2026 17:24:17 +0900 Subject: [PATCH 11/13] =?UTF-8?q?Revert=20"docs:=20Swagger=20=EC=88=98?= =?UTF-8?q?=EC=A0=95"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit bbd25c77ff1c538b38546bc802602a20ebcfc9e2. --- .../agit/konect/domain/club/dto/ClubFeeInfoReplaceRequest.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/gg/agit/konect/domain/club/dto/ClubFeeInfoReplaceRequest.java b/src/main/java/gg/agit/konect/domain/club/dto/ClubFeeInfoReplaceRequest.java index 1dd0ce56a..e30c9b018 100644 --- a/src/main/java/gg/agit/konect/domain/club/dto/ClubFeeInfoReplaceRequest.java +++ b/src/main/java/gg/agit/konect/domain/club/dto/ClubFeeInfoReplaceRequest.java @@ -1,7 +1,6 @@ package gg.agit.konect.domain.club.dto; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; - import java.time.LocalDate; import com.fasterxml.jackson.annotation.JsonFormat; From 82dbe197307114679c56139e113d0f53f6a888ed Mon Sep 17 00:00:00 2001 From: JanooGwan Date: Wed, 18 Feb 2026 17:24:50 +0900 Subject: [PATCH 12/13] =?UTF-8?q?Revert=20"refactor:=20=ED=9A=8C=EB=B9=84?= =?UTF-8?q?=20=EB=82=A9=EB=B6=80=20=EC=97=AC=EB=B6=80=20=EA=B4=80=EB=A0=A8?= =?UTF-8?q?=20=EC=88=98=EC=A0=95,=20=EB=82=A9=EB=B6=80=20=EA=B8=B0?= =?UTF-8?q?=ED=95=9C=20=EC=82=AD=EC=A0=9C"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 7248df4741f7f5e6999eb8b79e2940dfaa0a87f0. --- .../konect/domain/club/dto/ClubFeeInfoReplaceRequest.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/java/gg/agit/konect/domain/club/dto/ClubFeeInfoReplaceRequest.java b/src/main/java/gg/agit/konect/domain/club/dto/ClubFeeInfoReplaceRequest.java index e30c9b018..3253524c2 100644 --- a/src/main/java/gg/agit/konect/domain/club/dto/ClubFeeInfoReplaceRequest.java +++ b/src/main/java/gg/agit/konect/domain/club/dto/ClubFeeInfoReplaceRequest.java @@ -5,6 +5,10 @@ import com.fasterxml.jackson.annotation.JsonFormat; +import java.time.LocalDate; + +import com.fasterxml.jackson.annotation.JsonFormat; + import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.PositiveOrZero; From e4276680986b35081403e34351acb3da812f54cb Mon Sep 17 00:00:00 2001 From: JanooGwan Date: Wed, 18 Feb 2026 17:26:41 +0900 Subject: [PATCH 13/13] =?UTF-8?q?hotfix:=20=ED=9A=8C=EB=B9=84=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B4=80=EB=A0=A8=20=EC=BB=A4=EB=B0=8B=203?= =?UTF-8?q?=EA=B0=9C=20Revert=20=EB=B0=8F=20DB=20=EB=A1=A4=EB=B0=B1(V27)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/club/dto/ClubFeeInfoReplaceRequest.java | 4 ---- src/main/resources/db/migration/V27__add_fee_deadline | 9 +++++++++ 2 files changed, 9 insertions(+), 4 deletions(-) create mode 100644 src/main/resources/db/migration/V27__add_fee_deadline diff --git a/src/main/java/gg/agit/konect/domain/club/dto/ClubFeeInfoReplaceRequest.java b/src/main/java/gg/agit/konect/domain/club/dto/ClubFeeInfoReplaceRequest.java index 3253524c2..e30c9b018 100644 --- a/src/main/java/gg/agit/konect/domain/club/dto/ClubFeeInfoReplaceRequest.java +++ b/src/main/java/gg/agit/konect/domain/club/dto/ClubFeeInfoReplaceRequest.java @@ -5,10 +5,6 @@ import com.fasterxml.jackson.annotation.JsonFormat; -import java.time.LocalDate; - -import com.fasterxml.jackson.annotation.JsonFormat; - import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.PositiveOrZero; diff --git a/src/main/resources/db/migration/V27__add_fee_deadline b/src/main/resources/db/migration/V27__add_fee_deadline new file mode 100644 index 000000000..c883b1873 --- /dev/null +++ b/src/main/resources/db/migration/V27__add_fee_deadline @@ -0,0 +1,9 @@ +ALTER TABLE club_recruitment ADD COLUMN is_fee_required TINYINT(1) NULL; + +UPDATE club_recruitment cr + INNER JOIN club c ON cr.club_id = c.id +SET cr.is_fee_required = c.is_fee_required; + +ALTER TABLE club DROP COLUMN is_fee_required; + +ALTER TABLE club ADD COLUMN fee_deadline DATE NULL;