From 8ddc110e9be42f4eb9e979f5fb7c8d7f10a8bf77 Mon Sep 17 00:00:00 2001 From: fad Date: Sun, 4 Jan 2026 20:44:44 +0900 Subject: [PATCH 01/22] =?UTF-8?q?feat:=20JWT=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=ED=95=84=ED=84=B0=EC=97=90=EC=84=9C=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=ED=86=A0=ED=81=B0=20=EC=B2=98=EB=A6=AC=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EC=BF=A0=ED=82=A4=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../security/jwt/JwtAuthenticationFilter.java | 13 ++++++++----- .../common/security/jwt/JwtTokenProvider.java | 14 +++++++++++++- .../domain/member/controller/MemberController.java | 3 +++ 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/src/main/java/org/ject/support/common/security/jwt/JwtAuthenticationFilter.java b/src/main/java/org/ject/support/common/security/jwt/JwtAuthenticationFilter.java index c4705d86..3aa69c38 100644 --- a/src/main/java/org/ject/support/common/security/jwt/JwtAuthenticationFilter.java +++ b/src/main/java/org/ject/support/common/security/jwt/JwtAuthenticationFilter.java @@ -9,8 +9,6 @@ import org.ject.support.common.exception.GlobalErrorCode; import org.ject.support.common.exception.GlobalException; import org.ject.support.common.security.CustomUserDetails; -import org.ject.support.domain.auth.exception.AuthErrorCode; -import org.ject.support.domain.auth.exception.AuthException; import org.ject.support.domain.member.Role; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; @@ -64,9 +62,14 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse jwtTokenProvider.resolveVerificationToken(request); if (verificationToken != null) { - if (!jwtTokenProvider.validateToken(verificationToken)) { - // verification token은 실패 시 에러가 맞음 - throw new AuthException(AuthErrorCode.INVALID_TOKEN); + if (jwtTokenProvider.validateToken(verificationToken)) { + // 토큰이 유효한 경우, 인증 정보를 SecurityContext에 설정 + Authentication auth = jwtTokenProvider.getAuthenticationByToken(accessToken); + SecurityContextHolder.getContext().setAuthentication(auth); + } else { + clearAuthCookie(response, "Authentication auth = jwtTokenProvider.getAuthenticationByToken(accessToken);\n" + + " SecurityContextHolder.getContext().setAuthentication(auth);"); + SecurityContextHolder.clearContext(); } String email = jwtTokenProvider.extractEmailFromVerificationToken(verificationToken); diff --git a/src/main/java/org/ject/support/common/security/jwt/JwtTokenProvider.java b/src/main/java/org/ject/support/common/security/jwt/JwtTokenProvider.java index 06e54037..392f9ecf 100644 --- a/src/main/java/org/ject/support/common/security/jwt/JwtTokenProvider.java +++ b/src/main/java/org/ject/support/common/security/jwt/JwtTokenProvider.java @@ -10,6 +10,7 @@ import jakarta.annotation.PostConstruct; import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import lombok.Getter; import lombok.RequiredArgsConstructor; import org.ject.support.common.exception.GlobalException; @@ -205,7 +206,18 @@ public String createVerificationToken(String email) { .signWith(secretKey) .compact(); } - + + /** + * 인증번호 검증 쿠키 삭제 + */ + public void deleteVerificationCookie(HttpServletResponse response) { + Cookie cookie = new Cookie("verificationToken", null); + cookie.setPath("/"); + cookie.setHttpOnly(true); + cookie.setMaxAge(0); + response.addCookie(cookie); + } + /** * 인증번호 검증 토큰에서 이메일 추출 */ diff --git a/src/main/java/org/ject/support/domain/member/controller/MemberController.java b/src/main/java/org/ject/support/domain/member/controller/MemberController.java index 5f8c21d5..89bbec49 100644 --- a/src/main/java/org/ject/support/domain/member/controller/MemberController.java +++ b/src/main/java/org/ject/support/domain/member/controller/MemberController.java @@ -50,6 +50,9 @@ public boolean registerTempMember(HttpServletRequest request, HttpServletRespons Authentication authentication = memberService.registerTempMember(registerRequest, email); customSuccessHandler.onAuthenticationSuccess(request, response, authentication); + // verification 토큰은 더 이상 필요 없으므로 삭제 + jwtTokenProvider.deleteVerificationCookie(response); + return true; } From d689717ced00f22be7969bf670325befdd07fdcd Mon Sep 17 00:00:00 2001 From: fad Date: Sun, 4 Jan 2026 20:50:59 +0900 Subject: [PATCH 02/22] =?UTF-8?q?feat:=20JWT=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=ED=95=84=ED=84=B0=EC=97=90=EC=84=9C=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=ED=86=A0=ED=81=B0=20=EC=BF=A0=ED=82=A4=EC=97=90=20=EB=B3=B4?= =?UTF-8?q?=EC=95=88=20=EC=86=8D=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../org/ject/support/common/security/jwt/JwtTokenProvider.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/org/ject/support/common/security/jwt/JwtTokenProvider.java b/src/main/java/org/ject/support/common/security/jwt/JwtTokenProvider.java index 392f9ecf..478bf68c 100644 --- a/src/main/java/org/ject/support/common/security/jwt/JwtTokenProvider.java +++ b/src/main/java/org/ject/support/common/security/jwt/JwtTokenProvider.java @@ -214,6 +214,7 @@ public void deleteVerificationCookie(HttpServletResponse response) { Cookie cookie = new Cookie("verificationToken", null); cookie.setPath("/"); cookie.setHttpOnly(true); + cookie.setSecure(true); cookie.setMaxAge(0); response.addCookie(cookie); } From 73d25a3c562b1a141b59d2e7a23ed0e9e2e4a856 Mon Sep 17 00:00:00 2001 From: fad Date: Sun, 4 Jan 2026 21:07:28 +0900 Subject: [PATCH 03/22] =?UTF-8?q?feat:=20JWT=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=ED=95=84=ED=84=B0=EC=97=90=EC=84=9C=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=ED=86=A0=ED=81=B0=20=EC=B2=98=EB=A6=AC=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/security/jwt/JwtAuthenticationFilter.java | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/ject/support/common/security/jwt/JwtAuthenticationFilter.java b/src/main/java/org/ject/support/common/security/jwt/JwtAuthenticationFilter.java index 3aa69c38..14d14b54 100644 --- a/src/main/java/org/ject/support/common/security/jwt/JwtAuthenticationFilter.java +++ b/src/main/java/org/ject/support/common/security/jwt/JwtAuthenticationFilter.java @@ -63,12 +63,11 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse if (verificationToken != null) { if (jwtTokenProvider.validateToken(verificationToken)) { - // 토큰이 유효한 경우, 인증 정보를 SecurityContext에 설정 - Authentication auth = jwtTokenProvider.getAuthenticationByToken(accessToken); + String email = jwtTokenProvider.extractEmailFromVerificationToken(verificationToken); + Authentication auth = createVerificationAuthentication(email); SecurityContextHolder.getContext().setAuthentication(auth); } else { - clearAuthCookie(response, "Authentication auth = jwtTokenProvider.getAuthenticationByToken(accessToken);\n" + - " SecurityContextHolder.getContext().setAuthentication(auth);"); + clearAuthCookie(response, "verificationToken"); SecurityContextHolder.clearContext(); } From a436a08b7b7e14afbf64788cdd215c8b76793e0a Mon Sep 17 00:00:00 2001 From: fad Date: Sun, 4 Jan 2026 21:25:33 +0900 Subject: [PATCH 04/22] =?UTF-8?q?feat:=20JWT=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=ED=95=84=ED=84=B0=EC=97=90=EC=84=9C=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=ED=86=A0=ED=81=B0=20=EC=B2=98=EB=A6=AC=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EA=B0=84=EC=86=8C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../support/common/security/jwt/JwtAuthenticationFilter.java | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/main/java/org/ject/support/common/security/jwt/JwtAuthenticationFilter.java b/src/main/java/org/ject/support/common/security/jwt/JwtAuthenticationFilter.java index 14d14b54..4a62e4a5 100644 --- a/src/main/java/org/ject/support/common/security/jwt/JwtAuthenticationFilter.java +++ b/src/main/java/org/ject/support/common/security/jwt/JwtAuthenticationFilter.java @@ -70,11 +70,6 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse clearAuthCookie(response, "verificationToken"); SecurityContextHolder.clearContext(); } - - String email = jwtTokenProvider.extractEmailFromVerificationToken(verificationToken); - - Authentication auth = createVerificationAuthentication(email); - SecurityContextHolder.getContext().setAuthentication(auth); } chain.doFilter(request, response); From 9a60b2436f0ff04f66dc874a963547b78d82540f Mon Sep 17 00:00:00 2001 From: fad Date: Mon, 5 Jan 2026 01:41:09 +0900 Subject: [PATCH 05/22] =?UTF-8?q?feat:=20JWT=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=ED=95=84=ED=84=B0=EC=97=90=EC=84=9C=20=EC=95=A1=EC=84=B8?= =?UTF-8?q?=EC=8A=A4=20=ED=86=A0=ED=81=B0=20=EB=B0=8F=20=EA=B2=80=EC=A6=9D?= =?UTF-8?q?=20=ED=86=A0=ED=81=B0=20=EC=B2=98=EB=A6=AC=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../support/common/security/jwt/JwtAuthenticationFilter.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/java/org/ject/support/common/security/jwt/JwtAuthenticationFilter.java b/src/main/java/org/ject/support/common/security/jwt/JwtAuthenticationFilter.java index 4a62e4a5..134607ef 100644 --- a/src/main/java/org/ject/support/common/security/jwt/JwtAuthenticationFilter.java +++ b/src/main/java/org/ject/support/common/security/jwt/JwtAuthenticationFilter.java @@ -49,6 +49,9 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse if (jwtTokenProvider.validateToken(accessToken)) { Authentication auth = jwtTokenProvider.getAuthenticationByToken(accessToken); SecurityContextHolder.getContext().setAuthentication(auth); + + chain.doFilter(request, response); + return; } else { clearAuthCookie(response, "accessToken"); SecurityContextHolder.clearContext(); @@ -64,6 +67,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse if (verificationToken != null) { if (jwtTokenProvider.validateToken(verificationToken)) { String email = jwtTokenProvider.extractEmailFromVerificationToken(verificationToken); + Authentication auth = createVerificationAuthentication(email); SecurityContextHolder.getContext().setAuthentication(auth); } else { From 8e63b2e77c4c5b493e218077e6ecdf65bbe3510f Mon Sep 17 00:00:00 2001 From: fad Date: Wed, 7 Jan 2026 01:05:25 +0900 Subject: [PATCH 06/22] =?UTF-8?q?feat:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EB=B0=8F=20=EC=84=9C=ED=8F=AC?= =?UTF-8?q?=ED=84=B0=20=ED=86=A0=ED=81=B0=20=EB=B0=9C=EA=B8=89=20=EC=9D=B4?= =?UTF-8?q?=EB=B2=A4=ED=8A=B8=EB=A5=BC=20=EB=B0=9C=ED=96=89=ED=95=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=95=8C=EB=A6=BC=20=EC=8B=9C=EC=8A=A4?= =?UTF-8?q?=ED=85=9C=EC=9D=84=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=EC=9C=BC=EB=A1=9C=20=EB=A6=AC=ED=8C=A9=ED=86=A0?= =?UTF-8?q?=EB=A7=81=20(#405)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/service/AdminAuthService.java | 11 +- .../service/AuthSupporterTokenService.java | 12 +- .../external/discord/DiscordComponent.java | 104 ------------------ .../discord/DiscordWebhookPayload.java | 21 ---- .../DiscordNotificationMessageFactory.java | 27 ----- .../NotificationMessageFactory.java | 6 - .../notification/client/DiscordClient.java | 49 +++++++++ .../event/AdminLoginNotificationEvent.java | 6 + .../event/SupporterTokenIssuedEvent.java | 6 + .../executor/NotificationExecutorService.java | 20 ++++ .../listener/NotificationEventListener.java | 37 +++++++ .../payload/DiscordWebhookPayload.java | 66 +++++++++++ .../sender/DiscordNotificationSender.java | 60 ++++++++++ .../sender/NotificationSender.java | 16 +++ .../admin/service/AdminAuthServiceTest.java | 18 +-- 15 files changed, 281 insertions(+), 178 deletions(-) delete mode 100644 src/main/java/org/ject/support/external/discord/DiscordComponent.java delete mode 100644 src/main/java/org/ject/support/external/discord/DiscordWebhookPayload.java delete mode 100644 src/main/java/org/ject/support/external/notification/DiscordNotificationMessageFactory.java delete mode 100644 src/main/java/org/ject/support/external/notification/NotificationMessageFactory.java create mode 100644 src/main/java/org/ject/support/external/notification/client/DiscordClient.java create mode 100644 src/main/java/org/ject/support/external/notification/event/AdminLoginNotificationEvent.java create mode 100644 src/main/java/org/ject/support/external/notification/event/SupporterTokenIssuedEvent.java create mode 100644 src/main/java/org/ject/support/external/notification/executor/NotificationExecutorService.java create mode 100644 src/main/java/org/ject/support/external/notification/listener/NotificationEventListener.java create mode 100644 src/main/java/org/ject/support/external/notification/payload/DiscordWebhookPayload.java create mode 100644 src/main/java/org/ject/support/external/notification/sender/DiscordNotificationSender.java create mode 100644 src/main/java/org/ject/support/external/notification/sender/NotificationSender.java diff --git a/src/main/java/org/ject/support/domain/admin/service/AdminAuthService.java b/src/main/java/org/ject/support/domain/admin/service/AdminAuthService.java index 0b8b3c4e..93d942cc 100644 --- a/src/main/java/org/ject/support/domain/admin/service/AdminAuthService.java +++ b/src/main/java/org/ject/support/domain/admin/service/AdminAuthService.java @@ -9,8 +9,9 @@ import org.ject.support.domain.admin.exception.AdminException; import org.ject.support.domain.member.MemberStatus; import org.ject.support.domain.member.entity.Member; -import org.ject.support.external.discord.DiscordComponent; import org.ject.support.external.infrastructure.DiscordRateLimiter; +import org.ject.support.external.notification.event.AdminLoginNotificationEvent; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.security.core.Authentication; import org.springframework.stereotype.Service; @@ -24,10 +25,10 @@ @Transactional(readOnly = true) public class AdminAuthService { + private final ApplicationEventPublisher applicationEventPublisher; private final RedisTemplate redisTemplate; private final AdminMemberComponent adminMemberComponent; private final DiscordRateLimiter discordRateLimiter; - private final DiscordComponent discordComponent; private final JwtTokenProvider jwtTokenProvider; private static final String ADMIN_LOGIN_AUTH_CODE_KEY_PREFIX = "admin-login:"; @@ -46,9 +47,9 @@ public String sendAdminAuthCode(String email) { if (discordRateLimiter.tryConsume(1)) { redisTemplate.opsForValue().set(key, authCode, Duration.ofSeconds(ADMIN_LOGIN_AUTH_CODE_EXPIRATION)); - discordComponent.sendAdminLoginMessage(member.getEmail(), authCode) - .doOnError(e -> log.error("Discord 전송 실패: {}", member.getEmail(), e)) - .subscribe(); + + applicationEventPublisher + .publishEvent(new AdminLoginNotificationEvent(email, authCode)); } else { throw new AdminException(AdminErrorCode.TOO_MANY_REQUESTS); } diff --git a/src/main/java/org/ject/support/domain/auth/service/AuthSupporterTokenService.java b/src/main/java/org/ject/support/domain/auth/service/AuthSupporterTokenService.java index 9308327b..8ce6b5f7 100644 --- a/src/main/java/org/ject/support/domain/auth/service/AuthSupporterTokenService.java +++ b/src/main/java/org/ject/support/domain/auth/service/AuthSupporterTokenService.java @@ -11,7 +11,8 @@ import org.ject.support.domain.member.Role; import org.ject.support.domain.member.entity.Member; import org.ject.support.domain.member.repository.MemberRepository; -import org.ject.support.external.discord.DiscordComponent; +import org.ject.support.external.notification.event.SupporterTokenIssuedEvent; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -28,10 +29,10 @@ public class AuthSupporterTokenService { private final long TOKEN_EXPIRATION_MILLIS = 24 * 60 * 60 * 1000; // 1일 + private final ApplicationEventPublisher applicationEventPublisher; private final JwtTokenProvider jwtTokenProvider; private final MemberRepository memberRepository; private final PasswordEncoder passwordEncoder; - private final DiscordComponent discordComponent; public void issueReadOnlyToken(String email, String pin) { Member member = memberRepository.findByEmailAndRole(email, Role.ADMIN) @@ -50,10 +51,7 @@ public void issueReadOnlyToken(String email, String pin) { String token = jwtTokenProvider.createToken(claims, TOKEN_EXPIRATION_MILLIS); - discordComponent.sendSupporterTokenIssueMessage(email, token) - .doOnError(e -> { - log.error("지원자 토큰 전송 실패: {}", email, e); - }) - .subscribe(); + applicationEventPublisher + .publishEvent(new SupporterTokenIssuedEvent(email, token)); } } diff --git a/src/main/java/org/ject/support/external/discord/DiscordComponent.java b/src/main/java/org/ject/support/external/discord/DiscordComponent.java deleted file mode 100644 index 5b632722..00000000 --- a/src/main/java/org/ject/support/external/discord/DiscordComponent.java +++ /dev/null @@ -1,104 +0,0 @@ -package org.ject.support.external.discord; - -import lombok.extern.slf4j.Slf4j; -import org.ject.support.external.notification.NotificationMessageFactory; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.core.env.Environment; -import org.springframework.core.env.Profiles; -import org.springframework.http.ResponseEntity; -import org.springframework.stereotype.Component; -import org.springframework.util.StringUtils; -import org.springframework.web.reactive.function.client.WebClient; -import reactor.core.publisher.Mono; - -@Slf4j -@Component -public class DiscordComponent { - - private final WebClient webClient; - private final String prefix; - private final NotificationMessageFactory messageFactory; - - @Value("${notification.discord.webhook.admin-login}") - private String adminLoginWebhook; - - @Value("${notification.discord.webhook.supporter-token-issue}") - private String supporterTokenIssueWebhook; - - public DiscordComponent( - WebClient webClient, - Environment environment, - NotificationMessageFactory messageFactory - ) { - this.webClient = webClient; - this.prefix = resolvePrefix(environment); - this.messageFactory = messageFactory; - } - - public Mono sendAdminLoginMessage(String email, String code) { - String description = messageFactory.adminLoginCode(email, code); - - DiscordWebhookPayload.Embed embed = - new DiscordWebhookPayload.Embed("로그인 인증 요청", description); - - DiscordWebhookPayload payload = new DiscordWebhookPayload(); - payload.setContent("[관리자 로그인]"); - payload.getEmbeds().add(embed); - return sendMessage(adminLoginWebhook, payload); - } - - public Mono sendSupporterTokenIssueMessage(String email, String accessToken) { - String description = messageFactory.supporterAccessTokenIssued(email, accessToken); - - DiscordWebhookPayload.Embed embed = - new DiscordWebhookPayload.Embed("서포터즈 엑세스 토큰 발급 요청", description); - - DiscordWebhookPayload payload = new DiscordWebhookPayload(); - payload.setContent("[서포터즈 엑세스 토큰 발급]"); - payload.getEmbeds().add(embed); - return sendMessage(supporterTokenIssueWebhook, payload); - } - - /** - * 논블로킹 방식으로 메시지 전송. 호출자가 구독/에러처리를 담당해야 함. - * 기존 payload는 변경하지 않고 사본을 만들어 전송. - */ - public Mono sendMessage(String webhookUrl, DiscordWebhookPayload payload) { - if (!StringUtils.hasText(webhookUrl)) { - log.warn("Discord webhook URL이 설정되지 않았습니다, 메시지 전송을 건너뜁니다."); - return Mono.empty(); - } - - DiscordWebhookPayload toSend = createPayloadCopyWithPrefix(payload, prefix); - - return webClient.post() - .uri(webhookUrl) - .bodyValue(toSend) - .retrieve() - .toBodilessEntity() - .doOnSuccess((ResponseEntity resp) -> - log.info("Discord message sent successfully")) - .doOnError(e -> - log.error("Discord message send failed: {} - {}", e.getClass().getSimpleName(), e.getMessage())) - .then(); - } - - private DiscordWebhookPayload createPayloadCopyWithPrefix(DiscordWebhookPayload original, String prefix) { - DiscordWebhookPayload copy = new DiscordWebhookPayload(); - String originalContent = original == null || original.getContent() == null ? "" : original.getContent(); - copy.setContent(prefix + originalContent); - - if (original != null && original.getEmbeds() != null) { - copy.getEmbeds().addAll(original.getEmbeds()); - } - return copy; - } - - private String resolvePrefix(Environment environment) { - if (environment != null && environment.acceptsProfiles(Profiles.of("prod", "production"))) { - return ""; - } else { - return "[개발] "; - } - } -} diff --git a/src/main/java/org/ject/support/external/discord/DiscordWebhookPayload.java b/src/main/java/org/ject/support/external/discord/DiscordWebhookPayload.java deleted file mode 100644 index cda26a80..00000000 --- a/src/main/java/org/ject/support/external/discord/DiscordWebhookPayload.java +++ /dev/null @@ -1,21 +0,0 @@ -package org.ject.support.external.discord; - -import lombok.AllArgsConstructor; -import lombok.Data; - -import java.util.ArrayList; -import java.util.List; - -@Data -public class DiscordWebhookPayload { - private String content; - private boolean tts = false; - private List embeds = new ArrayList<>(); - - @Data - @AllArgsConstructor - public static class Embed { - private String title; - private String description; - } -} diff --git a/src/main/java/org/ject/support/external/notification/DiscordNotificationMessageFactory.java b/src/main/java/org/ject/support/external/notification/DiscordNotificationMessageFactory.java deleted file mode 100644 index 120f12f0..00000000 --- a/src/main/java/org/ject/support/external/notification/DiscordNotificationMessageFactory.java +++ /dev/null @@ -1,27 +0,0 @@ -package org.ject.support.external.notification; - -import org.springframework.context.annotation.Primary; -import org.springframework.stereotype.Component; - -@Primary -@Component -public class DiscordNotificationMessageFactory implements NotificationMessageFactory { - - @Override - public String adminLoginCode(String email, String code) { - return """ - 관리자 로그인 인증 코드 요청 - 관리자 이메일: %s - 인증 코드 : ||%s|| - """.formatted(email, code); - } - - @Override - public String supporterAccessTokenIssued(String email, String accessToken) { - return """ - 서포터즈 엑세스 토큰 요청 - 요청 이메일 : %s - 토큰 : ||%s|| - """.formatted(email, accessToken); - } -} diff --git a/src/main/java/org/ject/support/external/notification/NotificationMessageFactory.java b/src/main/java/org/ject/support/external/notification/NotificationMessageFactory.java deleted file mode 100644 index 6945274f..00000000 --- a/src/main/java/org/ject/support/external/notification/NotificationMessageFactory.java +++ /dev/null @@ -1,6 +0,0 @@ -package org.ject.support.external.notification; - -public interface NotificationMessageFactory { - String adminLoginCode(String email, String code); - String supporterAccessTokenIssued(String email, String accessToken); -} diff --git a/src/main/java/org/ject/support/external/notification/client/DiscordClient.java b/src/main/java/org/ject/support/external/notification/client/DiscordClient.java new file mode 100644 index 00000000..b45c0025 --- /dev/null +++ b/src/main/java/org/ject/support/external/notification/client/DiscordClient.java @@ -0,0 +1,49 @@ +package org.ject.support.external.notification.client; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.ject.support.external.notification.payload.DiscordWebhookPayload; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +@Slf4j +@Component +@RequiredArgsConstructor +public class DiscordClient { + + private final WebClient webClient; + + public Mono send( + String webhookUrl, + DiscordWebhookPayload payload + ) { + if (!StringUtils.hasText(webhookUrl)) { + log.warn("Discord webhook URL이 비어있어 메시지를 전송하지 않습니다."); + return Mono.empty(); + } + + return webClient.post() + .uri(webhookUrl) + .bodyValue(payload) + .retrieve() + .toBodilessEntity() + .doOnSuccess(this::logSuccess) + .doOnError(this::logError) + .then(); + } + + private void logSuccess(ResponseEntity response) { + log.info("Discord message sent successfully (status={})", response.getStatusCode()); + } + + private void logError(Throwable e) { + log.error( + "Discord message send failed: {} - {}", + e.getClass().getSimpleName(), + e.getMessage() + ); + } +} diff --git a/src/main/java/org/ject/support/external/notification/event/AdminLoginNotificationEvent.java b/src/main/java/org/ject/support/external/notification/event/AdminLoginNotificationEvent.java new file mode 100644 index 00000000..49cb7772 --- /dev/null +++ b/src/main/java/org/ject/support/external/notification/event/AdminLoginNotificationEvent.java @@ -0,0 +1,6 @@ +package org.ject.support.external.notification.event; + +public record AdminLoginNotificationEvent( + String email, + String code +) {} diff --git a/src/main/java/org/ject/support/external/notification/event/SupporterTokenIssuedEvent.java b/src/main/java/org/ject/support/external/notification/event/SupporterTokenIssuedEvent.java new file mode 100644 index 00000000..ef66060c --- /dev/null +++ b/src/main/java/org/ject/support/external/notification/event/SupporterTokenIssuedEvent.java @@ -0,0 +1,6 @@ +package org.ject.support.external.notification.event; + +public record SupporterTokenIssuedEvent( + String email, + String accessToken +) {} diff --git a/src/main/java/org/ject/support/external/notification/executor/NotificationExecutorService.java b/src/main/java/org/ject/support/external/notification/executor/NotificationExecutorService.java new file mode 100644 index 00000000..d4893fa0 --- /dev/null +++ b/src/main/java/org/ject/support/external/notification/executor/NotificationExecutorService.java @@ -0,0 +1,20 @@ +package org.ject.support.external.notification.executor; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Mono; + +import java.time.Duration; + +@Component +@Slf4j +public class NotificationExecutorService { + + public void execute(Mono mono) { + mono + .retry(3) + .timeout(Duration.ofSeconds(3)) + .doOnError(e -> log.error("Notification failed", e)) + .subscribe(); + } +} diff --git a/src/main/java/org/ject/support/external/notification/listener/NotificationEventListener.java b/src/main/java/org/ject/support/external/notification/listener/NotificationEventListener.java new file mode 100644 index 00000000..0c21cedd --- /dev/null +++ b/src/main/java/org/ject/support/external/notification/listener/NotificationEventListener.java @@ -0,0 +1,37 @@ +package org.ject.support.external.notification.listener; + +import lombok.RequiredArgsConstructor; +import org.ject.support.external.notification.sender.NotificationSender; +import org.ject.support.external.notification.event.AdminLoginNotificationEvent; +import org.ject.support.external.notification.event.SupporterTokenIssuedEvent; +import org.ject.support.external.notification.executor.NotificationExecutorService; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class NotificationEventListener { + + private final NotificationSender notificationSender; + private final NotificationExecutorService notificationExecutorService; + + @EventListener + public void handle(AdminLoginNotificationEvent event) { + notificationExecutorService.execute( + notificationSender.sendAdminLogin( + event.email(), + event.code() + ) + ); + } + + @EventListener + public void handle(SupporterTokenIssuedEvent event) { + notificationExecutorService.execute( + notificationSender.sendSupporterTokenIssued( + event.email(), + event.accessToken() + ) + ); + } +} diff --git a/src/main/java/org/ject/support/external/notification/payload/DiscordWebhookPayload.java b/src/main/java/org/ject/support/external/notification/payload/DiscordWebhookPayload.java new file mode 100644 index 00000000..edf8592b --- /dev/null +++ b/src/main/java/org/ject/support/external/notification/payload/DiscordWebhookPayload.java @@ -0,0 +1,66 @@ +package org.ject.support.external.notification.payload; + +import lombok.AllArgsConstructor; +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +@Data +public class DiscordWebhookPayload { + private String content; + private boolean tts = false; + private List embeds = new ArrayList<>(); + + @Data + @AllArgsConstructor + public static class Embed { + private String title; + private String description; + } + + public DiscordWebhookPayload withPrefix(String prefix) { + DiscordWebhookPayload copy = new DiscordWebhookPayload(); + copy.setContent(prefix + this.content); + copy.getEmbeds().addAll(this.embeds); + return copy; + } + + public static DiscordWebhookPayload adminLogin( + String email, + String code + ) { + DiscordWebhookPayload payload = new DiscordWebhookPayload(); + payload.setContent("[관리자 로그인]"); + + payload.getEmbeds().add( + new Embed( + "관리자 로그인 인증 코드 요청", + """ + 이메일: %s + 인증 코드 : ||%s|| + """.formatted(email, code) + ) + ); + return payload; + } + + public static DiscordWebhookPayload supporterToken( + String email, + String accessToken + ) { + DiscordWebhookPayload payload = new DiscordWebhookPayload(); + payload.setContent("[서포터즈 임시 토큰 발급]"); + + payload.getEmbeds().add( + new Embed( + "서포터즈 토큰 발급", + """ + 이메일: %s + 토큰 : ||%s|| + """.formatted(email, accessToken) + ) + ); + return payload; + } +} diff --git a/src/main/java/org/ject/support/external/notification/sender/DiscordNotificationSender.java b/src/main/java/org/ject/support/external/notification/sender/DiscordNotificationSender.java new file mode 100644 index 00000000..ea9b6921 --- /dev/null +++ b/src/main/java/org/ject/support/external/notification/sender/DiscordNotificationSender.java @@ -0,0 +1,60 @@ +package org.ject.support.external.notification.sender; + +import lombok.RequiredArgsConstructor; +import org.ject.support.external.notification.client.DiscordClient; +import org.ject.support.external.notification.payload.DiscordWebhookPayload; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Primary; +import org.springframework.core.env.Environment; +import org.springframework.core.env.Profiles; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Mono; + +@Primary +@Component +@RequiredArgsConstructor +public class DiscordNotificationSender implements NotificationSender { + + private final DiscordClient discordClient; + private final Environment environment; + + @Value("${notification.discord.webhook.admin-login}") + private String adminLoginWebhook; + + @Value("${notification.discord.webhook.supporter-token-issue}") + private String supporterTokenIssueWebhook; + + @Override + public Mono sendAdminLogin( + String email, + String code + ) { + return discordClient.send( + adminLoginWebhook, + DiscordWebhookPayload + .adminLogin(email, code) + .withPrefix(resolvePrefix()) + ); + } + + @Override + public Mono sendSupporterTokenIssued( + String email, + String accessToken + ) { + return discordClient.send( + supporterTokenIssueWebhook, + DiscordWebhookPayload + .supporterToken(email, accessToken) + .withPrefix(resolvePrefix()) + ); + } + + private String resolvePrefix() { + if (environment.acceptsProfiles(Profiles.of("prod", "production"))) { + return ""; + } else { + return "[개발] "; + } + } +} diff --git a/src/main/java/org/ject/support/external/notification/sender/NotificationSender.java b/src/main/java/org/ject/support/external/notification/sender/NotificationSender.java new file mode 100644 index 00000000..e1385f38 --- /dev/null +++ b/src/main/java/org/ject/support/external/notification/sender/NotificationSender.java @@ -0,0 +1,16 @@ +package org.ject.support.external.notification.sender; + +import reactor.core.publisher.Mono; + +public interface NotificationSender { + + Mono sendAdminLogin( + String email, + String code + ); + + Mono sendSupporterTokenIssued( + String email, + String accessToken + ); +} diff --git a/src/test/java/org/ject/support/domain/admin/service/AdminAuthServiceTest.java b/src/test/java/org/ject/support/domain/admin/service/AdminAuthServiceTest.java index d5f5d098..42a02b15 100644 --- a/src/test/java/org/ject/support/domain/admin/service/AdminAuthServiceTest.java +++ b/src/test/java/org/ject/support/domain/admin/service/AdminAuthServiceTest.java @@ -9,21 +9,21 @@ import org.ject.support.domain.member.Role; import org.ject.support.domain.member.entity.Member; import org.ject.support.domain.member.repository.MemberRepository; -import org.ject.support.external.discord.DiscordComponent; import org.ject.support.external.infrastructure.DiscordRateLimiter; +import org.ject.support.external.notification.event.AdminLoginNotificationEvent; import org.junit.jupiter.api.Test; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoSettings; import org.mockito.quality.Strictness; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.ValueOperations; import org.springframework.security.core.Authentication; -import reactor.core.publisher.Mono; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.verify; @@ -48,15 +48,15 @@ class AdminAuthServiceTest extends UnitTestSupport { @Mock DiscordRateLimiter discordRateLimiter; - @Mock - DiscordComponent discordComponent; - @Mock JwtTokenProvider jwtTokenProvider; @Mock private Authentication authentication; + @Mock + ApplicationEventPublisher applicationEventPublisher; + @Test void 관리자로그인_1차인증시_LOCKED_상태의_상태의_계정일_경우_LOCKED_ADMIN_예외_발생() { // given @@ -97,7 +97,7 @@ class AdminAuthServiceTest extends UnitTestSupport { } @Test - void 관리자_로그인_시도를_성공한_경우_관리자의_Email_반환() { + void 관리자_로그인_인증코드_요청_시_이벤트를_발행한다() { // given long memberId = 1L; String email = "test.com"; @@ -107,10 +107,10 @@ class AdminAuthServiceTest extends UnitTestSupport { .status(MemberStatus.ACTIVE) .role(Role.ADMIN) .build(); + given(adminMemberComponent.getMemberAdminByEmail(email)).willReturn(adminMember); given(redisTemplate.opsForValue()).willReturn(valueOperations); given(discordRateLimiter.tryConsume(1)).willReturn(true); - given(discordComponent.sendAdminLoginMessage(anyString(), anyString())).willReturn(Mono.empty()); // when String result = adminAuthService.sendAdminAuthCode(email); @@ -118,6 +118,8 @@ class AdminAuthServiceTest extends UnitTestSupport { // then verify(adminMemberComponent).getMemberAdminByEmail(email); verify(discordRateLimiter).tryConsume(1); + verify(applicationEventPublisher) + .publishEvent(any(AdminLoginNotificationEvent.class)); assertEquals(email, result); } From 810aef96f84258d81b9872b42837396865489ffd Mon Sep 17 00:00:00 2001 From: 023 Date: Wed, 7 Jan 2026 03:24:53 +0900 Subject: [PATCH 07/22] =?UTF-8?q?feat:=20=EB=AF=B8=EB=8B=88=20=EC=8A=A4?= =?UTF-8?q?=ED=84=B0=EB=94=94=20=EC=84=A4=EB=AA=85=20=ED=95=84=EB=93=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#408)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: MiniStudy 엔티티에 tag 필드 추가 * feat: MiniStudyResponse에 tag 필드 추가 * feat: MiniStudyQueryRepository에 tag 필드 추가 * test: MiniStudyQueryRepositoryTest에 tag 필드 관련 테스트 추가 * feat: mini_study 테이블에 tag 컬럼 추가 --- .../ject/support/domain/ministudy/dto/MiniStudyResponse.java | 2 +- .../org/ject/support/domain/ministudy/entity/MiniStudy.java | 3 +++ .../ministudy/repository/MiniStudyQueryRepositoryImpl.java | 3 ++- .../db/migration/V14__add_tag_column_to_mini_study.sql | 2 ++ .../ministudy/repository/MiniStudyQueryRepositoryTest.java | 2 ++ 5 files changed, 10 insertions(+), 2 deletions(-) create mode 100644 src/main/resources/db/migration/V14__add_tag_column_to_mini_study.sql diff --git a/src/main/java/org/ject/support/domain/ministudy/dto/MiniStudyResponse.java b/src/main/java/org/ject/support/domain/ministudy/dto/MiniStudyResponse.java index 47013de0..e595947e 100644 --- a/src/main/java/org/ject/support/domain/ministudy/dto/MiniStudyResponse.java +++ b/src/main/java/org/ject/support/domain/ministudy/dto/MiniStudyResponse.java @@ -4,7 +4,7 @@ import lombok.Builder; @Builder -public record MiniStudyResponse(Long id, String name, String linkUrl, String imageUrl, String summary) { +public record MiniStudyResponse(Long id, String name, String linkUrl, String imageUrl, String summary, String tag) { @QueryProjection public MiniStudyResponse { diff --git a/src/main/java/org/ject/support/domain/ministudy/entity/MiniStudy.java b/src/main/java/org/ject/support/domain/ministudy/entity/MiniStudy.java index 70cc6594..15926598 100644 --- a/src/main/java/org/ject/support/domain/ministudy/entity/MiniStudy.java +++ b/src/main/java/org/ject/support/domain/ministudy/entity/MiniStudy.java @@ -23,6 +23,9 @@ public class MiniStudy extends BaseTimeEntity { @Column(nullable = false) private String summary; + @Column(nullable = false) + private String tag; + @Column(length = 2083) private String linkUrl; diff --git a/src/main/java/org/ject/support/domain/ministudy/repository/MiniStudyQueryRepositoryImpl.java b/src/main/java/org/ject/support/domain/ministudy/repository/MiniStudyQueryRepositoryImpl.java index 421ae830..aab37ef2 100644 --- a/src/main/java/org/ject/support/domain/ministudy/repository/MiniStudyQueryRepositoryImpl.java +++ b/src/main/java/org/ject/support/domain/ministudy/repository/MiniStudyQueryRepositoryImpl.java @@ -28,7 +28,8 @@ public Page findMiniStudies(Pageable pageable) { miniStudy.name, miniStudy.linkUrl, miniStudy.imageUrl, - miniStudy.summary + miniStudy.summary, + miniStudy.tag )) .from(miniStudy) .orderBy(miniStudy.id.desc()) diff --git a/src/main/resources/db/migration/V14__add_tag_column_to_mini_study.sql b/src/main/resources/db/migration/V14__add_tag_column_to_mini_study.sql new file mode 100644 index 00000000..4dfc181b --- /dev/null +++ b/src/main/resources/db/migration/V14__add_tag_column_to_mini_study.sql @@ -0,0 +1,2 @@ +ALTER TABLE mini_study + ADD COLUMN tag VARCHAR(255) NOT NULL DEFAULT ''; diff --git a/src/test/java/org/ject/support/domain/ministudy/repository/MiniStudyQueryRepositoryTest.java b/src/test/java/org/ject/support/domain/ministudy/repository/MiniStudyQueryRepositoryTest.java index a55ba442..1d6c6093 100644 --- a/src/test/java/org/ject/support/domain/ministudy/repository/MiniStudyQueryRepositoryTest.java +++ b/src/test/java/org/ject/support/domain/ministudy/repository/MiniStudyQueryRepositoryTest.java @@ -47,6 +47,7 @@ void findMiniStudies() { assertThat(firstResponse.id()).isNotNull(); assertThat(firstResponse.name()).isEqualTo("미니 스터디 3"); // ID 내림차순이므로 마지막에 생성된 데이터가 첫 번째 assertThat(firstResponse.summary()).isEqualTo("summary"); + assertThat(firstResponse.tag()).isEqualTo("test-tag"); assertThat(firstResponse.linkUrl()).isEqualTo("https://test.net/ministudy3"); assertThat(firstResponse.imageUrl()).isEqualTo("https://test.net/image3.png"); @@ -60,6 +61,7 @@ private MiniStudy createMiniStudy(String name) { return MiniStudy.builder() .name(name) .summary("summary") + .tag("test-tag") .linkUrl("https://test.net/ministudy" + urlSafeName) .imageUrl("https://test.net/image" + urlSafeName + ".png") .build(); From 4f50d17ec5bcdc5d64e413d194a1d102f6a73d1f Mon Sep 17 00:00:00 2001 From: 023 Date: Wed, 7 Jan 2026 16:49:29 +0900 Subject: [PATCH 08/22] =?UTF-8?q?feat:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EC=A7=80=EC=9B=90=EC=84=9C=20=EC=A1=B0=ED=9A=8C=20API=EC=97=90?= =?UTF-8?q?=20=EA=B8=B0=EC=88=98=20=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#409)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 임시 지원서 목록 조회 API에 semesterId 필터 추가 * feat: 제출된 지원서 조회 API에 semesterId 필터 추가 * feat: 제출된 지원서 조회 API에 semesterId 파라미터 추가 * feat: 임시 지원서 조회 API에 semesterId 파라미터 추가 * feat: 지원서 조회 쿼리에 semesterId 파라미터 추가 및 필터링 로직 구현 * test: 지원서 조회 테스트에 semesterId 필터링 테스트 추가 --- .../controller/AdminTempApplyApiSpec.java | 1 + .../controller/AdminTempApplyController.java | 3 +- .../controller/SubmittedApplyApiSpec.java | 1 + .../controller/SubmittedApplyController.java | 3 +- .../admin/service/AdminTempApplyService.java | 4 +- .../admin/service/SubmittedApplyService.java | 3 +- .../repository/ApplyQueryRepository.java | 1 + .../repository/ApplyQueryRepositoryImpl.java | 16 +++++- .../service/AdminTempApplyServiceTest.java | 49 ++++++++++++++-- .../service/SubmittedApplyServiceTest.java | 50 +++++++++++++---- .../repository/ApplyQueryRepositoryTest.java | 56 ++++++++++++++++--- 11 files changed, 154 insertions(+), 33 deletions(-) diff --git a/src/main/java/org/ject/support/domain/admin/controller/AdminTempApplyApiSpec.java b/src/main/java/org/ject/support/domain/admin/controller/AdminTempApplyApiSpec.java index 101f9a2a..28ed0c15 100644 --- a/src/main/java/org/ject/support/domain/admin/controller/AdminTempApplyApiSpec.java +++ b/src/main/java/org/ject/support/domain/admin/controller/AdminTempApplyApiSpec.java @@ -37,6 +37,7 @@ public interface AdminTempApplyApiSpec { ) Page getTempApplies( @RequestParam(required = false) JobFamily jobFamily, + @RequestParam(required = false) final Long semesterId, @PageableDefault(size = 15) final Pageable pageable ); } diff --git a/src/main/java/org/ject/support/domain/admin/controller/AdminTempApplyController.java b/src/main/java/org/ject/support/domain/admin/controller/AdminTempApplyController.java index f4f43251..f757488d 100644 --- a/src/main/java/org/ject/support/domain/admin/controller/AdminTempApplyController.java +++ b/src/main/java/org/ject/support/domain/admin/controller/AdminTempApplyController.java @@ -40,7 +40,8 @@ public void deleteTempApply(@PathVariable final Long tempApplyId) { @GetMapping() public Page getTempApplies(@RequestParam(required = false) JobFamily jobFamily, + @RequestParam(required = false) final Long semesterId, @PageableDefault(size = 15) Pageable pageable) { - return adminTempApplyService.getTempApplies(jobFamily, pageable); + return adminTempApplyService.getTempApplies(jobFamily, semesterId, pageable); } } diff --git a/src/main/java/org/ject/support/domain/admin/controller/SubmittedApplyApiSpec.java b/src/main/java/org/ject/support/domain/admin/controller/SubmittedApplyApiSpec.java index b4371fba..b5660a4a 100644 --- a/src/main/java/org/ject/support/domain/admin/controller/SubmittedApplyApiSpec.java +++ b/src/main/java/org/ject/support/domain/admin/controller/SubmittedApplyApiSpec.java @@ -28,6 +28,7 @@ public interface SubmittedApplyApiSpec { summary = "제출된 지원서 목록 조회", description = "제출된 지원서들의 목록을 조회합니다.") Page findSubmittedApplies(@RequestParam(required = false) final JobFamily jobFamily, + @RequestParam(required = false) final Long semesterId, @PageableDefault(size = 15) final Pageable pageable); @Operation( diff --git a/src/main/java/org/ject/support/domain/admin/controller/SubmittedApplyController.java b/src/main/java/org/ject/support/domain/admin/controller/SubmittedApplyController.java index 6065c031..2316db9d 100644 --- a/src/main/java/org/ject/support/domain/admin/controller/SubmittedApplyController.java +++ b/src/main/java/org/ject/support/domain/admin/controller/SubmittedApplyController.java @@ -37,8 +37,9 @@ public SubmittedApplyCountResponse getSubmittedApplyCount() { @Override @GetMapping public Page findSubmittedApplies(@RequestParam(required = false) final JobFamily jobFamily, + @RequestParam(required = false) final Long semesterId, @PageableDefault(size = 15) final Pageable pageable) { - return submittedApplyService.findSubmittedApplies(jobFamily, pageable); + return submittedApplyService.findSubmittedApplies(jobFamily, semesterId, pageable); } @Override diff --git a/src/main/java/org/ject/support/domain/admin/service/AdminTempApplyService.java b/src/main/java/org/ject/support/domain/admin/service/AdminTempApplyService.java index 1285908f..21840e55 100644 --- a/src/main/java/org/ject/support/domain/admin/service/AdminTempApplyService.java +++ b/src/main/java/org/ject/support/domain/admin/service/AdminTempApplyService.java @@ -56,9 +56,9 @@ public void deleteTempApply(Long applyId) { applyRepository.delete(apply); } - public Page getTempApplies(JobFamily jobFamily, Pageable pageable) { + public Page getTempApplies(JobFamily jobFamily, Long semesterId, Pageable pageable) { Apply.Status tempSavedStatus = Apply.Status.TEMP_SAVED; - Page applyPage = applyRepository.findAppliesByStatus(jobFamily, tempSavedStatus, pageable); + Page applyPage = applyRepository.findAppliesByStatus(jobFamily, tempSavedStatus, semesterId, pageable); List content = applyPage.getContent().stream() .map(this::toTempSavedApplyResponse) diff --git a/src/main/java/org/ject/support/domain/admin/service/SubmittedApplyService.java b/src/main/java/org/ject/support/domain/admin/service/SubmittedApplyService.java index beb772ce..d3af7d93 100644 --- a/src/main/java/org/ject/support/domain/admin/service/SubmittedApplyService.java +++ b/src/main/java/org/ject/support/domain/admin/service/SubmittedApplyService.java @@ -41,8 +41,9 @@ public class SubmittedApplyService { @Transactional(readOnly = true) public Page findSubmittedApplies(final JobFamily jobFamily, + final Long semesterId, final Pageable pageable) { - Page applyPage = applyRepository.findAppliesByStatus(jobFamily, Status.SUBMITTED, pageable); + Page applyPage = applyRepository.findAppliesByStatus(jobFamily, Status.SUBMITTED, semesterId, pageable); List content = applyPage.getContent().stream() .map(this::toSubmittedApplyResponse) diff --git a/src/main/java/org/ject/support/domain/apply/repository/ApplyQueryRepository.java b/src/main/java/org/ject/support/domain/apply/repository/ApplyQueryRepository.java index b88afde3..84804910 100644 --- a/src/main/java/org/ject/support/domain/apply/repository/ApplyQueryRepository.java +++ b/src/main/java/org/ject/support/domain/apply/repository/ApplyQueryRepository.java @@ -8,5 +8,6 @@ public interface ApplyQueryRepository { Page findAppliesByStatus(final JobFamily jobFamily, final Apply.Status status, + final Long semesterId, final Pageable pageable); } diff --git a/src/main/java/org/ject/support/domain/apply/repository/ApplyQueryRepositoryImpl.java b/src/main/java/org/ject/support/domain/apply/repository/ApplyQueryRepositoryImpl.java index 206f0026..5d0b9132 100644 --- a/src/main/java/org/ject/support/domain/apply/repository/ApplyQueryRepositoryImpl.java +++ b/src/main/java/org/ject/support/domain/apply/repository/ApplyQueryRepositoryImpl.java @@ -26,7 +26,9 @@ public class ApplyQueryRepositoryImpl implements ApplyQueryRepository { @Override public Page findAppliesByStatus(final JobFamily jobFamily, - final Apply.Status status, final Pageable pageable) { + final Apply.Status status, + final Long semesterId, + final Pageable pageable) { List content = queryFactory .selectFrom(apply) @@ -37,7 +39,8 @@ public Page findAppliesByStatus(final JobFamily jobFamily, .where( apply.member.isDeleted.eq(false), eqJobFamily(jobFamily), - eqApplyStatus(status) + eqApplyStatus(status), + eqSemesterId(semesterId) ) .orderBy(apply.createdAt.desc()) .offset(pageable.getOffset()) @@ -51,7 +54,8 @@ public Page findAppliesByStatus(final JobFamily jobFamily, .where( apply.member.isDeleted.eq(false), eqJobFamily(jobFamily), - eqApplyStatus(status) + eqApplyStatus(status), + eqSemesterId(semesterId) ).fetchOne(); return PageResponse.from(content, pageable, total); @@ -67,4 +71,10 @@ private BooleanExpression eqApplyStatus(final Apply.Status status) { .map(apply.status::eq) .orElse(null); } + + private BooleanExpression eqSemesterId(final Long semesterId) { + return Optional.ofNullable(semesterId) + .map(apply.recruit.semester.id::eq) + .orElse(null); + } } diff --git a/src/test/java/org/ject/support/domain/admin/service/AdminTempApplyServiceTest.java b/src/test/java/org/ject/support/domain/admin/service/AdminTempApplyServiceTest.java index b3cee337..041c9d32 100644 --- a/src/test/java/org/ject/support/domain/admin/service/AdminTempApplyServiceTest.java +++ b/src/test/java/org/ject/support/domain/admin/service/AdminTempApplyServiceTest.java @@ -164,16 +164,17 @@ class AdminTempApplyServiceTest extends UnitTestSupport { void 임시_저장된_지원서를_조회할때_결과가_없으면_빈페이지_반환한다() { // given Pageable pageable = PageRequest.of(0, 10); + Long semesterId = null; Page applyPage = new PageImpl<>(List.of(), pageable, 0); - given(applyRepository.findAppliesByStatus(null, Apply.Status.TEMP_SAVED, pageable)) + given(applyRepository.findAppliesByStatus(null, Apply.Status.TEMP_SAVED, semesterId, pageable)) .willReturn(applyPage); // when - Page result = adminTempApplyService.getTempApplies(null, pageable); + Page result = adminTempApplyService.getTempApplies(null, semesterId, pageable); // then - verify(applyRepository).findAppliesByStatus(null, Apply.Status.TEMP_SAVED, pageable); + verify(applyRepository).findAppliesByStatus(null, Apply.Status.TEMP_SAVED, semesterId, pageable); assertThat(result.getTotalElements()).isEqualTo(0); assertThat(result.getContent()).isEmpty(); } @@ -182,6 +183,7 @@ class AdminTempApplyServiceTest extends UnitTestSupport { void 임시_저장된_지원서를_조회_한다() { // given Pageable pageable = PageRequest.of(0, 10); + Long semesterId = null; Member m1 = Member.builder().name("김1").build(); Member m2 = Member.builder().name("김2").build(); @@ -210,20 +212,55 @@ class AdminTempApplyServiceTest extends UnitTestSupport { List applies = List.of(a1, a2); Page applyPage = new PageImpl<>(applies, pageable, applies.size()); - given(applyRepository.findAppliesByStatus(null, Apply.Status.TEMP_SAVED, pageable)) + given(applyRepository.findAppliesByStatus(null, Apply.Status.TEMP_SAVED, semesterId, pageable)) .willReturn(applyPage); // applicationForm.content가 "{}" 이므로 이 호출을 stub 처리 given(string2MapSerializer.serializeAsMap("{}")).willReturn(java.util.Map.of()); Page result = - adminTempApplyService.getTempApplies(null, pageable); + adminTempApplyService.getTempApplies(null, semesterId, pageable); - verify(applyRepository).findAppliesByStatus(null,Apply.Status.TEMP_SAVED, pageable); + verify(applyRepository).findAppliesByStatus(null, Apply.Status.TEMP_SAVED, semesterId, pageable); assertThat(result.getTotalElements()).isEqualTo(2); assertThat(result.getContent()).hasSize(2); assertThat(result.getContent().get(0).applyId()).isEqualTo(1L); assertThat(result.getContent().get(1).applyId()).isEqualTo(2L); } + @Test + void 임시_저장된_지원서를_semesterId로_필터링하여_조회한다() { + // given + Pageable pageable = PageRequest.of(0, 10); + Long semesterId = 1L; + + Member member = Member.builder().name("김젝트").build(); + Semester semester = Semester.builder().name("1").build(); + Recruit recruit = Recruit.builder().semester(semester).build(); + ApplicationForm form = ApplicationForm.builder().content("{}").build(); + + Apply apply = Apply.builder() + .id(1L) + .member(member) + .recruit(recruit) + .status(Apply.Status.TEMP_SAVED) + .applicationForm(form) + .build(); + + List applies = List.of(apply); + Page applyPage = new PageImpl<>(applies, pageable, applies.size()); + + given(applyRepository.findAppliesByStatus(null, Apply.Status.TEMP_SAVED, semesterId, pageable)) + .willReturn(applyPage); + given(string2MapSerializer.serializeAsMap("{}")).willReturn(java.util.Map.of()); + + // when + Page result = adminTempApplyService.getTempApplies(null, semesterId, pageable); + + // then + verify(applyRepository).findAppliesByStatus(null, Apply.Status.TEMP_SAVED, semesterId, pageable); + assertThat(result.getTotalElements()).isEqualTo(1); + assertThat(result.getContent()).hasSize(1); + assertThat(result.getContent().get(0).applyId()).isEqualTo(1L); + } } diff --git a/src/test/java/org/ject/support/domain/admin/service/SubmittedApplyServiceTest.java b/src/test/java/org/ject/support/domain/admin/service/SubmittedApplyServiceTest.java index 8b4e2a73..6978088a 100644 --- a/src/test/java/org/ject/support/domain/admin/service/SubmittedApplyServiceTest.java +++ b/src/test/java/org/ject/support/domain/admin/service/SubmittedApplyServiceTest.java @@ -219,39 +219,41 @@ void setUp() { // given var pageable = PageRequest.of(0, 15); var jobFamily = JobFamily.BE; + Long semesterId = null; var applies = List.of(submittedApply); var page = new PageImpl<>(applies, pageable, 1L); - given(applyRepository.findAppliesByStatus(jobFamily, Status.SUBMITTED ,pageable)) + given(applyRepository.findAppliesByStatus(jobFamily, Status.SUBMITTED, semesterId, pageable)) .willReturn(page); // when - Page result = submittedApplyService.findSubmittedApplies(jobFamily, pageable); + Page result = submittedApplyService.findSubmittedApplies(jobFamily, semesterId, pageable); // then assertThat(result.getContent()).hasSize(1); assertThat(result.getTotalElements()).isEqualTo(1); - verify(applyRepository).findAppliesByStatus(jobFamily, Status.SUBMITTED, pageable); + verify(applyRepository).findAppliesByStatus(jobFamily, Status.SUBMITTED, semesterId, pageable); } @Test void 제출된_지원서_목록_조회시_JobFamily가_null이면_전체_조회() { // given var pageable = PageRequest.of(0, 15); + Long semesterId = null; var applies = List.of(submittedApply); var page = new PageImpl<>(applies, pageable, 1L); - given(applyRepository.findAppliesByStatus(null, Status.SUBMITTED, pageable)) + given(applyRepository.findAppliesByStatus(null, Status.SUBMITTED, semesterId, pageable)) .willReturn(page); // when - Page result = submittedApplyService.findSubmittedApplies(null, pageable); + Page result = submittedApplyService.findSubmittedApplies(null, semesterId, pageable); // then assertThat(result.getContent()).hasSize(1); assertThat(result.getTotalElements()).isEqualTo(1); - verify(applyRepository).findAppliesByStatus(null, Status.SUBMITTED, pageable); + verify(applyRepository).findAppliesByStatus(null, Status.SUBMITTED, semesterId, pageable); } @Test @@ -259,18 +261,19 @@ void setUp() { // given var pageable = PageRequest.of(0, 15); var jobFamily = JobFamily.BE; + Long semesterId = null; var page = new PageImpl(List.of(), pageable, 0L); - given(applyRepository.findAppliesByStatus(jobFamily, Status.SUBMITTED, pageable)) + given(applyRepository.findAppliesByStatus(jobFamily, Status.SUBMITTED, semesterId, pageable)) .willReturn(page); // when - Page result = submittedApplyService.findSubmittedApplies(jobFamily, pageable); + Page result = submittedApplyService.findSubmittedApplies(jobFamily, semesterId, pageable); // then assertThat(result.getContent()).isEmpty(); assertThat(result.getTotalElements()).isZero(); - verify(applyRepository).findAppliesByStatus(jobFamily, Status.SUBMITTED, pageable); + verify(applyRepository).findAppliesByStatus(jobFamily, Status.SUBMITTED, semesterId, pageable); } @Test @@ -278,6 +281,7 @@ void setUp() { // given var pageable = PageRequest.of(1, 10, Sort.by("createdAt").descending()); var jobFamily = JobFamily.BE; + Long semesterId = null; var member2 = Member.builder().name("김젝트2").build(); var apply2 = Apply.builder() @@ -291,18 +295,40 @@ void setUp() { var applies = List.of(submittedApply, apply2); var page = new PageImpl<>(applies, pageable, 25L); - given(applyRepository.findAppliesByStatus(jobFamily, Status.SUBMITTED, pageable)) + given(applyRepository.findAppliesByStatus(jobFamily, Status.SUBMITTED, semesterId, pageable)) .willReturn(page); // when - Page result = submittedApplyService.findSubmittedApplies(jobFamily, pageable); + Page result = submittedApplyService.findSubmittedApplies(jobFamily, semesterId, pageable); // then assertThat(result.getContent()).hasSize(2); assertThat(result.getTotalElements()).isEqualTo(25L); assertThat(result.getNumber()).isEqualTo(1); assertThat(result.getSize()).isEqualTo(10); - verify(applyRepository).findAppliesByStatus(jobFamily, Status.SUBMITTED, pageable); + verify(applyRepository).findAppliesByStatus(jobFamily, Status.SUBMITTED, semesterId, pageable); + } + + @Test + void 제출된_지원서_목록을_semesterId로_필터링하여_조회() { + // given + var pageable = PageRequest.of(0, 15); + var jobFamily = JobFamily.BE; + Long semesterId = 1L; + + var applies = List.of(submittedApply); + var page = new PageImpl<>(applies, pageable, 1L); + + given(applyRepository.findAppliesByStatus(jobFamily, Status.SUBMITTED, semesterId, pageable)) + .willReturn(page); + + // when + Page result = submittedApplyService.findSubmittedApplies(jobFamily, semesterId, pageable); + + // then + assertThat(result.getContent()).hasSize(1); + assertThat(result.getTotalElements()).isEqualTo(1); + verify(applyRepository).findAppliesByStatus(jobFamily, Status.SUBMITTED, semesterId, pageable); } @Test diff --git a/src/test/java/org/ject/support/domain/apply/repository/ApplyQueryRepositoryTest.java b/src/test/java/org/ject/support/domain/apply/repository/ApplyQueryRepositoryTest.java index 0aa3f62e..3646f99e 100644 --- a/src/test/java/org/ject/support/domain/apply/repository/ApplyQueryRepositoryTest.java +++ b/src/test/java/org/ject/support/domain/apply/repository/ApplyQueryRepositoryTest.java @@ -83,7 +83,7 @@ void setUp() { Apply.Status status = SUBMITTED; // when - Page result = applyRepository.findAppliesByStatus(BE, status, pageable); + Page result = applyRepository.findAppliesByStatus(BE, status, null, pageable); // then assertThat(result.getContent()).hasSize(2); @@ -110,7 +110,7 @@ void setUp() { Apply.Status status = SUBMITTED; // when - Page result = applyRepository.findAppliesByStatus(null, status, pageable); + Page result = applyRepository.findAppliesByStatus(null, status, null, pageable); // then assertThat(result.getContent()).hasSize(3); @@ -132,7 +132,7 @@ void setUp() { Apply.Status status = SUBMITTED; // when - Page result = applyRepository.findAppliesByStatus(BE, status, pageable); + Page result = applyRepository.findAppliesByStatus(BE, status, null, pageable); // then assertThat(result.getContent()).hasSize(1); @@ -155,7 +155,7 @@ void setUp() { Apply.Status status = SUBMITTED; // when - Page result = applyRepository.findAppliesByStatus(BE, status, pageable); + Page result = applyRepository.findAppliesByStatus(BE, status, null, pageable); // then assertThat(result.getContent()).hasSize(1); @@ -176,7 +176,7 @@ void setUp() { Apply.Status status = SUBMITTED; // when - Page result = applyRepository.findAppliesByStatus(BE, status, pageable); + Page result = applyRepository.findAppliesByStatus(BE, status, null, pageable); // then assertThat(result.getContent()).hasSize(10); @@ -192,7 +192,7 @@ void setUp() { Apply.Status status = SUBMITTED; // when - Page result = applyRepository.findAppliesByStatus(BE, status, pageable); + Page result = applyRepository.findAppliesByStatus(BE, status, null, pageable); // then @@ -221,7 +221,7 @@ void setUp() { Apply.Status status = SUBMITTED; // when - Page result = applyRepository.findAppliesByStatus(BE, status, pageable); + Page result = applyRepository.findAppliesByStatus(BE, status, null, pageable); // then assertThat(result.getContent()).hasSize(3); @@ -230,6 +230,48 @@ void setUp() { assertThat(result.getContent().get(2)).isEqualTo(apply1); } + @Test + void semesterId로_제출된_지원서_필터링_조회() { + // given + Semester semester2 = semesterRepository.save(Semester.builder() + .name("2기") + .isRecruiting(false) + .build()); + + Recruit recruit1 = recruitRepository.save(Recruit.builder() + .semester(semester) + .startDate(LocalDateTime.now().minusDays(1)) + .endDate(LocalDateTime.now().plusDays(7)) + .jobFamily(BE) + .build()); + + Recruit recruit2 = recruitRepository.save(Recruit.builder() + .semester(semester2) + .startDate(LocalDateTime.now().minusDays(1)) + .endDate(LocalDateTime.now().plusDays(7)) + .jobFamily(BE) + .build()); + + Member member1 = createMember("be1@test.com", BE); + Member member2 = createMember("be2@test.com", BE); + memberRepository.saveAll(List.of(member1, member2)); + + Apply apply1 = getApply(member1, recruit1, SUBMITTED); + Apply apply2 = getApply(member2, recruit2, SUBMITTED); + applyRepository.saveAll(List.of(apply1, apply2)); + + Pageable pageable = PageRequest.of(0, 15); + Apply.Status status = SUBMITTED; + + // when + Page result = applyRepository.findAppliesByStatus(BE, status, semester.getId(), pageable); + + // then + assertThat(result.getContent()).hasSize(1); + assertThat(result.getTotalElements()).isEqualTo(1); + assertThat(result.getContent().getFirst().getRecruit().getSemester()).isEqualTo(semester); + } + private Recruit getRecruit(JobFamily jobFamily) { return Recruit.builder() .semester(semester) From 4ffa70bfb43483b5377ce30f593cb5af35bdddd8 Mon Sep 17 00:00:00 2001 From: 023 Date: Fri, 9 Jan 2026 00:33:49 +0900 Subject: [PATCH 09/22] =?UTF-8?q?feat:=20Jectalk=20=EC=97=94=ED=8B=B0?= =?UTF-8?q?=ED=8B=B0=20=EC=8A=A4=ED=8E=99=20=EB=B3=80=EA=B2=BD=20(#412)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: ContentType enum 추가하여 콘텐츠 유형 정의 * feat: Jectalk 엔티티 및 DTO 구조 변경 * feat: JectalkQueryRepository 반환 필드 수정 * test: JectalkQueryRepositoryTest 수정으로 반환 필드 검증 업데이트 * feat: Jectalk 테이블 구조 변경 및 데이터 마이그레이션으로 인한 Flyway migration 추 * feat: Jectalk 테이블 content_type 컬럼 기본값 추가 * feat: Category 필드 및 필터링 추가 --- .../jectalk/controller/JectalkApiSpec.java | 8 +- .../jectalk/controller/JectalkController.java | 8 +- .../domain/jectalk/dto/JectalkResponse.java | 10 ++- .../domain/jectalk/entity/Jectalk.java | 23 ++++- .../domain/jectalk/enums/ContentType.java | 13 +++ .../repository/JectalkQueryRepository.java | 3 +- .../JectalkQueryRepositoryImpl.java | 29 +++++-- .../jectalk/service/JectalkService.java | 7 +- .../V15__alter_jectalk_table_structure.sql | 27 ++++++ .../JectalkQueryRepositoryTest.java | 87 ++++++++++++++++--- 10 files changed, 181 insertions(+), 34 deletions(-) create mode 100644 src/main/java/org/ject/support/domain/jectalk/enums/ContentType.java create mode 100644 src/main/resources/db/migration/V15__alter_jectalk_table_structure.sql diff --git a/src/main/java/org/ject/support/domain/jectalk/controller/JectalkApiSpec.java b/src/main/java/org/ject/support/domain/jectalk/controller/JectalkApiSpec.java index 2d4bd726..e89b16de 100644 --- a/src/main/java/org/ject/support/domain/jectalk/controller/JectalkApiSpec.java +++ b/src/main/java/org/ject/support/domain/jectalk/controller/JectalkApiSpec.java @@ -1,11 +1,14 @@ package org.ject.support.domain.jectalk.controller; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import org.ject.support.domain.jectalk.dto.JectalkResponse; +import org.ject.support.domain.project.entity.Project; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.web.PageableDefault; +import org.springframework.web.bind.annotation.RequestParam; @Tag(name = "Jectalk", description = "젝톡 API") public interface JectalkApiSpec { @@ -14,5 +17,8 @@ public interface JectalkApiSpec { summary = "젝톡 목록 조회", description = "젝톡 목록을 조회합니다." ) - Page findJectalks(@PageableDefault(size = 12) Pageable pageable); + Page findJectalks( + @PageableDefault(size = 12) Pageable pageable, + @Parameter(description = "기수 (SEMESTER_1, SEMESTER_2, SEMESTER_3)", example = "SEMESTER_1") + @RequestParam(required = false) Project.Category category); } diff --git a/src/main/java/org/ject/support/domain/jectalk/controller/JectalkController.java b/src/main/java/org/ject/support/domain/jectalk/controller/JectalkController.java index d607fe5f..fc6171ae 100644 --- a/src/main/java/org/ject/support/domain/jectalk/controller/JectalkController.java +++ b/src/main/java/org/ject/support/domain/jectalk/controller/JectalkController.java @@ -3,11 +3,13 @@ import lombok.RequiredArgsConstructor; import org.ject.support.domain.jectalk.dto.JectalkResponse; import org.ject.support.domain.jectalk.service.JectalkService; +import org.ject.support.domain.project.entity.Project; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.web.PageableDefault; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @RestController @@ -19,7 +21,9 @@ public class JectalkController implements JectalkApiSpec { @Override @GetMapping - public Page findJectalks(@PageableDefault(size = 12) Pageable pageable) { - return jectalkService.findJectalks(pageable); + public Page findJectalks( + @PageableDefault(size = 12) Pageable pageable, + @RequestParam(required = false) Project.Category category) { + return jectalkService.findJectalks(pageable, category); } } diff --git a/src/main/java/org/ject/support/domain/jectalk/dto/JectalkResponse.java b/src/main/java/org/ject/support/domain/jectalk/dto/JectalkResponse.java index 111651b2..b5f39f1f 100644 --- a/src/main/java/org/ject/support/domain/jectalk/dto/JectalkResponse.java +++ b/src/main/java/org/ject/support/domain/jectalk/dto/JectalkResponse.java @@ -2,9 +2,17 @@ import com.querydsl.core.annotations.QueryProjection; import lombok.Builder; +import org.ject.support.domain.jectalk.enums.ContentType; @Builder -public record JectalkResponse(Long id, String name, String youtubeUrl, String imageUrl, String summary) { +public record JectalkResponse( + Long id, + String title, + String description, + String contentUrl, + ContentType contentType, + String thumbnailUrl, + String summary) { @QueryProjection public JectalkResponse { diff --git a/src/main/java/org/ject/support/domain/jectalk/entity/Jectalk.java b/src/main/java/org/ject/support/domain/jectalk/entity/Jectalk.java index 9067ede1..dd60f2e6 100644 --- a/src/main/java/org/ject/support/domain/jectalk/entity/Jectalk.java +++ b/src/main/java/org/ject/support/domain/jectalk/entity/Jectalk.java @@ -4,10 +4,14 @@ import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Builder; +import lombok.Getter; import lombok.NoArgsConstructor; import org.ject.support.domain.base.BaseTimeEntity; +import org.ject.support.domain.jectalk.enums.ContentType; +import org.ject.support.domain.project.entity.Project.Category; @Entity +@Getter @Builder @AllArgsConstructor(access = AccessLevel.PRIVATE) @NoArgsConstructor(access = AccessLevel.PROTECTED) @@ -18,14 +22,25 @@ public class Jectalk extends BaseTimeEntity { private Long id; @Column(length = 50, nullable = false) - private String name; + private String title; @Column(nullable = false) - private String summary; + private String description; + + @Column(nullable = false) + @Enumerated(EnumType.STRING) + private ContentType contentType; @Column(length = 2083) - private String youtubeUrl; + private String contentUrl; @Column(length = 2083) - private String imageUrl; + private String thumbnailUrl; + + @Column(nullable = false) + private String author; + + @Enumerated(EnumType.STRING) + @Column(columnDefinition = "varchar(30)", nullable = false) + private Category category; } diff --git a/src/main/java/org/ject/support/domain/jectalk/enums/ContentType.java b/src/main/java/org/ject/support/domain/jectalk/enums/ContentType.java new file mode 100644 index 00000000..f49333f1 --- /dev/null +++ b/src/main/java/org/ject/support/domain/jectalk/enums/ContentType.java @@ -0,0 +1,13 @@ +package org.ject.support.domain.jectalk.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum ContentType { + YOUTUBE("YouTube"), + ; + + private final String name; +} diff --git a/src/main/java/org/ject/support/domain/jectalk/repository/JectalkQueryRepository.java b/src/main/java/org/ject/support/domain/jectalk/repository/JectalkQueryRepository.java index ccf75a8e..91af7254 100644 --- a/src/main/java/org/ject/support/domain/jectalk/repository/JectalkQueryRepository.java +++ b/src/main/java/org/ject/support/domain/jectalk/repository/JectalkQueryRepository.java @@ -1,9 +1,10 @@ package org.ject.support.domain.jectalk.repository; import org.ject.support.domain.jectalk.dto.JectalkResponse; +import org.ject.support.domain.project.entity.Project; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; public interface JectalkQueryRepository { - Page findJectalks(Pageable pageable); + Page findJectalks(Pageable pageable, Project.Category category); } diff --git a/src/main/java/org/ject/support/domain/jectalk/repository/JectalkQueryRepositoryImpl.java b/src/main/java/org/ject/support/domain/jectalk/repository/JectalkQueryRepositoryImpl.java index 962a678e..cde1106b 100644 --- a/src/main/java/org/ject/support/domain/jectalk/repository/JectalkQueryRepositoryImpl.java +++ b/src/main/java/org/ject/support/domain/jectalk/repository/JectalkQueryRepositoryImpl.java @@ -1,18 +1,21 @@ package org.ject.support.domain.jectalk.repository; +import static org.ject.support.domain.jectalk.entity.QJectalk.jectalk; + +import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.jpa.impl.JPAQuery; import com.querydsl.jpa.impl.JPAQueryFactory; import java.util.List; +import java.util.Optional; import lombok.RequiredArgsConstructor; import org.ject.support.common.data.PageResponse; import org.ject.support.domain.jectalk.dto.JectalkResponse; import org.ject.support.domain.jectalk.dto.QJectalkResponse; +import org.ject.support.domain.project.entity.Project; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Repository; -import static org.ject.support.domain.jectalk.entity.QJectalk.jectalk; - @Repository @RequiredArgsConstructor public class JectalkQueryRepositoryImpl implements JectalkQueryRepository { @@ -20,16 +23,19 @@ public class JectalkQueryRepositoryImpl implements JectalkQueryRepository { private final JPAQueryFactory queryFactory; @Override - public Page findJectalks(Pageable pageable) { + public Page findJectalks(Pageable pageable, Project.Category category) { List content = queryFactory .select(new QJectalkResponse( jectalk.id, - jectalk.name, - jectalk.youtubeUrl, - jectalk.imageUrl, - jectalk.summary + jectalk.title, + jectalk.description, + jectalk.contentUrl, + jectalk.contentType, + jectalk.thumbnailUrl, + jectalk.author )) .from(jectalk) + .where(categoryEq(category)) .orderBy(jectalk.id.desc()) .offset(pageable.getOffset()) .limit(pageable.getPageSize()) @@ -37,8 +43,15 @@ public Page findJectalks(Pageable pageable) { JPAQuery countQuery = queryFactory .select(jectalk.count()) - .from(jectalk); + .from(jectalk) + .where(categoryEq(category)); return PageResponse.from(content, pageable, countQuery.fetchFirst()); } + + private BooleanExpression categoryEq(Project.Category category) { + return Optional.ofNullable(category) + .map(jectalk.category::eq) + .orElse(null); + } } diff --git a/src/main/java/org/ject/support/domain/jectalk/service/JectalkService.java b/src/main/java/org/ject/support/domain/jectalk/service/JectalkService.java index 2e0d833c..70db546c 100644 --- a/src/main/java/org/ject/support/domain/jectalk/service/JectalkService.java +++ b/src/main/java/org/ject/support/domain/jectalk/service/JectalkService.java @@ -3,6 +3,7 @@ import lombok.RequiredArgsConstructor; import org.ject.support.domain.jectalk.dto.JectalkResponse; import org.ject.support.domain.jectalk.repository.JectalkRepository; +import org.ject.support.domain.project.entity.Project; import org.springframework.cache.annotation.Cacheable; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -15,9 +16,9 @@ public class JectalkService { private final JectalkRepository jectalkRepository; - @Cacheable(value = "jectalk", key = "#pageable.pageNumber + ':' + #pageable.pageSize") + @Cacheable(value = "jectalk", key = "#pageable.pageNumber + ':' + #pageable.pageSize + ':' + (#category != null ? #category.name() : 'ALL')") @Transactional(readOnly = true) - public Page findJectalks(Pageable pageable) { - return jectalkRepository.findJectalks(pageable); + public Page findJectalks(Pageable pageable, Project.Category category) { + return jectalkRepository.findJectalks(pageable, category); } } diff --git a/src/main/resources/db/migration/V15__alter_jectalk_table_structure.sql b/src/main/resources/db/migration/V15__alter_jectalk_table_structure.sql new file mode 100644 index 00000000..8f1a5a63 --- /dev/null +++ b/src/main/resources/db/migration/V15__alter_jectalk_table_structure.sql @@ -0,0 +1,27 @@ +-- Jectalk 테이블 구조 변경 +-- 기존: name, summary, youtube_url, image_url +-- 변경: title, description, content_type, content_url, thumbnail_url, author, category + +-- author 컬럼 추가 +ALTER TABLE jectalk ADD COLUMN author VARCHAR(255) NOT NULL DEFAULT ''; + +-- 기존 summary 데이터를 author로 마이그레이션 +UPDATE jectalk SET author = summary; + +-- content_type 컬럼 추가 +ALTER TABLE jectalk ADD COLUMN content_type VARCHAR(50) NOT NULL DEFAULT 'YOUTUBE'; + +-- category 컬럼 추가 +ALTER TABLE jectalk ADD COLUMN category VARCHAR(30) NOT NULL DEFAULT 'SEMESTER_1'; + +-- 컬럼명 변경 +ALTER TABLE jectalk CHANGE COLUMN name title VARCHAR(50) NOT NULL; +ALTER TABLE jectalk CHANGE COLUMN summary description VARCHAR(255) NOT NULL; +ALTER TABLE jectalk CHANGE COLUMN youtube_url content_url VARCHAR(2083) NULL; +ALTER TABLE jectalk CHANGE COLUMN image_url thumbnail_url VARCHAR(2083) NULL; + +-- DEFAULT 제거 +ALTER TABLE jectalk MODIFY COLUMN author VARCHAR(255) NOT NULL; +ALTER TABLE jectalk MODIFY COLUMN content_type VARCHAR(50) NOT NULL; +ALTER TABLE jectalk MODIFY COLUMN category VARCHAR(30) NOT NULL; + diff --git a/src/test/java/org/ject/support/domain/jectalk/repository/JectalkQueryRepositoryTest.java b/src/test/java/org/ject/support/domain/jectalk/repository/JectalkQueryRepositoryTest.java index 2b5ec0ed..30b1671a 100644 --- a/src/test/java/org/ject/support/domain/jectalk/repository/JectalkQueryRepositoryTest.java +++ b/src/test/java/org/ject/support/domain/jectalk/repository/JectalkQueryRepositoryTest.java @@ -3,6 +3,7 @@ import java.util.List; import org.ject.support.domain.jectalk.dto.JectalkResponse; import org.ject.support.domain.jectalk.entity.Jectalk; +import org.ject.support.domain.project.entity.Project; import org.ject.support.testconfig.IntegrationTest; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -30,7 +31,7 @@ void findJectalks() { jectalkRepository.saveAll(List.of(jectalk1, jectalk2, jectalk3)); // when - Page result = jectalkRepository.findJectalks(PageRequest.of(0, 2)); + Page result = jectalkRepository.findJectalks (PageRequest.of(0, 2), null); // then assertThat(result).isNotNull(); @@ -43,30 +44,88 @@ void findJectalks() { assertThat(responses).hasSize(2); // 현재 페이지의 데이터 개수 responses.forEach(jectalkResponse -> { assertThat(jectalkResponse.id()).isNotNull(); - assertThat(jectalkResponse.name()).isNotNull(); - assertThat(jectalkResponse.imageUrl()).isNotNull(); - assertThat(jectalkResponse.youtubeUrl()).isNotNull(); + assertThat(jectalkResponse.title()).isNotNull(); + assertThat(jectalkResponse.thumbnailUrl()).isNotNull(); + assertThat(jectalkResponse.contentUrl()).isNotNull(); + assertThat(jectalkResponse.description()).isNotNull(); + assertThat(jectalkResponse.contentType()).isNotNull(); assertThat(jectalkResponse.summary()).isNotNull(); }); - JectalkResponse firstResponse = responses.get(0); - assertThat(firstResponse.name()).isEqualTo("젝톡 3"); // ID 내림차순이므로 마지막에 생성된 데이터가 첫 번째 - assertThat(firstResponse.summary()).isEqualTo("summary"); - assertThat(firstResponse.youtubeUrl()).isEqualTo("https://youtube.com/jectalk3"); - assertThat(firstResponse.imageUrl()).isEqualTo("https://image.com/jectalk3.png"); + assertThat(firstResponse.title()).isEqualTo("젝톡 3"); // ID 내림차순이므로 마지막에 생성된 데이터가 첫 번째 + assertThat(firstResponse.description()).isEqualTo("description"); + assertThat(firstResponse.contentUrl()).isEqualTo("https://youtube.com/jectalk3"); + assertThat(firstResponse.thumbnailUrl()).isEqualTo("https://image.com/jectalk3.png"); + assertThat(firstResponse.summary()).isEqualTo("author"); + // 두 번째 페이지 조회 - Page secondPage = jectalkRepository.findJectalks(PageRequest.of(1, 2)); + Page secondPage = jectalkRepository.findJectalks(PageRequest.of(1, 2), null); assertThat(secondPage.getContent()).hasSize(1); // 마지막 페이지는 1개의 데이터만 존재 } + @Test + @DisplayName("젝톡 목록 조회 - 카테고리 필터링") + void findJectalksByCategory() { + // given + Jectalk jectalk1 = createJectalkWithCategory("젝톡 1기-1", Project.Category.SEMESTER_1); + Jectalk jectalk2 = createJectalkWithCategory("젝톡 1기-2", Project.Category.SEMESTER_1); + Jectalk jectalk3 = createJectalkWithCategory("젝톡 2기-1", Project.Category.SEMESTER_2); + Jectalk jectalk4 = createJectalkWithCategory("젝톡 2기-2", Project.Category.SEMESTER_2); + jectalkRepository.saveAll(List.of(jectalk1, jectalk2, jectalk3, jectalk4)); + + // when - 1기 조회 + Page result1 = jectalkRepository.findJectalks(PageRequest.of(0, 10), Project.Category.SEMESTER_1); + + // then + assertThat(result1.getTotalElements()).isEqualTo(2); + + // when - 2기 조회 + Page result2 = jectalkRepository.findJectalks(PageRequest.of(0, 10), Project.Category.SEMESTER_2); + + // then + assertThat(result2.getTotalElements()).isEqualTo(2); + } + + @Test + @DisplayName("젝톡 목록 조회 - 카테고리 미지정 시 전체 조회") + void findJectalksWithoutCategory() { + // given + Jectalk jectalk1 = createJectalkWithCategory("젝톡 1기", Project.Category.SEMESTER_1); + Jectalk jectalk2 = createJectalkWithCategory("젝톡 2기", Project.Category.SEMESTER_2); + Jectalk jectalk3 = createJectalkWithCategory("젝톡 3기", Project.Category.SEMESTER_3); + jectalkRepository.saveAll(List.of(jectalk1, jectalk2, jectalk3)); + + // when - category=null로 전체 조회 + Page result = jectalkRepository.findJectalks(PageRequest.of(0, 10), null); + + // then - 모든 기수의 젝톡이 조회됨 + assertThat(result.getTotalElements()).isEqualTo(3); + } + private Jectalk createJectalk(String name) { String urlSafeName = "jectalk" + name.replaceAll("[젝톡 ]", ""); return Jectalk.builder() - .name(name) - .summary("summary") - .youtubeUrl("https://youtube.com/" + urlSafeName) - .imageUrl("https://image.com/" + urlSafeName + ".png") + .title(name) + .description("description") + .contentType(org.ject.support.domain.jectalk.enums.ContentType.YOUTUBE) + .contentUrl("https://youtube.com/" + urlSafeName) + .thumbnailUrl("https://image.com/" + urlSafeName + ".png") + .author("author") + .category(Project.Category.SEMESTER_1) + .build(); + } + + private Jectalk createJectalkWithCategory(String name, Project.Category category) { + String urlSafeName = "jectalk" + name.replaceAll("[젝톡 기\\-]", ""); + return Jectalk.builder() + .title(name) + .description("description") + .contentType(org.ject.support.domain.jectalk.enums.ContentType.YOUTUBE) + .contentUrl("https://youtube.com/" + urlSafeName) + .thumbnailUrl("https://image.com/" + urlSafeName + ".png") + .author("author") + .category(category) .build(); } } From 88e4818e112f1195dcc6b71e064002cd41934e06 Mon Sep 17 00:00:00 2001 From: fad Date: Sun, 11 Jan 2026 19:07:34 +0900 Subject: [PATCH 10/22] =?UTF-8?q?feat:=20n8n=EC=97=90=20=EC=A7=80=EC=9B=90?= =?UTF-8?q?=EC=84=9C=20=EC=A0=9C=EC=B6=9C=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=B0=9C=ED=96=89=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?(#415)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/apply/service/ApplyService.java | 9 ++- .../external/n8n/client/N8nClient.java | 47 +++++++++++++ .../n8n/event/ApplicationSubmittedEvent.java | 5 ++ .../N8nApplicationSubmittedListener.java | 26 ++++++++ .../external/n8n/service/N8nApplyService.java | 66 +++++++++++++++++++ src/main/resources/application-test.yml | 5 ++ .../apply/service/ApplyServiceTest.java | 48 +++++++++----- 7 files changed, 187 insertions(+), 19 deletions(-) create mode 100644 src/main/java/org/ject/support/external/n8n/client/N8nClient.java create mode 100644 src/main/java/org/ject/support/external/n8n/event/ApplicationSubmittedEvent.java create mode 100644 src/main/java/org/ject/support/external/n8n/listener/N8nApplicationSubmittedListener.java create mode 100644 src/main/java/org/ject/support/external/n8n/service/N8nApplyService.java diff --git a/src/main/java/org/ject/support/domain/apply/service/ApplyService.java b/src/main/java/org/ject/support/domain/apply/service/ApplyService.java index 7c00fc10..9008aaac 100644 --- a/src/main/java/org/ject/support/domain/apply/service/ApplyService.java +++ b/src/main/java/org/ject/support/domain/apply/service/ApplyService.java @@ -27,6 +27,8 @@ import org.ject.support.domain.recruit.exception.RecruitErrorCode; import org.ject.support.domain.recruit.exception.RecruitException; import org.ject.support.domain.recruit.repository.RecruitRepository; +import org.ject.support.external.n8n.event.ApplicationSubmittedEvent; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -34,7 +36,6 @@ import java.time.LocalDateTime; import java.util.List; import java.util.Map; -import java.util.Optional; import static org.ject.support.domain.apply.domain.Apply.Status.JOINED; import static org.ject.support.domain.apply.domain.Apply.Status.SUBMITTED; @@ -53,6 +54,8 @@ public class ApplyService implements ApplyUsecase { private final Map2JsonSerializer map2JsonSerializer; private final String2MapSerializer string2MapSerializer; + private final ApplicationEventPublisher applicationEventPublisher; + @Override @PeriodAccessible(permitAllJob = true) @Transactional(readOnly = true) @@ -167,6 +170,10 @@ public void submitApplication(Long memberId, // 5. Apply 엔티티에 제출 위임 (검증 및 상태 변경 포함) apply.submit(applicationForm); + + // 6. n8n에 지원 완료 이벤트 발행 + applicationEventPublisher + .publishEvent(new ApplicationSubmittedEvent(apply.getId())); } @Override diff --git a/src/main/java/org/ject/support/external/n8n/client/N8nClient.java b/src/main/java/org/ject/support/external/n8n/client/N8nClient.java new file mode 100644 index 00000000..321c3f7c --- /dev/null +++ b/src/main/java/org/ject/support/external/n8n/client/N8nClient.java @@ -0,0 +1,47 @@ +package org.ject.support.external.n8n.client; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.ject.support.domain.admin.dto.SubmittedApplyDetailResponse; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; + +@Slf4j +@Component +@RequiredArgsConstructor +public class N8nClient { + + private final WebClient webClient; + + @Value("${n8n.secret-key}") + private String secretKey; + + @Value("${n8n.webhook.application-submit}") + private String applicationSubmitWebhook; + + public void send(SubmittedApplyDetailResponse payload) { + webClient.post() + .uri(applicationSubmitWebhook) + .header("Apply-Webhook-Secret", secretKey) + .bodyValue(payload) + .retrieve() + .toBodilessEntity() + .doOnSuccess(this::logSuccess) + .doOnError(this::logError) + .block(); + } + + private void logSuccess(ResponseEntity response) { + log.info("N8n message sent successfully (status={})", response.getStatusCode()); + } + + private void logError(Throwable e) { + log.error( + "N8n message send failed apply: {} - {}", + e.getClass().getSimpleName(), + e.getMessage() + ); + } +} diff --git a/src/main/java/org/ject/support/external/n8n/event/ApplicationSubmittedEvent.java b/src/main/java/org/ject/support/external/n8n/event/ApplicationSubmittedEvent.java new file mode 100644 index 00000000..49e19385 --- /dev/null +++ b/src/main/java/org/ject/support/external/n8n/event/ApplicationSubmittedEvent.java @@ -0,0 +1,5 @@ +package org.ject.support.external.n8n.event; + +public record ApplicationSubmittedEvent( + Long applyId +) {} diff --git a/src/main/java/org/ject/support/external/n8n/listener/N8nApplicationSubmittedListener.java b/src/main/java/org/ject/support/external/n8n/listener/N8nApplicationSubmittedListener.java new file mode 100644 index 00000000..0752f6e4 --- /dev/null +++ b/src/main/java/org/ject/support/external/n8n/listener/N8nApplicationSubmittedListener.java @@ -0,0 +1,26 @@ +package org.ject.support.external.n8n.listener; + +import lombok.RequiredArgsConstructor; +import org.ject.support.external.n8n.event.ApplicationSubmittedEvent; +import org.ject.support.external.n8n.service.N8nApplyService; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +/** + * Admin Page가 생기기 전 까지 n8n으로 지원서 제출 알림을 보냄 + * - Admin Page가 생기면 해당 기능은 제거될 예정 + */ +@Component +@RequiredArgsConstructor +public class N8nApplicationSubmittedListener { + + private final N8nApplyService n8nApplyService; + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handle(ApplicationSubmittedEvent event) { + n8nApplyService.sendToN8n(event.applyId()); + } +} diff --git a/src/main/java/org/ject/support/external/n8n/service/N8nApplyService.java b/src/main/java/org/ject/support/external/n8n/service/N8nApplyService.java new file mode 100644 index 00000000..3d6a2e86 --- /dev/null +++ b/src/main/java/org/ject/support/external/n8n/service/N8nApplyService.java @@ -0,0 +1,66 @@ +package org.ject.support.external.n8n.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.ject.support.common.util.String2MapSerializer; +import org.ject.support.domain.admin.dto.SubmittedApplyDetailResponse; +import org.ject.support.domain.apply.domain.ApplicationForm; +import org.ject.support.domain.apply.domain.Apply; +import org.ject.support.domain.apply.dto.ApplyPortfolioDto; +import org.ject.support.domain.apply.exception.ApplyErrorCode; +import org.ject.support.domain.apply.exception.ApplyException; +import org.ject.support.domain.apply.repository.ApplyRepository; +import org.ject.support.external.n8n.client.N8nClient; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class N8nApplyService { + + private final ApplyRepository applyRepository; + private final String2MapSerializer string2MapSerializer; + private final N8nClient n8nClient; + + @Transactional(readOnly = true) + public void sendToN8n(final Long applyId) { + // SubmittedApplyDetailResponse n8n 전송용으로 임시 재사용 + SubmittedApplyDetailResponse response = applyRepository.findByIdAndStatusWithMember(applyId, Apply.Status.SUBMITTED) + .map(this::toSubmittedApplyDetailResponse) + .orElseThrow(() -> new ApplyException(ApplyErrorCode.NOT_FOUND_APPLY)); + try { + n8nClient.send(response); + } catch (Exception e) { + log.error("Failed to send apply {} to n8n", applyId, e); + } + + } + + private SubmittedApplyDetailResponse toSubmittedApplyDetailResponse(final Apply apply) { + ApplicationForm submittedApplicationForm = apply.getApplicationForm(); + Map content = extractContent(submittedApplicationForm); + List portfolios = extractPortfolios(submittedApplicationForm); + return SubmittedApplyDetailResponse.from(apply, content, portfolios); + } + + private Map extractContent(final ApplicationForm applicationForm) { + return Optional.ofNullable(applicationForm) + .map(ApplicationForm::getContent) + .map(string2MapSerializer::serializeAsMap) + .orElse(Map.of()); + } + + private List extractPortfolios(final ApplicationForm applicationForm) { + return Optional.ofNullable(applicationForm) + .map(ApplicationForm::getPortfolios) + .orElse(List.of()) + .stream() + .map(ApplyPortfolioDto::from) + .toList(); + } +} diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml index a78911b1..d0376df3 100644 --- a/src/main/resources/application-test.yml +++ b/src/main/resources/application-test.yml @@ -69,3 +69,8 @@ notification: webhook: admin-login: dummy-url-for-test supporter-token-issue: dummy-url-for-test + +n8n: + secret-key: n8n-test-secret-key + webhook: + application-submit: http://localhost:5678/webhook/application-submit diff --git a/src/test/java/org/ject/support/domain/apply/service/ApplyServiceTest.java b/src/test/java/org/ject/support/domain/apply/service/ApplyServiceTest.java index 152aa5a4..dac07094 100644 --- a/src/test/java/org/ject/support/domain/apply/service/ApplyServiceTest.java +++ b/src/test/java/org/ject/support/domain/apply/service/ApplyServiceTest.java @@ -1,23 +1,5 @@ package org.ject.support.domain.apply.service; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.ject.support.domain.apply.domain.Apply.Status.JOINED; -import static org.ject.support.domain.apply.domain.Apply.Status.SUBMITTED; -import static org.ject.support.domain.apply.domain.Apply.Status.TEMP_SAVED; -import static org.ject.support.domain.member.JobFamily.BE; -import static org.ject.support.domain.member.JobFamily.PD; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import java.time.LocalDateTime; -import java.util.List; -import java.util.Map; -import java.util.Optional; import org.ject.support.base.UnitTestSupport; import org.ject.support.common.util.Map2JsonSerializer; import org.ject.support.common.util.String2MapSerializer; @@ -45,10 +27,31 @@ import org.ject.support.domain.recruit.domain.Semester; import org.ject.support.domain.recruit.exception.QuestionException; import org.ject.support.domain.recruit.repository.RecruitRepository; +import org.ject.support.external.n8n.event.ApplicationSubmittedEvent; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; +import org.springframework.context.ApplicationEventPublisher; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.ject.support.domain.apply.domain.Apply.Status.JOINED; +import static org.ject.support.domain.apply.domain.Apply.Status.SUBMITTED; +import static org.ject.support.domain.apply.domain.Apply.Status.TEMP_SAVED; +import static org.ject.support.domain.member.JobFamily.BE; +import static org.ject.support.domain.member.JobFamily.PD; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; class ApplyServiceTest extends UnitTestSupport { @@ -73,6 +76,9 @@ class ApplyServiceTest extends UnitTestSupport { @Mock String2MapSerializer string2MapSerializer; + @Mock + ApplicationEventPublisher applicationEventPublisher; + @Test void 지원서_제출_성공() { // given @@ -113,6 +119,10 @@ class ApplyServiceTest extends UnitTestSupport { // 3. apply의 상태가 SUBMITTED로 변경되었는지 확인 assertThat(apply.getStatus()).isEqualTo(SUBMITTED); + + // 4. AdminLoginNotificationEvent 이벤트가 발행되었는지 확인 + verify(applicationEventPublisher) + .publishEvent(any(ApplicationSubmittedEvent.class)); } @Test @@ -164,6 +174,8 @@ class ApplyServiceTest extends UnitTestSupport { // then assertThat(apply.getStatus()).isEqualTo(SUBMITTED); assertThat(applicationForm.getPortfolios()).hasSize(1); + verify(applicationEventPublisher) + .publishEvent(any(ApplicationSubmittedEvent.class)); } @Test From 49db8493b29a35aa4a8373fb75c165305b427f05 Mon Sep 17 00:00:00 2001 From: fad Date: Wed, 14 Jan 2026 20:05:46 +0900 Subject: [PATCH 11/22] =?UTF-8?q?feat:=20=EC=A0=9C=EC=B6=9C=EB=90=9C=20?= =?UTF-8?q?=EC=A7=80=EC=9B=90=EC=84=9C=20=EC=9D=91=EB=8B=B5=EC=97=90=20?= =?UTF-8?q?=EC=A7=80=EC=97=AD,=20=EA=B2=BD=EB=A0=A5=20=EC=82=AC=ED=95=AD,?= =?UTF-8?q?=20=EA=B2=BD=ED=97=98=20=EA=B8=B0=EA=B0=84=20=EB=B0=8F=20?= =?UTF-8?q?=EA=B4=80=EC=8B=AC=20=EB=B6=84=EC=95=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/dto/SubmittedApplyResponse.java | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/ject/support/domain/admin/dto/SubmittedApplyResponse.java b/src/main/java/org/ject/support/domain/admin/dto/SubmittedApplyResponse.java index 8916e098..7e88fc3d 100644 --- a/src/main/java/org/ject/support/domain/admin/dto/SubmittedApplyResponse.java +++ b/src/main/java/org/ject/support/domain/admin/dto/SubmittedApplyResponse.java @@ -1,12 +1,17 @@ package org.ject.support.domain.admin.dto; -import java.util.List; -import java.util.Map; -import java.util.Optional; import org.ject.support.domain.apply.domain.ApplicationForm; import org.ject.support.domain.apply.domain.Apply; import org.ject.support.domain.apply.dto.ApplyPortfolioDto; +import org.ject.support.domain.member.CareerDetails; +import org.ject.support.domain.member.ExperiencePeriod; import org.ject.support.domain.member.JobFamily; +import org.ject.support.domain.member.Region; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; public record SubmittedApplyResponse( Long applyId, @@ -14,6 +19,10 @@ public record SubmittedApplyResponse( String phoneNumber, String email, JobFamily jobFamily, + String region, + String careerDetails, + String experiencePeriod, + List interestedDomains, boolean hasPortfolio, ApplicationFormResponse applicationFormResponse ) { @@ -26,6 +35,16 @@ public static SubmittedApplyResponse from(Apply apply, apply.getMember().getPhoneNumber(), apply.getMember().getEmail(), apply.getMember().getJobFamily(), + Optional.ofNullable(apply.getMember().getRegion()) + .map(Region::getDescription) + .orElse(""), + Optional.ofNullable(apply.getMember().getCareerDetails()) + .map(CareerDetails::getDescription) + .orElse(""), + Optional.ofNullable(apply.getMember().getExperiencePeriod()) + .map(ExperiencePeriod::getDescription) + .orElse(""), + new ArrayList<>(apply.getMember().getInterestedDomains()), Optional.ofNullable(apply.getApplicationForm()) .map(ApplicationForm::getPortfolios) .map(portfolioList -> !portfolioList.isEmpty()) From 36765d59f7dff04fcbd93e318ade6a0dd5cc1a5a Mon Sep 17 00:00:00 2001 From: fad Date: Wed, 14 Jan 2026 20:13:12 +0900 Subject: [PATCH 12/22] =?UTF-8?q?feat:=20=EC=A7=80=EC=9B=90=EC=84=9C=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=EC=97=90=20=EA=B4=80=EC=8B=AC=20=EB=B6=84?= =?UTF-8?q?=EC=95=BC=EA=B0=80=20null=EC=9D=BC=20=EA=B2=BD=EC=9A=B0=20?= =?UTF-8?q?=EB=B9=88=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=EB=A1=9C=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ject/support/domain/admin/dto/SubmittedApplyResponse.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/ject/support/domain/admin/dto/SubmittedApplyResponse.java b/src/main/java/org/ject/support/domain/admin/dto/SubmittedApplyResponse.java index 7e88fc3d..15c2225b 100644 --- a/src/main/java/org/ject/support/domain/admin/dto/SubmittedApplyResponse.java +++ b/src/main/java/org/ject/support/domain/admin/dto/SubmittedApplyResponse.java @@ -44,7 +44,9 @@ public static SubmittedApplyResponse from(Apply apply, Optional.ofNullable(apply.getMember().getExperiencePeriod()) .map(ExperiencePeriod::getDescription) .orElse(""), - new ArrayList<>(apply.getMember().getInterestedDomains()), + new ArrayList<>(Optional.ofNullable(apply.getMember().getInterestedDomains()) + .orElse(List.of()) + ), Optional.ofNullable(apply.getApplicationForm()) .map(ApplicationForm::getPortfolios) .map(portfolioList -> !portfolioList.isEmpty()) From 439074f3da2269d2deb6b2834af116ea814dec0d Mon Sep 17 00:00:00 2001 From: 023 Date: Wed, 14 Jan 2026 20:59:50 +0900 Subject: [PATCH 13/22] =?UTF-8?q?feat:=20=EC=A7=88=EB=AC=B8=EC=97=90=20sub?= =?UTF-8?q?title=20=EC=BB=AC=EB=9F=BC=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20?= =?UTF-8?q?=EC=B4=88=EA=B8=B0=ED=99=94=20=EC=BF=BC=EB=A6=AC=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1=20(#421)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ject/support/domain/recruit/domain/Question.java | 3 +++ .../support/domain/recruit/dto/QuestionResponse.java | 1 + .../repository/QuestionQueryRepositoryImpl.java | 1 + .../db/migration/V16__add_subtitle_to_question.sql | 12 ++++++++++++ 4 files changed, 17 insertions(+) create mode 100644 src/main/resources/db/migration/V16__add_subtitle_to_question.sql diff --git a/src/main/java/org/ject/support/domain/recruit/domain/Question.java b/src/main/java/org/ject/support/domain/recruit/domain/Question.java index f9d7ddf0..3b9fcabf 100644 --- a/src/main/java/org/ject/support/domain/recruit/domain/Question.java +++ b/src/main/java/org/ject/support/domain/recruit/domain/Question.java @@ -47,6 +47,9 @@ public class Question extends BaseTimeEntity { @Column(length = 100, nullable = false) private String title; + @Column(length = 200) + private String subtitle; + @Column(length = 30, nullable = false) private String label; diff --git a/src/main/java/org/ject/support/domain/recruit/dto/QuestionResponse.java b/src/main/java/org/ject/support/domain/recruit/dto/QuestionResponse.java index 6b1d4c01..396860a4 100644 --- a/src/main/java/org/ject/support/domain/recruit/dto/QuestionResponse.java +++ b/src/main/java/org/ject/support/domain/recruit/dto/QuestionResponse.java @@ -13,6 +13,7 @@ public record QuestionResponse(Long id, InputType inputType, boolean isRequired, String title, + String subtitle, String label, List selectOptions, String inputHint, diff --git a/src/main/java/org/ject/support/domain/recruit/repository/QuestionQueryRepositoryImpl.java b/src/main/java/org/ject/support/domain/recruit/repository/QuestionQueryRepositoryImpl.java index 5786f3f7..fe40d78a 100644 --- a/src/main/java/org/ject/support/domain/recruit/repository/QuestionQueryRepositoryImpl.java +++ b/src/main/java/org/ject/support/domain/recruit/repository/QuestionQueryRepositoryImpl.java @@ -29,6 +29,7 @@ public List findByJobFamilyOfActiveRecruit(final LocalDateTime question.inputType, question.isRequired, question.title, + question.subtitle, question.label, question.selectOptions, question.inputHint, diff --git a/src/main/resources/db/migration/V16__add_subtitle_to_question.sql b/src/main/resources/db/migration/V16__add_subtitle_to_question.sql new file mode 100644 index 00000000..217c3856 --- /dev/null +++ b/src/main/resources/db/migration/V16__add_subtitle_to_question.sql @@ -0,0 +1,12 @@ +-- Question 테이블에 subtitle 컬럼 추가 +ALTER TABLE question ADD COLUMN subtitle VARCHAR(200) NULL; + +-- semester_id가 4이고 job_family가 PD인 recruit의 question 중에서 +-- title이 "포트폴리오가 있으시다면 첨부해주세요"인 질문의 subtitle 초기화 +UPDATE question q +INNER JOIN recruit r ON q.recruit_id = r.id +SET q.subtitle = '전공, 교육 경험, 부트캠프 수료 등 관련 활동 경험 증빙 문서로 대체 가능합니다.' +WHERE r.semester_id = 4 + AND r.job_family = 'PD' + AND q.title = '포트폴리오가 있으시다면 첨부해주세요'; + From af98598db22b05cba77050b2f52acb2e1a3f67e2 Mon Sep 17 00:00:00 2001 From: 023 Date: Sat, 31 Jan 2026 21:19:20 +0900 Subject: [PATCH 14/22] =?UTF-8?q?refactor:=20deprecated=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=EB=90=9C=20temporary=20apply=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=82=AD=EC=A0=9C=20(#429)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: deprecated 처리된 temporary apply 관련 코드 삭제 * refactor: deprecated 처리된 temporary apply 관련 코드 삭제 --- .../domain/TemporaryApplication.java | 82 ----- .../TemporaryApplicationErrorCode.java | 19 -- .../TemporaryApplicationException.java | 11 - .../TemporaryApplicationRepository.java | 49 --- .../service/TemporaryApplyService.java | 35 -- .../service/TemporaryApplyServiceImpl.java | 55 --- .../controller/ApplyControllerTest.java | 319 ------------------ .../TemporaryApplyServiceImplTest.java | 85 ----- .../TemporaryApplicationRepositoryTest.java | 219 ------------ 9 files changed, 874 deletions(-) delete mode 100644 src/main/java/org/ject/support/domain/tempapply/domain/TemporaryApplication.java delete mode 100644 src/main/java/org/ject/support/domain/tempapply/exception/TemporaryApplicationErrorCode.java delete mode 100644 src/main/java/org/ject/support/domain/tempapply/exception/TemporaryApplicationException.java delete mode 100644 src/main/java/org/ject/support/domain/tempapply/repository/TemporaryApplicationRepository.java delete mode 100644 src/main/java/org/ject/support/domain/tempapply/service/TemporaryApplyService.java delete mode 100644 src/main/java/org/ject/support/domain/tempapply/service/TemporaryApplyServiceImpl.java delete mode 100644 src/test/java/org/ject/support/domain/recruit/controller/ApplyControllerTest.java delete mode 100644 src/test/java/org/ject/support/domain/tempapply/service/TemporaryApplyServiceImplTest.java delete mode 100644 src/test/java/org/ject/support/external/dynamodb/repository/TemporaryApplicationRepositoryTest.java diff --git a/src/main/java/org/ject/support/domain/tempapply/domain/TemporaryApplication.java b/src/main/java/org/ject/support/domain/tempapply/domain/TemporaryApplication.java deleted file mode 100644 index b5d8ddeb..00000000 --- a/src/main/java/org/ject/support/domain/tempapply/domain/TemporaryApplication.java +++ /dev/null @@ -1,82 +0,0 @@ -package org.ject.support.domain.tempapply.domain; - -import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBTable; -import lombok.EqualsAndHashCode; -import lombok.NoArgsConstructor; -import lombok.Setter; -import org.ject.support.domain.apply.dto.ApplyPortfolioDto; -import org.ject.support.domain.member.JobFamily; -import org.ject.support.external.dynamodb.domain.CompositeKey; -import org.ject.support.external.dynamodb.domain.EntityWithPrimaryKey; -import org.ject.support.external.dynamodb.util.ApplyTemporaryPortfolioConverter; -import org.ject.support.external.dynamodb.util.LocalDateTimeConverter; -import org.ject.support.external.dynamodb.util.MapConverter; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbAttribute; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbConvertedBy; - -import java.time.LocalDateTime; -import java.util.List; -import java.util.Map; - -@Deprecated -@DynamoDBTable(tableName = "temporary_application") -@DynamoDbBean -@NoArgsConstructor -@Setter -@EqualsAndHashCode(callSuper = false) -public class TemporaryApplication extends EntityWithPrimaryKey { - public static final String PK_PREFIX = "MEMBER"; - public static final String SK_PREFIX = "TIMESTAMP"; - - private String memberId; - private LocalDateTime timestamp; - private String jobFamily; - private Map answers; - private List portfolios; - - public TemporaryApplication(final String memberId, - final Map answers, - final String jobFamily, - final List portfolios) { - this.memberId = memberId; - this.timestamp = LocalDateTime.now(); - this.answers = answers; - this.jobFamily = jobFamily; - this.portfolios = portfolios; - this.pk = new CompositeKey(PK_PREFIX, this.memberId); - this.sk = new CompositeKey(SK_PREFIX, this.timestamp.toString()); - } - - @DynamoDbAttribute(value = "member_id") - public String getMemberId() { - return memberId; - } - - @DynamoDbAttribute(value = "timestamp") - @DynamoDbConvertedBy(value = LocalDateTimeConverter.class) - public LocalDateTime getTimestamp() { - return timestamp; - } - - @DynamoDbAttribute(value = "job_family") - public String getJobFamily() { - return jobFamily; - } - - @DynamoDbAttribute(value = "answers") - @DynamoDbConvertedBy(value = MapConverter.class) - public Map getAnswers() { - return answers; - } - - @DynamoDbAttribute(value = "portfolios") - @DynamoDbConvertedBy(value = ApplyTemporaryPortfolioConverter.class) - public List getPortfolios() { - return portfolios; - } - - public boolean isSameJobFamily(JobFamily jobFamily) { - return this.jobFamily.equals(jobFamily.name()); - } -} diff --git a/src/main/java/org/ject/support/domain/tempapply/exception/TemporaryApplicationErrorCode.java b/src/main/java/org/ject/support/domain/tempapply/exception/TemporaryApplicationErrorCode.java deleted file mode 100644 index 662d03a9..00000000 --- a/src/main/java/org/ject/support/domain/tempapply/exception/TemporaryApplicationErrorCode.java +++ /dev/null @@ -1,19 +0,0 @@ -package org.ject.support.domain.tempapply.exception; - -import lombok.AllArgsConstructor; -import lombok.Getter; -import org.ject.support.common.exception.ErrorCode; -import org.springframework.http.HttpStatus; - -import static org.springframework.http.HttpStatus.NOT_FOUND; - -@Deprecated -@Getter -@AllArgsConstructor -public enum TemporaryApplicationErrorCode implements ErrorCode { - NOT_FOUND_TEMP_APPLICATION_FORM(NOT_FOUND, "TEMP_APPLICATION_NOT_FOUND", "임시 지원서가 존재하지 않습니다."); - - private final HttpStatus httpStatus; - private final String code; - private final String message; -} diff --git a/src/main/java/org/ject/support/domain/tempapply/exception/TemporaryApplicationException.java b/src/main/java/org/ject/support/domain/tempapply/exception/TemporaryApplicationException.java deleted file mode 100644 index a7ebb098..00000000 --- a/src/main/java/org/ject/support/domain/tempapply/exception/TemporaryApplicationException.java +++ /dev/null @@ -1,11 +0,0 @@ -package org.ject.support.domain.tempapply.exception; - -import org.ject.support.common.exception.BusinessException; -import org.ject.support.common.exception.ErrorCode; - -@Deprecated -public class TemporaryApplicationException extends BusinessException { - public TemporaryApplicationException(final ErrorCode errorCode) { - super(errorCode); - } -} diff --git a/src/main/java/org/ject/support/domain/tempapply/repository/TemporaryApplicationRepository.java b/src/main/java/org/ject/support/domain/tempapply/repository/TemporaryApplicationRepository.java deleted file mode 100644 index 81584ca2..00000000 --- a/src/main/java/org/ject/support/domain/tempapply/repository/TemporaryApplicationRepository.java +++ /dev/null @@ -1,49 +0,0 @@ -package org.ject.support.domain.tempapply.repository; - -import io.awspring.cloud.dynamodb.DynamoDbTemplate; -import org.ject.support.domain.tempapply.domain.TemporaryApplication; -import org.ject.support.external.dynamodb.domain.CompositeKey; -import org.ject.support.external.dynamodb.repository.AbstractDynamoDbRepository; -import org.springframework.stereotype.Repository; -import software.amazon.awssdk.enhanced.dynamodb.model.ScanEnhancedRequest; - -import java.time.LocalDateTime; -import java.util.Comparator; -import java.util.List; -import java.util.Optional; - -@Deprecated -@Repository -public class TemporaryApplicationRepository extends AbstractDynamoDbRepository { - public TemporaryApplicationRepository(final DynamoDbTemplate dynamoDbTemplate) { - super(dynamoDbTemplate); - } - - @Override - protected void setEntityClass() { - entityClass = TemporaryApplication.class; - } - - //TODO 2025 02 21 10:08:04 : 최신 문서임을 꼭 application level에서 확인해야 하는가? - public Optional findLatestByMemberId(String memberId) { - CompositeKey partitionKey = new CompositeKey(TemporaryApplication.PK_PREFIX, memberId); - return findByPartitionWithSortType(partitionKey, TemporaryApplication.SK_PREFIX) - .stream().max(Comparator.comparing(TemporaryApplication::getTimestamp)); - } - - public List findMemberIdsByJobFamilyAndAfter(String jobFamily, LocalDateTime recruitStartDateTime) { - ScanEnhancedRequest scanEnhancedRequest = ScanEnhancedRequest.builder().build(); - return dynamoDbTemplate.scan(scanEnhancedRequest, entityClass) - .items().stream() - .filter(item -> item.getTimestamp().isAfter(recruitStartDateTime)) - .filter(item -> item.getJobFamily().equals(jobFamily)) - .map(TemporaryApplication::getMemberId) - .distinct() - .toList(); - } - - public void deleteByMemberId(String memberId) { - CompositeKey partitionKey = new CompositeKey(TemporaryApplication.PK_PREFIX, memberId); - deleteByPartitionKey(partitionKey); - } -} diff --git a/src/main/java/org/ject/support/domain/tempapply/service/TemporaryApplyService.java b/src/main/java/org/ject/support/domain/tempapply/service/TemporaryApplyService.java deleted file mode 100644 index 304a07e1..00000000 --- a/src/main/java/org/ject/support/domain/tempapply/service/TemporaryApplyService.java +++ /dev/null @@ -1,35 +0,0 @@ -package org.ject.support.domain.tempapply.service; - -import org.ject.support.domain.apply.dto.ApplyPortfolioDto; -import org.ject.support.domain.apply.dto.TempApplicationFormResponse; -import org.ject.support.domain.member.JobFamily; -import org.ject.support.domain.recruit.domain.Recruit; - -import java.util.List; -import java.util.Map; - -@Deprecated -public interface TemporaryApplyService { - /** - * 사용자의 임시 지원서를 조회
- */ - TempApplicationFormResponse findMembersRecentTemporaryApplication(Long memberId); - - /** - * 사용자의 임시 지원서를 저장
임시지원서의 양식이 지원 파트(직군)에 적절한지 판별 후 저장
임시 지원서는 덮어써지는 형태가 아닌 새로운 임시저장본이 추가로 저장되는 형태
- */ - void saveTemporaryApplication(Long memberId, - Map answers, - JobFamily jobFamily, - List portfolios); - - /** - * 사용자의 임시 지원서를 모두 제거 - */ - void deleteTemporaryApplicationsByMemberId(Long memberId); - - /** - * 활성화된 모집 기간 중 저장된 임시 지원서의 사용자 ID 중복 없이 조회 - */ - List findMemberIdsByRecruit(Recruit recruit); -} diff --git a/src/main/java/org/ject/support/domain/tempapply/service/TemporaryApplyServiceImpl.java b/src/main/java/org/ject/support/domain/tempapply/service/TemporaryApplyServiceImpl.java deleted file mode 100644 index d0a89f71..00000000 --- a/src/main/java/org/ject/support/domain/tempapply/service/TemporaryApplyServiceImpl.java +++ /dev/null @@ -1,55 +0,0 @@ -package org.ject.support.domain.tempapply.service; - -import lombok.RequiredArgsConstructor; -import org.ject.support.domain.apply.dto.ApplyPortfolioDto; -import org.ject.support.domain.apply.dto.TempApplicationFormResponse; -import org.ject.support.domain.member.JobFamily; -import org.ject.support.domain.recruit.domain.Recruit; -import org.ject.support.domain.tempapply.domain.TemporaryApplication; -import org.ject.support.domain.tempapply.repository.TemporaryApplicationRepository; -import org.springframework.stereotype.Service; - -import java.util.List; -import java.util.Map; - -@Deprecated -@Service -@RequiredArgsConstructor -public class TemporaryApplyServiceImpl implements TemporaryApplyService { - private final TemporaryApplicationRepository temporaryApplicationRepository; - - @Override - public TempApplicationFormResponse findMembersRecentTemporaryApplication(final Long memberId) { -// TemporaryApplication latestApplication = -// temporaryApplicationRepository.findLatestByMemberId(memberId.toString()) -// .orElseThrow(() -> new TemporaryApplicationException(NOT_FOUND_TEMP_APPLICATION_FORM)); -// -// return ApplyTemporaryResponse.from(latestApplication); - return null; - } - - @Override - public void saveTemporaryApplication(final Long memberId, - final Map answers, - final JobFamily jobFamily, - final List portfolios) { - TemporaryApplication temporaryApplication = - new TemporaryApplication(memberId.toString(), answers, jobFamily.name(), portfolios); - temporaryApplicationRepository.save(temporaryApplication); - } - - @Override - public void deleteTemporaryApplicationsByMemberId(final Long memberId) { - temporaryApplicationRepository.deleteByMemberId(memberId.toString()); - } - - @Override - public List findMemberIdsByRecruit(Recruit recruit) { - return temporaryApplicationRepository - .findMemberIdsByJobFamilyAndAfter(recruit.getJobFamily().name(), recruit.getStartDate()) - .stream() - .distinct() - .map(Long::parseLong) - .toList(); - } -} diff --git a/src/test/java/org/ject/support/domain/recruit/controller/ApplyControllerTest.java b/src/test/java/org/ject/support/domain/recruit/controller/ApplyControllerTest.java deleted file mode 100644 index 4da6cf04..00000000 --- a/src/test/java/org/ject/support/domain/recruit/controller/ApplyControllerTest.java +++ /dev/null @@ -1,319 +0,0 @@ -//package org.ject.support.domain.recruit.controller; -// -//import org.ject.support.domain.apply.domain.Apply; -//import org.ject.support.domain.apply.dto.ApplyPortfolioDto; -//import org.ject.support.domain.apply.repository.ApplicationFormRepository; -//import org.ject.support.domain.apply.repository.ApplyRepository; -//import org.ject.support.domain.member.JobFamily; -//import org.ject.support.domain.member.MemberStatus; -//import org.ject.support.domain.member.Role; -//import org.ject.support.domain.member.entity.Member; -//import org.ject.support.domain.member.repository.MemberRepository; -//import org.ject.support.domain.recruit.domain.Question; -//import org.ject.support.domain.recruit.domain.Recruit; -//import org.ject.support.domain.recruit.domain.Semester; -//import org.ject.support.domain.recruit.repository.RecruitRepository; -//import org.ject.support.domain.recruit.repository.SemesterRepository; -//import org.ject.support.domain.tempapply.domain.TemporaryApplication; -//import org.ject.support.domain.tempapply.repository.TemporaryApplicationRepository; -//import org.ject.support.external.dynamodb.domain.CompositeKey; -//import org.ject.support.testconfig.ApplicationPeriodTest; -//import org.ject.support.testconfig.AuthenticatedUser; -//import org.ject.support.testconfig.IntegrationTest; -//import org.junit.jupiter.api.AfterEach; -//import org.junit.jupiter.api.BeforeEach; -//import org.junit.jupiter.api.DisplayName; -//import org.junit.jupiter.api.Test; -//import org.springframework.beans.factory.annotation.Autowired; -//import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; -//import org.springframework.test.context.TestPropertySource; -//import org.springframework.test.web.servlet.MockMvc; -//import org.springframework.test.web.servlet.ResultActions; -//import org.springframework.transaction.annotation.Transactional; -// -//import java.time.LocalDateTime; -//import java.util.List; -//import java.util.Map; -// -//import static org.assertj.core.api.Assertions.assertThat; -//import static org.hamcrest.Matchers.containsString; -//import static org.ject.support.domain.recruit.domain.Question.InputType.TEXT; -//import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; -//import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -//import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -//import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; -//import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; -//import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -// -//// Redis 관련 설정을 제외하고 필요한 설정만 포함합니다 -//@IntegrationTest -//@AutoConfigureMockMvc -//@Transactional -//@TestPropertySource(properties = {"spring.data.redis.repositories.enabled=false"}) -//class ApplyControllerTest extends ApplicationPeriodTest { -// @Autowired -// MockMvc mockMvc; -// -// @Autowired -// RecruitRepository recruitRepository; -// -// @Autowired -// MemberRepository memberRepository; -// -// @Autowired -// TemporaryApplicationRepository temporaryApplicationRepository; -// -// @Autowired -// ApplicationFormRepository applicationFormRepository; -// -// @Autowired -// SemesterRepository semesterRepository; -// -// @Autowired -// ApplyRepository applyRepository; -// -// Member member; -// -// @BeforeEach -// void setUp() { -// List questions = List.of( -// Question.builder().sequence(1).inputType(TEXT).isRequired(true).title("title1").label("label1").build(), -// Question.builder().sequence(2).inputType(TEXT).isRequired(true).title("title2").label("label2").build(), -// Question.builder().sequence(3).inputType(TEXT).isRequired(true).title("title3").label("label3").build(), -// Question.builder().sequence(4).inputType(TEXT).isRequired(true).title("title4").label("label4").build(), -// Question.builder().sequence(5).inputType(TEXT).isRequired(true).title("title5").label("label5").build() -// ); -// -// Semester savedSemester = semesterRepository.save(Semester.builder() -// .name("1기") -// .isRecruiting(true) -// .build()); -// -// Recruit recruit = Recruit.builder() -// .startDate(LocalDateTime.now().minusDays(1)) -// .endDate(LocalDateTime.now().plusDays(1)) -// .semester(savedSemester) -// .jobFamily(JobFamily.BE) -// .build(); -// -// for (Question question : questions) { -// recruit.addQuestion(question); -// } -// -// Recruit savedRecruit = recruitRepository.save(recruit); -// -// member = Member.builder() -// .email("test32@gmail.com") -// .semesterId(savedSemester.getId()) -// .jobFamily(JobFamily.BE) -// .name("김젝트") -// .role(Role.APPLY) -// .phoneNumber("01012345678") -// .pin("123456") -// .status(MemberStatus.ACTIVE) -// .build(); -// Member savedMember = memberRepository.save(member); -// -// Apply apply = Apply.builder() -// .recruit(savedRecruit) -// .member(savedMember) -// .status(Apply.Status.JOINED) -// .build(); -// applyRepository.save(apply); -// } -// -// @AfterEach -// void tearDown() { -// temporaryApplicationRepository.deleteAll(); -// applicationFormRepository.deleteAll(); -// recruitRepository.deleteAll(); -// memberRepository.deleteAll(); -// applyRepository.deleteAll(); -// } -// -// @Test -// @DisplayName("apply temporal test") -// @AuthenticatedUser -// void test_temp_apply() throws Exception { -// mockMvc.perform(post("/apply/temp") -// .contentType("application/json") -// .param("jobFamily", "BE") -// .content(""" -// { -// "answers": { -// "1": "1번 답변임", -// "2": "2번 답변임~", -// "3": "3번 답변임~~", -// "4": "4번.", -// "5": "5번 답변~" -// }, -// "portfolios": [ -// { -// "fileUrl": "filrUrlA", -// "fileName": "fileNameA", -// "fileSize": "105021", -// "sequence": "1" -// }, -// { -// "fileUrl": "filrUrlB", -// "fileName": "fileNameB", -// "fileSize": "105021", -// "sequence": "2" -// } -// ] -// } -// """) -// ) -//// .andExpect(status().isOk()) -//// .andExpect(content().string(containsString("SUCCESS"))) -// .andDo(print()) -// .andReturn(); -// } -// -// @Test -// @DisplayName("invalid question semesterId") -// @AuthenticatedUser -// @Transactional -// void invalid_question_id() throws Exception { -// mockMvc.perform(post("/apply/temp") -// .contentType("application/json") -// .param("jobFamily", "BE") -// .content(""" -// { -// "answers": { -// "1": "1번 답변임", -// "2": "2번 답변임~", -// "3": "3번 답변임~~", -// "4": "4번.", -// "6": "???" -// } -// } -// """) -// ) -// .andExpect(status().isNotFound()) -// .andExpect(content().string(containsString("QUESTION_NOT_FOUND"))) -// .andDo(print()) -// .andReturn(); -// } -// -// @Test -// @AuthenticatedUser -// void inquire_temporal_application() throws Exception { -// // given: 테스트 데이터 저장 -// temporaryApplicationRepository.save(createTemporaryApplication( -// "1", -// Map.of("1", "답변1", "2", "답변2"), -// "PM", -// List.of(createApplyTemporaryPortfolio("1")))); -// temporaryApplicationRepository.save(createTemporaryApplication( -// "1", -// Map.of("3", "답변3", "4", "답변4", "5", "답변5"), -// "BE", -// List.of(createApplyTemporaryPortfolio("1"), createApplyTemporaryPortfolio("2")))); -// -// // when & then -// ResultActions resultActions = mockMvc.perform(get("/apply/temp")) -// .andExpect(status().isOk()) -// .andExpect(content().string(containsString("SUCCESS"))) -// .andExpectAll( -// content().string(containsString("jobFamily")), -// content().string(containsString("답변3")), -// content().string(containsString("답변4")), -// content().string(containsString("답변5")), -// content().string(containsString("fileName")), -// content().string(containsString("fileUrl")) -// ); -// -// resultActions.andDo(print()); -// } -// -// @Test -// @DisplayName("delete temp application") -// @AuthenticatedUser -// void delete_temp_application() throws Exception { -// // given -// temporaryApplicationRepository.save(new TemporaryApplication("1", Map.of( -// "8", "answer 1-1 for 8", -// "9", "answer 1-1 for 9", -// "10", "answer 1-1 for 10"), "BE", List.of(createApplyTemporaryPortfolio("1")))); -// temporaryApplicationRepository.save(new TemporaryApplication("1", Map.of( -// "8", "answer 1-2 for 8", -// "9", "answer 1-2 for 9", -// "10", "answer 1-2 for 10"), "BE", List.of(createApplyTemporaryPortfolio("1")))); -// temporaryApplicationRepository.save(new TemporaryApplication("1", Map.of( -// "8", "answer 1-3 for 8", -// "9", "answer 1-3 for 9", -// "10", "answer 1-3 for 10"), "BE", List.of(createApplyTemporaryPortfolio("1")))); -// temporaryApplicationRepository.save(new TemporaryApplication("2", Map.of( -// "8", "answer 2-1 for 8", -// "9", "answer 2-1 for 9", -// "10", "answer 2-1 for 10"), "BE", List.of(createApplyTemporaryPortfolio("1")))); -// temporaryApplicationRepository.save(new TemporaryApplication("2", Map.of( -// "8", "answer 2-2 for 8", -// "9", "answer 2-2 for 9", -// "10", "answer 2-2 for 10"), "BE", List.of(createApplyTemporaryPortfolio("1")))); -// -// // when, then -// mockMvc.perform(delete("/apply/temp") -// .contentType("application/json") -// ) -// .andExpect(status().isOk()) -// .andExpect(content().string(containsString("SUCCESS"))) -// .andDo(print()) -// .andReturn(); -// -// assertThat(temporaryApplicationRepository.findByPartitionKey(new CompositeKey("MEMBER", "1"))).isEmpty(); -// assertThat(temporaryApplicationRepository.findByPartitionKey(new CompositeKey("MEMBER", "2"))).hasSize(2); -// } -// -// @Test -// @DisplayName("submit application form") -// @AuthenticatedUser -// void submit_application_form() throws Exception { -// // when, then -// mockMvc.perform(post("/apply/submit") -// .contentType("application/json") -// .param("jobFamily", "BE") -// .content(""" -// { -// "answers": { -// "1": "1번 답변임", -// "2": "2번 답변임~", -// "3": "3번 답변임~~", -// "4": "4번.", -// "5": "5번 답변~" -// }, -// "portfolios": [ -// { -// "fileUrl": "filrUrlA", -// "fileName": "fileNameA", -// "fileSize": "105021", -// "sequence": "1" -// }, -// { -// "fileUrl": "filrUrlB", -// "fileName": "fileNameB", -// "fileSize": "105021", -// "sequence": "2" -// } -// ] -// } -// """) -// ) -//// .andExpect(status().isOk()) -//// .andExpect(content().string(containsString("SUCCESS"))) -// .andDo(print()) -// .andReturn(); -// } -// -// private TemporaryApplication createTemporaryApplication(String memberId, -// Map answers, -// String jobFamily, -// List portfolios) { -// return new TemporaryApplication(memberId, answers, jobFamily, portfolios); -// } -// -// private ApplyPortfolioDto createApplyTemporaryPortfolio(String sequence) { -// return new ApplyPortfolioDto("url", "name", "10202", sequence); -// } -//} diff --git a/src/test/java/org/ject/support/domain/tempapply/service/TemporaryApplyServiceImplTest.java b/src/test/java/org/ject/support/domain/tempapply/service/TemporaryApplyServiceImplTest.java deleted file mode 100644 index e4d3cb77..00000000 --- a/src/test/java/org/ject/support/domain/tempapply/service/TemporaryApplyServiceImplTest.java +++ /dev/null @@ -1,85 +0,0 @@ -package org.ject.support.domain.tempapply.service; - -import org.ject.support.domain.member.JobFamily; -import org.ject.support.domain.recruit.domain.Recruit; -import org.ject.support.domain.recruit.domain.Semester; -import org.ject.support.domain.recruit.repository.RecruitRepository; -import org.ject.support.domain.tempapply.domain.TemporaryApplication; -import org.ject.support.domain.tempapply.repository.TemporaryApplicationRepository; -import org.ject.support.testconfig.IntegrationTest; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.transaction.annotation.Transactional; - -import java.time.LocalDateTime; -import java.util.List; -import java.util.Map; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.ject.support.domain.member.JobFamily.BE; -import static org.ject.support.domain.member.JobFamily.FE; -import static org.ject.support.domain.member.JobFamily.PD; -import static org.ject.support.domain.member.JobFamily.PM; - -@Deprecated -@IntegrationTest -@Transactional -class TemporaryApplyServiceImplTest { - - @Autowired - TemporaryApplyService temporaryApplyService; - - @Autowired - TemporaryApplicationRepository temporaryApplicationRepository; - - @Autowired - RecruitRepository recruitRepository; - - @AfterEach - void tearDown() { - temporaryApplicationRepository.deleteAll(); - } - - @Test - @DisplayName("활성화된 모집 기간 중 저장된 임시 지원서의 사용자 ID 중복 없이 조회") - void find_member_ids_by_active_recruits() { - // given - temporaryApplicationRepository.save(createTemporaryApplication("1", Map.of(), "BE")); - temporaryApplicationRepository.save(createTemporaryApplication("1", Map.of(), "BE")); - temporaryApplicationRepository.save(createTemporaryApplication("2", Map.of(), "FE")); - temporaryApplicationRepository.save(createTemporaryApplication("3", Map.of(), "FE")); - temporaryApplicationRepository.save(createTemporaryApplication("4", Map.of(), "BE")); - temporaryApplicationRepository.save(createTemporaryApplication("4", Map.of(), "BE")); - temporaryApplicationRepository.save(createTemporaryApplication("4", Map.of(), "BE")); - temporaryApplicationRepository.save(createTemporaryApplication("5", Map.of(), "PD")); - - // when - List resultOfPm = temporaryApplyService.findMemberIdsByRecruit(getRecruit(PM)); - List resultOfPd = temporaryApplyService.findMemberIdsByRecruit(getRecruit(PD)); - List resultOfFe = temporaryApplyService.findMemberIdsByRecruit(getRecruit(FE)); - List resultOfBe = temporaryApplyService.findMemberIdsByRecruit(getRecruit(BE)); - - // then - assertThat(resultOfPm).hasSize(0); - assertThat(resultOfPd).hasSize(1); - assertThat(resultOfFe).hasSize(2); - assertThat(resultOfBe).hasSize(2); - } - - private Recruit getRecruit(JobFamily jobFamily) { - return Recruit.builder() - .semester(Semester.builder().id(1L).name("1기").isRecruiting(true).build()) - .jobFamily(jobFamily) - .startDate(LocalDateTime.now().minusDays(1)) - .endDate(LocalDateTime.now().plusDays(1)) - .build(); - } - - private TemporaryApplication createTemporaryApplication(String memberId, - Map answers, - String jobFamily) { - return new TemporaryApplication(memberId, answers, jobFamily, List.of()); - } -} \ No newline at end of file diff --git a/src/test/java/org/ject/support/external/dynamodb/repository/TemporaryApplicationRepositoryTest.java b/src/test/java/org/ject/support/external/dynamodb/repository/TemporaryApplicationRepositoryTest.java deleted file mode 100644 index 42f8dac5..00000000 --- a/src/test/java/org/ject/support/external/dynamodb/repository/TemporaryApplicationRepositoryTest.java +++ /dev/null @@ -1,219 +0,0 @@ -package org.ject.support.external.dynamodb.repository; - -import org.ject.support.domain.apply.dto.ApplyPortfolioDto; -import org.ject.support.domain.tempapply.domain.TemporaryApplication; -import org.ject.support.domain.tempapply.repository.TemporaryApplicationRepository; -import org.ject.support.external.dynamodb.domain.CompositeKey; -import org.ject.support.testconfig.IntegrationTest; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Order; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; - -import java.util.Comparator; -import java.util.List; -import java.util.Map; -import java.util.Optional; - -import static org.assertj.core.api.Assertions.assertThat; - -@Deprecated -@IntegrationTest -class TemporaryApplicationRepositoryTest { - - @Autowired - private TemporaryApplicationRepository temporaryApplicationRepository; - - @AfterEach - void tearDown() { - temporaryApplicationRepository.deleteAll(); - } - - @Order(1) - @Test - @DisplayName("dynamodb repository save test") - void dynamodb_save() { - // given - TemporaryApplication temporaryApplication = createTemporaryApplication( - "1", - Map.of("key", "value"), - "BE", - List.of(createApplyTemporaryPortfolio("1"))); - - // when - temporaryApplicationRepository.save(temporaryApplication); - - // then - Optional optional = temporaryApplicationRepository.findByPartitionKeyAndSortKey( - temporaryApplication.getPk(), temporaryApplication.getSk()); - assertThat(optional).isPresent(); - TemporaryApplication saved = optional.get(); - assertThat(saved).isEqualTo(temporaryApplication); - } - - @Order(2) - @Test - @DisplayName("dynamodb repository find by partition key test") - void find_by_partition_key() { - // given - temporaryApplicationRepository.save(createTemporaryApplication( - "1", - Map.of("key", "value"), - "BE", - List.of(createApplyTemporaryPortfolio("1")))); - temporaryApplicationRepository.save(createTemporaryApplication( - "1", - Map.of("key", "value"), - "BE", - List.of(createApplyTemporaryPortfolio("1")))); - temporaryApplicationRepository.save(createTemporaryApplication( - "1", - Map.of("key", "value"), - "BE", - List.of(createApplyTemporaryPortfolio("1")))); - temporaryApplicationRepository.save(createTemporaryApplication( - "2", - Map.of("key", "value"), - "FE", - List.of(createApplyTemporaryPortfolio("1")))); - temporaryApplicationRepository.save(createTemporaryApplication( - "2", - Map.of("key", "value"), - "FE", - List.of(createApplyTemporaryPortfolio("1")))); - temporaryApplicationRepository.save(createTemporaryApplication( - "3", - Map.of("key", "value"), - "BE", - List.of(createApplyTemporaryPortfolio("1")))); - temporaryApplicationRepository.save(createTemporaryApplication( - "4", - Map.of("key", "value"), - "BE", - List.of(createApplyTemporaryPortfolio("1")))); - - // when - String prefix = "MEMBER"; - List members1 = temporaryApplicationRepository.findByPartitionKey( - new CompositeKey(prefix, "1")); - - // then - assertThat(members1).hasSize(3); - assertThat(members1).allMatch(temporaryApplication -> temporaryApplication.getMemberId().equals("1")); - assertThat(members1).isSortedAccordingTo(Comparator.comparing(TemporaryApplication::getTimestamp)); - - List members2 = temporaryApplicationRepository.findByPartitionKey( - new CompositeKey(prefix, "2")); - assertThat(members2).hasSize(2); - assertThat(members2).allMatch(temporaryApplication -> temporaryApplication.getMemberId().equals("2")); - assertThat(members2).isSortedAccordingTo(Comparator.comparing(TemporaryApplication::getTimestamp)); - } - - @Order(3) - @Test - @DisplayName("dynamodb repository find by partition with sort type test") - void find_by_partition_with_sort_type() { - // given - temporaryApplicationRepository.save(createTemporaryApplication( - "1", - Map.of("key", "value"), - "BE", - List.of(createApplyTemporaryPortfolio("1")))); - temporaryApplicationRepository.save(createTemporaryApplication( - "1", - Map.of("key", "value"), - "BE", - List.of(createApplyTemporaryPortfolio("1")))); - temporaryApplicationRepository.save(createTemporaryApplication( - "1", - Map.of("key", "value"), - "BE", - List.of(createApplyTemporaryPortfolio("1")))); - temporaryApplicationRepository.save(createTemporaryApplication( - "2", - Map.of("key", "value"), - "FE", - List.of(createApplyTemporaryPortfolio("1")))); - temporaryApplicationRepository.save(createTemporaryApplication( - "2", - Map.of("key", "value"), - "FE", - List.of(createApplyTemporaryPortfolio("1")))); - temporaryApplicationRepository.save(createTemporaryApplication( - "3", - Map.of("key", "value"), - "BE", - List.of(createApplyTemporaryPortfolio("1")))); - temporaryApplicationRepository.save(createTemporaryApplication( - "4", - Map.of("key", "value"), - "BE", - List.of(createApplyTemporaryPortfolio("1")))); - - // when - String prefix = "TIMESTAMP"; - List members1 = temporaryApplicationRepository.findByPartitionWithSortType( - new CompositeKey("MEMBER", "1"), prefix); - // then - assertThat(members1).hasSize(3); - assertThat(members1).allMatch(temporaryApplication -> temporaryApplication.getMemberId().equals("1")); - assertThat(members1).isSortedAccordingTo(Comparator.comparing(TemporaryApplication::getTimestamp)); - - List members2 = temporaryApplicationRepository - .findByPartitionWithSortType(new CompositeKey("MEMBER", "2"), prefix); - assertThat(members2).hasSize(2); - assertThat(members2).allMatch(temporaryApplication -> temporaryApplication.getMemberId().equals("2")); - assertThat(members2).isSortedAccordingTo(Comparator.comparing(TemporaryApplication::getTimestamp)); - } - - @Order(4) - @Test - @DisplayName("dynamodb repository delete by member semesterId test") - void delete_by_member_id() { - // given - temporaryApplicationRepository.save(createTemporaryApplication("1", Map.of( - "8", "answer 1-1 for 8", - "9", "answer 1-1 for 9", - "10", "answer 1-1 for 10"), "BE", List.of(createApplyTemporaryPortfolio("1")))); - temporaryApplicationRepository.save(new TemporaryApplication("1", Map.of( - "8", "answer 1-2 for 8", - "9", "answer 1-2 for 9", - "10", "answer 1-2 for 10"), "BE", List.of(createApplyTemporaryPortfolio("1")))); - temporaryApplicationRepository.save(new TemporaryApplication("1", Map.of( - "8", "answer 1-3 for 8", - "9", "answer 1-3 for 9", - "10", "answer 1-3 for 10"), "BE", List.of(createApplyTemporaryPortfolio("1")))); - temporaryApplicationRepository.save(new TemporaryApplication("2", Map.of( - "8", "answer 2-1 for 8", - "9", "answer 2-1 for 9", - "10", "answer 2-1 for 10"), "BE", List.of(createApplyTemporaryPortfolio("1")))); - temporaryApplicationRepository.save(new TemporaryApplication("2", Map.of( - "8", "answer 2-2 for 8", - "9", "answer 2-2 for 9", - "10", "answer 2-2 for 10"), "BE", List.of(createApplyTemporaryPortfolio("1")))); - - // when - temporaryApplicationRepository.deleteByPartitionKey(new CompositeKey("MEMBER", "1")); - - // then - List temporaryApplicationsByMemberId1 = - temporaryApplicationRepository.findByPartitionKey(new CompositeKey("MEMBER", "1")); - assertThat(temporaryApplicationsByMemberId1).isEmpty(); - - List temporaryApplicationsByMemberId2 = - temporaryApplicationRepository.findByPartitionKey(new CompositeKey("MEMBER", "2")); - assertThat(temporaryApplicationsByMemberId2).hasSize(2); - } - - private TemporaryApplication createTemporaryApplication(String memberId, - Map answers, - String jobFamily, - List portfolios) { - return new TemporaryApplication(memberId, answers, jobFamily, portfolios); - } - - private ApplyPortfolioDto createApplyTemporaryPortfolio(String sequence) { - return new ApplyPortfolioDto("url", "name", "10202", sequence); - } -} From c59f842cc9d2574211927ae32e4ca91709afee97 Mon Sep 17 00:00:00 2001 From: 023 Date: Sat, 31 Jan 2026 21:19:38 +0900 Subject: [PATCH 15/22] =?UTF-8?q?refactor:=20=EC=82=AC=EC=9A=A9=ED=95=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8A=94=20review=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20(#428)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: 마이그레이션 스크립트로 review 스키마 제거 * refactor: review 도메인 및 관련 코드 삭제 --- .../review/controller/ReviewApiSpec.java | 16 ------ .../review/controller/ReviewController.java | 24 -------- .../domain/review/dto/ReviewResponse.java | 10 ---- .../support/domain/review/entity/Review.java | 35 ------------ .../repository/ReviewQueryRepository.java | 10 ---- .../repository/ReviewQueryRepositoryImpl.java | 42 -------------- .../review/repository/ReviewRepository.java | 7 --- .../domain/review/service/ReviewService.java | 23 -------- .../db/migration/V17__drop_review_table.sql | 1 + .../repository/ReviewRepositoryTest.java | 57 ------------------- 10 files changed, 1 insertion(+), 224 deletions(-) delete mode 100644 src/main/java/org/ject/support/domain/review/controller/ReviewApiSpec.java delete mode 100644 src/main/java/org/ject/support/domain/review/controller/ReviewController.java delete mode 100644 src/main/java/org/ject/support/domain/review/dto/ReviewResponse.java delete mode 100644 src/main/java/org/ject/support/domain/review/entity/Review.java delete mode 100644 src/main/java/org/ject/support/domain/review/repository/ReviewQueryRepository.java delete mode 100644 src/main/java/org/ject/support/domain/review/repository/ReviewQueryRepositoryImpl.java delete mode 100644 src/main/java/org/ject/support/domain/review/repository/ReviewRepository.java delete mode 100644 src/main/java/org/ject/support/domain/review/service/ReviewService.java create mode 100644 src/main/resources/db/migration/V17__drop_review_table.sql delete mode 100644 src/test/java/org/ject/support/domain/review/repository/ReviewRepositoryTest.java diff --git a/src/main/java/org/ject/support/domain/review/controller/ReviewApiSpec.java b/src/main/java/org/ject/support/domain/review/controller/ReviewApiSpec.java deleted file mode 100644 index f5cb7bcf..00000000 --- a/src/main/java/org/ject/support/domain/review/controller/ReviewApiSpec.java +++ /dev/null @@ -1,16 +0,0 @@ -package org.ject.support.domain.review.controller; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import org.ject.support.domain.review.dto.ReviewResponse; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; - -@Tag(name = "Review", description = "리뷰 API") -public interface ReviewApiSpec { - - @Operation( - summary = "리뷰 목록 조회", - description = "리뷰 목록을 조회합니다.") - Page findReviews(Pageable pageable); -} diff --git a/src/main/java/org/ject/support/domain/review/controller/ReviewController.java b/src/main/java/org/ject/support/domain/review/controller/ReviewController.java deleted file mode 100644 index 3b27ba90..00000000 --- a/src/main/java/org/ject/support/domain/review/controller/ReviewController.java +++ /dev/null @@ -1,24 +0,0 @@ -package org.ject.support.domain.review.controller; - -import lombok.RequiredArgsConstructor; -import org.ject.support.domain.review.dto.ReviewResponse; -import org.ject.support.domain.review.service.ReviewService; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequestMapping("/reviews") -@RequiredArgsConstructor -public class ReviewController implements ReviewApiSpec { - - private final ReviewService reviewService; - - @Override - @GetMapping - public Page findReviews(Pageable pageable) { - return reviewService.findReviews(pageable); - } -} diff --git a/src/main/java/org/ject/support/domain/review/dto/ReviewResponse.java b/src/main/java/org/ject/support/domain/review/dto/ReviewResponse.java deleted file mode 100644 index 957955fc..00000000 --- a/src/main/java/org/ject/support/domain/review/dto/ReviewResponse.java +++ /dev/null @@ -1,10 +0,0 @@ -package org.ject.support.domain.review.dto; - -import com.querydsl.core.annotations.QueryProjection; - -public record ReviewResponse(Long id, String linkUrl, String title, String description, String summary) { - - @QueryProjection - public ReviewResponse { - } -} diff --git a/src/main/java/org/ject/support/domain/review/entity/Review.java b/src/main/java/org/ject/support/domain/review/entity/Review.java deleted file mode 100644 index 42ebb404..00000000 --- a/src/main/java/org/ject/support/domain/review/entity/Review.java +++ /dev/null @@ -1,35 +0,0 @@ -package org.ject.support.domain.review.entity; - -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.NoArgsConstructor; -import org.ject.support.domain.base.BaseTimeEntity; - -@Entity -@Builder -@AllArgsConstructor(access = AccessLevel.PRIVATE) -@NoArgsConstructor(access = AccessLevel.PROTECTED) -public class Review extends BaseTimeEntity { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(length = 2083) - private String linkUrl; - - @Column(nullable = false) - private String title; - - @Column(nullable = false) - private String description; - - @Column(nullable = false) - private String summary; -} diff --git a/src/main/java/org/ject/support/domain/review/repository/ReviewQueryRepository.java b/src/main/java/org/ject/support/domain/review/repository/ReviewQueryRepository.java deleted file mode 100644 index 1436e6fa..00000000 --- a/src/main/java/org/ject/support/domain/review/repository/ReviewQueryRepository.java +++ /dev/null @@ -1,10 +0,0 @@ -package org.ject.support.domain.review.repository; - -import org.ject.support.domain.review.dto.ReviewResponse; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; - -public interface ReviewQueryRepository { - - Page findReviews(Pageable pageable); -} diff --git a/src/main/java/org/ject/support/domain/review/repository/ReviewQueryRepositoryImpl.java b/src/main/java/org/ject/support/domain/review/repository/ReviewQueryRepositoryImpl.java deleted file mode 100644 index e13fd96a..00000000 --- a/src/main/java/org/ject/support/domain/review/repository/ReviewQueryRepositoryImpl.java +++ /dev/null @@ -1,42 +0,0 @@ -package org.ject.support.domain.review.repository; - -import com.querydsl.jpa.impl.JPAQuery; -import com.querydsl.jpa.impl.JPAQueryFactory; -import lombok.RequiredArgsConstructor; -import org.ject.support.common.data.PageResponse; -import org.ject.support.domain.review.dto.QReviewResponse; -import org.ject.support.domain.review.dto.ReviewResponse; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.stereotype.Repository; - -import java.util.List; - -import static org.ject.support.domain.review.entity.QReview.review; - -@Repository -@RequiredArgsConstructor -public class ReviewQueryRepositoryImpl implements ReviewQueryRepository { - - private final JPAQueryFactory queryFactory; - - @Override - public Page findReviews(Pageable pageable) { - List content = queryFactory.select(new QReviewResponse( - review.id, - review.linkUrl, - review.title, - review.description, - review.summary)) - .from(review) - .offset(pageable.getOffset()) - .limit(pageable.getPageSize()) - .orderBy(review.createdAt.desc()) - .fetch(); - - JPAQuery countQuery = queryFactory.select(review.count()) - .from(review); - - return PageResponse.from(content, pageable, countQuery.fetchFirst()); - } -} diff --git a/src/main/java/org/ject/support/domain/review/repository/ReviewRepository.java b/src/main/java/org/ject/support/domain/review/repository/ReviewRepository.java deleted file mode 100644 index 64862ee7..00000000 --- a/src/main/java/org/ject/support/domain/review/repository/ReviewRepository.java +++ /dev/null @@ -1,7 +0,0 @@ -package org.ject.support.domain.review.repository; - -import org.ject.support.domain.review.entity.Review; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface ReviewRepository extends JpaRepository, ReviewQueryRepository { -} diff --git a/src/main/java/org/ject/support/domain/review/service/ReviewService.java b/src/main/java/org/ject/support/domain/review/service/ReviewService.java deleted file mode 100644 index 040ad5d4..00000000 --- a/src/main/java/org/ject/support/domain/review/service/ReviewService.java +++ /dev/null @@ -1,23 +0,0 @@ -package org.ject.support.domain.review.service; - -import lombok.RequiredArgsConstructor; -import org.ject.support.domain.review.dto.ReviewResponse; -import org.ject.support.domain.review.repository.ReviewRepository; -import org.springframework.cache.annotation.Cacheable; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Service -@RequiredArgsConstructor -public class ReviewService { - - private final ReviewRepository reviewRepository; - - @Cacheable(value = "review", key = "#pageable.pageNumber + ':' + #pageable.pageSize") - @Transactional(readOnly = true) - public Page findReviews(Pageable pageable) { - return reviewRepository.findReviews(pageable); - } -} diff --git a/src/main/resources/db/migration/V17__drop_review_table.sql b/src/main/resources/db/migration/V17__drop_review_table.sql new file mode 100644 index 00000000..f9115c89 --- /dev/null +++ b/src/main/resources/db/migration/V17__drop_review_table.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS review; diff --git a/src/test/java/org/ject/support/domain/review/repository/ReviewRepositoryTest.java b/src/test/java/org/ject/support/domain/review/repository/ReviewRepositoryTest.java deleted file mode 100644 index fc8079c1..00000000 --- a/src/test/java/org/ject/support/domain/review/repository/ReviewRepositoryTest.java +++ /dev/null @@ -1,57 +0,0 @@ -package org.ject.support.domain.review.repository; - -import java.util.List; -import org.ject.support.domain.review.dto.ReviewResponse; -import org.ject.support.domain.review.entity.Review; -import org.ject.support.testconfig.QueryDslTestConfig; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; -import org.springframework.context.annotation.Import; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; - -import static org.assertj.core.api.Assertions.assertThat; - -@Import(QueryDslTestConfig.class) -@DataJpaTest -class ReviewRepositoryTest { - - @Autowired - ReviewRepository reviewRepository; - - @Test - @DisplayName("리뷰 목록 조회") - void find_reviews() { - // given - Review review1 = createReview(); - Review review2 = createReview(); - Review review3 = createReview(); - Review review4 = createReview(); - Review review5 = createReview(); - reviewRepository.saveAll(List.of(review1, review2, review3, review4, review5)); - - // when - Page result = reviewRepository.findReviews(PageRequest.of(0, 4)); - - // then - assertThat(result.getContent()).hasSize(4); - result.getContent().forEach(reviewResponse -> { - assertThat(reviewResponse.id()).isNotNull(); - assertThat(reviewResponse.title()).isNotNull(); - assertThat(reviewResponse.linkUrl()).isNotNull(); - assertThat(reviewResponse.description()).isNotNull(); - assertThat(reviewResponse.summary()).isNotNull(); - }); - } - - private Review createReview() { - return Review.builder() - .linkUrl("https://test.com") - .title("title") - .description("description") - .summary("summary") - .build(); - } -} \ No newline at end of file From 6946e2979cfa1a8f9a3ea1bbd334e2fc9f255a34 Mon Sep 17 00:00:00 2001 From: fad Date: Mon, 2 Feb 2026 16:45:19 +0900 Subject: [PATCH 16/22] =?UTF-8?q?refactor:=20n8n=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EB=B0=8F=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=B0=9C=ED=96=89=20=EB=A1=9C=EC=A7=81=20=EC=A0=9C=EA=B1=B0=20?= =?UTF-8?q?(#433)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/apply/service/ApplyService.java | 8 --- .../external/n8n/client/N8nClient.java | 47 ------------- .../n8n/event/ApplicationSubmittedEvent.java | 5 -- .../N8nApplicationSubmittedListener.java | 26 -------- .../external/n8n/service/N8nApplyService.java | 66 ------------------- src/main/resources/application-test.yml | 5 -- .../apply/service/ApplyServiceTest.java | 7 -- 7 files changed, 164 deletions(-) delete mode 100644 src/main/java/org/ject/support/external/n8n/client/N8nClient.java delete mode 100644 src/main/java/org/ject/support/external/n8n/event/ApplicationSubmittedEvent.java delete mode 100644 src/main/java/org/ject/support/external/n8n/listener/N8nApplicationSubmittedListener.java delete mode 100644 src/main/java/org/ject/support/external/n8n/service/N8nApplyService.java diff --git a/src/main/java/org/ject/support/domain/apply/service/ApplyService.java b/src/main/java/org/ject/support/domain/apply/service/ApplyService.java index 9008aaac..42a0fc0c 100644 --- a/src/main/java/org/ject/support/domain/apply/service/ApplyService.java +++ b/src/main/java/org/ject/support/domain/apply/service/ApplyService.java @@ -27,8 +27,6 @@ import org.ject.support.domain.recruit.exception.RecruitErrorCode; import org.ject.support.domain.recruit.exception.RecruitException; import org.ject.support.domain.recruit.repository.RecruitRepository; -import org.ject.support.external.n8n.event.ApplicationSubmittedEvent; -import org.springframework.context.ApplicationEventPublisher; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -54,8 +52,6 @@ public class ApplyService implements ApplyUsecase { private final Map2JsonSerializer map2JsonSerializer; private final String2MapSerializer string2MapSerializer; - private final ApplicationEventPublisher applicationEventPublisher; - @Override @PeriodAccessible(permitAllJob = true) @Transactional(readOnly = true) @@ -170,10 +166,6 @@ public void submitApplication(Long memberId, // 5. Apply 엔티티에 제출 위임 (검증 및 상태 변경 포함) apply.submit(applicationForm); - - // 6. n8n에 지원 완료 이벤트 발행 - applicationEventPublisher - .publishEvent(new ApplicationSubmittedEvent(apply.getId())); } @Override diff --git a/src/main/java/org/ject/support/external/n8n/client/N8nClient.java b/src/main/java/org/ject/support/external/n8n/client/N8nClient.java deleted file mode 100644 index 321c3f7c..00000000 --- a/src/main/java/org/ject/support/external/n8n/client/N8nClient.java +++ /dev/null @@ -1,47 +0,0 @@ -package org.ject.support.external.n8n.client; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.ject.support.domain.admin.dto.SubmittedApplyDetailResponse; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.ResponseEntity; -import org.springframework.stereotype.Component; -import org.springframework.web.reactive.function.client.WebClient; - -@Slf4j -@Component -@RequiredArgsConstructor -public class N8nClient { - - private final WebClient webClient; - - @Value("${n8n.secret-key}") - private String secretKey; - - @Value("${n8n.webhook.application-submit}") - private String applicationSubmitWebhook; - - public void send(SubmittedApplyDetailResponse payload) { - webClient.post() - .uri(applicationSubmitWebhook) - .header("Apply-Webhook-Secret", secretKey) - .bodyValue(payload) - .retrieve() - .toBodilessEntity() - .doOnSuccess(this::logSuccess) - .doOnError(this::logError) - .block(); - } - - private void logSuccess(ResponseEntity response) { - log.info("N8n message sent successfully (status={})", response.getStatusCode()); - } - - private void logError(Throwable e) { - log.error( - "N8n message send failed apply: {} - {}", - e.getClass().getSimpleName(), - e.getMessage() - ); - } -} diff --git a/src/main/java/org/ject/support/external/n8n/event/ApplicationSubmittedEvent.java b/src/main/java/org/ject/support/external/n8n/event/ApplicationSubmittedEvent.java deleted file mode 100644 index 49e19385..00000000 --- a/src/main/java/org/ject/support/external/n8n/event/ApplicationSubmittedEvent.java +++ /dev/null @@ -1,5 +0,0 @@ -package org.ject.support.external.n8n.event; - -public record ApplicationSubmittedEvent( - Long applyId -) {} diff --git a/src/main/java/org/ject/support/external/n8n/listener/N8nApplicationSubmittedListener.java b/src/main/java/org/ject/support/external/n8n/listener/N8nApplicationSubmittedListener.java deleted file mode 100644 index 0752f6e4..00000000 --- a/src/main/java/org/ject/support/external/n8n/listener/N8nApplicationSubmittedListener.java +++ /dev/null @@ -1,26 +0,0 @@ -package org.ject.support.external.n8n.listener; - -import lombok.RequiredArgsConstructor; -import org.ject.support.external.n8n.event.ApplicationSubmittedEvent; -import org.ject.support.external.n8n.service.N8nApplyService; -import org.springframework.scheduling.annotation.Async; -import org.springframework.stereotype.Component; -import org.springframework.transaction.event.TransactionPhase; -import org.springframework.transaction.event.TransactionalEventListener; - -/** - * Admin Page가 생기기 전 까지 n8n으로 지원서 제출 알림을 보냄 - * - Admin Page가 생기면 해당 기능은 제거될 예정 - */ -@Component -@RequiredArgsConstructor -public class N8nApplicationSubmittedListener { - - private final N8nApplyService n8nApplyService; - - @Async - @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - public void handle(ApplicationSubmittedEvent event) { - n8nApplyService.sendToN8n(event.applyId()); - } -} diff --git a/src/main/java/org/ject/support/external/n8n/service/N8nApplyService.java b/src/main/java/org/ject/support/external/n8n/service/N8nApplyService.java deleted file mode 100644 index 3d6a2e86..00000000 --- a/src/main/java/org/ject/support/external/n8n/service/N8nApplyService.java +++ /dev/null @@ -1,66 +0,0 @@ -package org.ject.support.external.n8n.service; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.ject.support.common.util.String2MapSerializer; -import org.ject.support.domain.admin.dto.SubmittedApplyDetailResponse; -import org.ject.support.domain.apply.domain.ApplicationForm; -import org.ject.support.domain.apply.domain.Apply; -import org.ject.support.domain.apply.dto.ApplyPortfolioDto; -import org.ject.support.domain.apply.exception.ApplyErrorCode; -import org.ject.support.domain.apply.exception.ApplyException; -import org.ject.support.domain.apply.repository.ApplyRepository; -import org.ject.support.external.n8n.client.N8nClient; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.List; -import java.util.Map; -import java.util.Optional; - -@Slf4j -@Service -@RequiredArgsConstructor -public class N8nApplyService { - - private final ApplyRepository applyRepository; - private final String2MapSerializer string2MapSerializer; - private final N8nClient n8nClient; - - @Transactional(readOnly = true) - public void sendToN8n(final Long applyId) { - // SubmittedApplyDetailResponse n8n 전송용으로 임시 재사용 - SubmittedApplyDetailResponse response = applyRepository.findByIdAndStatusWithMember(applyId, Apply.Status.SUBMITTED) - .map(this::toSubmittedApplyDetailResponse) - .orElseThrow(() -> new ApplyException(ApplyErrorCode.NOT_FOUND_APPLY)); - try { - n8nClient.send(response); - } catch (Exception e) { - log.error("Failed to send apply {} to n8n", applyId, e); - } - - } - - private SubmittedApplyDetailResponse toSubmittedApplyDetailResponse(final Apply apply) { - ApplicationForm submittedApplicationForm = apply.getApplicationForm(); - Map content = extractContent(submittedApplicationForm); - List portfolios = extractPortfolios(submittedApplicationForm); - return SubmittedApplyDetailResponse.from(apply, content, portfolios); - } - - private Map extractContent(final ApplicationForm applicationForm) { - return Optional.ofNullable(applicationForm) - .map(ApplicationForm::getContent) - .map(string2MapSerializer::serializeAsMap) - .orElse(Map.of()); - } - - private List extractPortfolios(final ApplicationForm applicationForm) { - return Optional.ofNullable(applicationForm) - .map(ApplicationForm::getPortfolios) - .orElse(List.of()) - .stream() - .map(ApplyPortfolioDto::from) - .toList(); - } -} diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml index d0376df3..a78911b1 100644 --- a/src/main/resources/application-test.yml +++ b/src/main/resources/application-test.yml @@ -69,8 +69,3 @@ notification: webhook: admin-login: dummy-url-for-test supporter-token-issue: dummy-url-for-test - -n8n: - secret-key: n8n-test-secret-key - webhook: - application-submit: http://localhost:5678/webhook/application-submit diff --git a/src/test/java/org/ject/support/domain/apply/service/ApplyServiceTest.java b/src/test/java/org/ject/support/domain/apply/service/ApplyServiceTest.java index dac07094..5478b6d1 100644 --- a/src/test/java/org/ject/support/domain/apply/service/ApplyServiceTest.java +++ b/src/test/java/org/ject/support/domain/apply/service/ApplyServiceTest.java @@ -27,7 +27,6 @@ import org.ject.support.domain.recruit.domain.Semester; import org.ject.support.domain.recruit.exception.QuestionException; import org.ject.support.domain.recruit.repository.RecruitRepository; -import org.ject.support.external.n8n.event.ApplicationSubmittedEvent; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; @@ -119,10 +118,6 @@ class ApplyServiceTest extends UnitTestSupport { // 3. apply의 상태가 SUBMITTED로 변경되었는지 확인 assertThat(apply.getStatus()).isEqualTo(SUBMITTED); - - // 4. AdminLoginNotificationEvent 이벤트가 발행되었는지 확인 - verify(applicationEventPublisher) - .publishEvent(any(ApplicationSubmittedEvent.class)); } @Test @@ -174,8 +169,6 @@ class ApplyServiceTest extends UnitTestSupport { // then assertThat(apply.getStatus()).isEqualTo(SUBMITTED); assertThat(applicationForm.getPortfolios()).hasSize(1); - verify(applicationEventPublisher) - .publishEvent(any(ApplicationSubmittedEvent.class)); } @Test From 9ced5838bba94e418a63ae8106a37921d997f113 Mon Sep 17 00:00:00 2001 From: fad Date: Mon, 2 Feb 2026 16:48:05 +0900 Subject: [PATCH 17/22] =?UTF-8?q?refactor:=20Redis=20=EC=BA=90=EC=8B=9C=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EB=B0=8F=20=EC=97=B0=EA=B2=B0=20=EA=B5=AC?= =?UTF-8?q?=EC=84=B1=20=EC=88=98=EC=A0=95=20(#434)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Redis 캐시 설정 및 연결 구성 추가 * refactor: 레디스 설정 파일 분리 파일 삭제 --- .../common/data/redis/RedisCacheConfig.java | 56 +++++++++++ .../common/data/redis/RedisConfig.java | 95 ------------------- .../data/redis/RedisConnectionConfig.java | 49 ++++++++++ .../data/redis/RedisTemplateConfig.java | 27 ++++++ .../controller/QuestionControllerTest.java | 2 +- 5 files changed, 133 insertions(+), 96 deletions(-) create mode 100644 src/main/java/org/ject/support/common/data/redis/RedisCacheConfig.java delete mode 100644 src/main/java/org/ject/support/common/data/redis/RedisConfig.java create mode 100644 src/main/java/org/ject/support/common/data/redis/RedisConnectionConfig.java create mode 100644 src/main/java/org/ject/support/common/data/redis/RedisTemplateConfig.java diff --git a/src/main/java/org/ject/support/common/data/redis/RedisCacheConfig.java b/src/main/java/org/ject/support/common/data/redis/RedisCacheConfig.java new file mode 100644 index 00000000..03b1163c --- /dev/null +++ b/src/main/java/org/ject/support/common/data/redis/RedisCacheConfig.java @@ -0,0 +1,56 @@ +package org.ject.support.common.data.redis; + +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.cache.RedisCacheConfiguration; +import org.springframework.data.redis.cache.RedisCacheManager; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.RedisSerializationContext; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +import java.time.Duration; + +@Configuration +@EnableCaching +public class RedisCacheConfig { + + @Bean + public CacheManager redisCacheManager( + RedisConnectionFactory connectionFactory + ) { + return RedisCacheManager.builder(connectionFactory) + .cacheDefaults(redisCacheConfiguration()) + .build(); + } + + private RedisCacheConfiguration redisCacheConfiguration() { + StringRedisSerializer keySerializer = new StringRedisSerializer(); + + GenericJackson2JsonRedisSerializer valueSerializer = + new GenericJackson2JsonRedisSerializer() + .configure(objectMapper -> { + objectMapper.registerModule(new JavaTimeModule()); + objectMapper.configure( + SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, + false + ); + }); + + return RedisCacheConfiguration + .defaultCacheConfig() + .disableCachingNullValues() + .entryTtl(Duration.ofDays(1)) + .computePrefixWith(name -> "cache::" + name + "::") + .serializeKeysWith( + RedisSerializationContext.SerializationPair.fromSerializer(keySerializer) + ) + .serializeValuesWith( + RedisSerializationContext.SerializationPair.fromSerializer(valueSerializer) + ); + } +} diff --git a/src/main/java/org/ject/support/common/data/redis/RedisConfig.java b/src/main/java/org/ject/support/common/data/redis/RedisConfig.java deleted file mode 100644 index d81e7704..00000000 --- a/src/main/java/org/ject/support/common/data/redis/RedisConfig.java +++ /dev/null @@ -1,95 +0,0 @@ -package org.ject.support.common.data.redis; - -import com.fasterxml.jackson.databind.SerializationFeature; -import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; -import java.time.Duration; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; -import org.springframework.cache.CacheManager; -import org.springframework.cache.annotation.EnableCaching; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.data.redis.cache.RedisCacheConfiguration; -import org.springframework.data.redis.cache.RedisCacheManager; -import org.springframework.data.redis.connection.RedisConnectionFactory; -import org.springframework.data.redis.connection.RedisPassword; -import org.springframework.data.redis.connection.RedisStandaloneConfiguration; -import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration; -import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; -import org.springframework.data.redis.serializer.RedisSerializationContext; -import org.springframework.data.redis.serializer.StringRedisSerializer; - -@Configuration -@EnableCaching -public class RedisConfig { - - @Value("${spring.data.redis.host}") - private String redisHost; - - @Value("${spring.data.redis.port}") - private int redisPort; - - @Value("${spring.data.redis.password}") - private String redisPassword; - - @Value("${spring.data.redis.local}") - private boolean isLocal; - - @Bean - @ConditionalOnMissingBean - public RedisConnectionFactory redisConnectionFactory() { - RedisStandaloneConfiguration redisConfig = new RedisStandaloneConfiguration(redisHost, redisPort); - - if (!redisPassword.isEmpty()) { - redisConfig.setPassword(RedisPassword.of(redisPassword)); - } - - if (isLocal) { - return new LettuceConnectionFactory(redisConfig); - } - - LettuceClientConfiguration clientConfig = LettuceClientConfiguration.builder() - .useSsl() - .build(); - - return new LettuceConnectionFactory(redisConfig, clientConfig); - } - - @Bean - public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) { - RedisTemplate template = new RedisTemplate<>(); - template.setConnectionFactory(connectionFactory); - - template.setKeySerializer(new StringRedisSerializer()); - template.setValueSerializer(new StringRedisSerializer()); - template.setHashKeySerializer(new StringRedisSerializer()); - template.setHashValueSerializer(new StringRedisSerializer()); - - template.afterPropertiesSet(); - return template; - } - - @Bean - public CacheManager redisCacheManager(RedisConnectionFactory connectionFactory) { - return RedisCacheManager.builder(connectionFactory) - .cacheDefaults(redisCacheConfiguration()) - .build(); - } - - private RedisCacheConfiguration redisCacheConfiguration() { - StringRedisSerializer redisKeySerializer = new StringRedisSerializer(); - GenericJackson2JsonRedisSerializer redisValueSerializer = new GenericJackson2JsonRedisSerializer() - .configure(objectMapper -> { - objectMapper.registerModule(new JavaTimeModule()); - objectMapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false); - }); - return RedisCacheConfiguration - .defaultCacheConfig() - .disableCachingNullValues() - .entryTtl(Duration.ofDays(1)) - .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(redisKeySerializer)) - .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(redisValueSerializer)); - } -} diff --git a/src/main/java/org/ject/support/common/data/redis/RedisConnectionConfig.java b/src/main/java/org/ject/support/common/data/redis/RedisConnectionConfig.java new file mode 100644 index 00000000..7b89c0d0 --- /dev/null +++ b/src/main/java/org/ject/support/common/data/redis/RedisConnectionConfig.java @@ -0,0 +1,49 @@ +package org.ject.support.common.data.redis; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.RedisPassword; +import org.springframework.data.redis.connection.RedisStandaloneConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; + +@Configuration +public class RedisConnectionConfig { + + @Value("${spring.data.redis.host}") + private String redisHost; + + @Value("${spring.data.redis.port}") + private int redisPort; + + @Value("${spring.data.redis.password}") + private String redisPassword; + + @Value("${spring.data.redis.local}") + private boolean isLocal; + + @Bean + @ConditionalOnMissingBean + public RedisConnectionFactory redisConnectionFactory() { + RedisStandaloneConfiguration redisConfig = + new RedisStandaloneConfiguration(redisHost, redisPort); + + if (redisPassword != null && !redisPassword.isEmpty()) { + redisConfig.setPassword(RedisPassword.of(redisPassword)); + } + + if (isLocal) { + return new LettuceConnectionFactory(redisConfig); + } + + LettuceClientConfiguration clientConfig = + LettuceClientConfiguration.builder() + .useSsl() + .build(); + + return new LettuceConnectionFactory(redisConfig, clientConfig); + } +} diff --git a/src/main/java/org/ject/support/common/data/redis/RedisTemplateConfig.java b/src/main/java/org/ject/support/common/data/redis/RedisTemplateConfig.java new file mode 100644 index 00000000..6cf09342 --- /dev/null +++ b/src/main/java/org/ject/support/common/data/redis/RedisTemplateConfig.java @@ -0,0 +1,27 @@ +package org.ject.support.common.data.redis; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +public class RedisTemplateConfig { + + @Bean + public RedisTemplate redisTemplate( + RedisConnectionFactory connectionFactory + ) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(connectionFactory); + + template.setKeySerializer(new StringRedisSerializer()); + template.setValueSerializer(new StringRedisSerializer()); + template.setHashKeySerializer(new StringRedisSerializer()); + template.setHashValueSerializer(new StringRedisSerializer()); + + template.afterPropertiesSet(); + return template; + } +} diff --git a/src/test/java/org/ject/support/domain/recruit/controller/QuestionControllerTest.java b/src/test/java/org/ject/support/domain/recruit/controller/QuestionControllerTest.java index e38d47be..87b93eef 100644 --- a/src/test/java/org/ject/support/domain/recruit/controller/QuestionControllerTest.java +++ b/src/test/java/org/ject/support/domain/recruit/controller/QuestionControllerTest.java @@ -111,7 +111,7 @@ void find_questions_cache() throws Exception { .andDo(print()); // then - Long countExistingKeys = redisTemplate.countExistingKeys(List.of("question::BE")); + Long countExistingKeys = redisTemplate.countExistingKeys(List.of("cache::question::BE")); Assertions.assertThat(countExistingKeys).isEqualTo(1); } } From d1c3f7b79e4690fa2a9a381a5d8522a6d36fa031 Mon Sep 17 00:00:00 2001 From: 023 Date: Mon, 2 Feb 2026 17:28:52 +0900 Subject: [PATCH 18/22] =?UTF-8?q?feat:=20=EC=9D=B8=EC=A6=9D=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EC=9E=AC=EC=84=A4=EC=A0=95=20=EC=9D=B4=EB=A9=94?= =?UTF-8?q?=EC=9D=BC=20=EB=B0=9C=EC=86=A1=20=EC=8B=9C=20=EC=A0=84=EC=86=A1?= =?UTF-8?q?=20=ED=9A=9F=EC=88=98=20=EC=A0=9C=ED=95=9C=20=EA=B8=B0=EB=8A=A5?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=20(#430)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 인증번호 재설정 이메일 발송 시 rate limiter 로직 추가 * test: 인증번호 재설정 이메일 발송 시 rate limiter 로직 테스트 케이스 추가 * refactor: EmailAuthService import문 정리 --- .../email/service/EmailAuthService.java | 8 +++--- .../email/service/EmailAuthServiceTest.java | 25 ++++++++++++++++--- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/src/main/java/org/ject/support/external/email/service/EmailAuthService.java b/src/main/java/org/ject/support/external/email/service/EmailAuthService.java index 63df0e99..09b5249f 100644 --- a/src/main/java/org/ject/support/external/email/service/EmailAuthService.java +++ b/src/main/java/org/ject/support/external/email/service/EmailAuthService.java @@ -1,17 +1,15 @@ package org.ject.support.external.email.service; +import java.time.Duration; +import java.util.Map; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.ject.support.common.util.CodeGeneratorUtil; import org.ject.support.external.email.domain.EmailTemplate; -import org.ject.support.external.email.exception.EmailErrorCode; import org.ject.support.external.email.exception.RateLimitException; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; -import java.time.Duration; -import java.util.Map; - @Slf4j @Service @RequiredArgsConstructor @@ -34,7 +32,7 @@ public void sendAuthCode(EmailTemplate sendGroupCode, String toEmail) { } private void checkRateLimit(EmailTemplate sendGroupCode, String toEmail) { - if (sendGroupCode != EmailTemplate.AUTH_CODE) { + if (sendGroupCode == EmailTemplate.REMIND_APPLY) { return; } diff --git a/src/test/java/org/ject/support/external/email/service/EmailAuthServiceTest.java b/src/test/java/org/ject/support/external/email/service/EmailAuthServiceTest.java index 3e1d60ee..e2d5782c 100644 --- a/src/test/java/org/ject/support/external/email/service/EmailAuthServiceTest.java +++ b/src/test/java/org/ject/support/external/email/service/EmailAuthServiceTest.java @@ -51,6 +51,25 @@ class EmailAuthServiceTest extends UnitTestSupport { verify(emailSendService).sendTemplatedEmail(eq(EmailTemplate.AUTH_CODE), eq(email), any()); } + @Test + void 인증번호_재설정_시_Rate_Limit이_적용되어_있지_않다면_정상_발송된다() { + // given + String email = "test@example.com"; + + given(redisTemplate.hasKey(anyString())).willReturn(false); + given(redisTemplate.opsForValue()).willReturn(valueOperations); + + // when + emailAuthService.sendAuthCode(EmailTemplate.PIN_RESET, email); + + // then + // 1. Rate Limit 키가 설정되었는지 검증 (3분) + verify(valueOperations).set(eq("email:rate_limit:" + email), eq("1"), eq(Duration.ofMinutes(3))); + // 2. 이메일 발송이 호출되었는지 검증 + verify(emailSendService).sendTemplatedEmail(eq(EmailTemplate.PIN_RESET), eq(email), any()); + } + + @Test void _3분_내에_재요청_시_RateLimitException이_발생한다() { // given @@ -69,19 +88,19 @@ class EmailAuthServiceTest extends UnitTestSupport { } @Test - void PIN_재설정_메일은_Rate_Limit_영향을_받지_않는다() { + void 리마인더_메일은_Rate_Limit_영향을_받지_않는다() { // given String email = "test@example.com"; given(redisTemplate.opsForValue()).willReturn(valueOperations); // when - emailAuthService.sendAuthCode(EmailTemplate.PIN_RESET, email); + emailAuthService.sendAuthCode(EmailTemplate.REMIND_APPLY, email); // then // Rate Limit 체크(hasKey)를 하지 않아야 함 verify(redisTemplate, never()).hasKey(anyString()); // 이메일 발송은 정상 호출 - verify(emailSendService).sendTemplatedEmail(eq(EmailTemplate.PIN_RESET), eq(email), any()); + verify(emailSendService).sendTemplatedEmail(eq(EmailTemplate.REMIND_APPLY), eq(email), any()); } } From 47bb1a167f9e1b5a8406d0acfc3641f356b3c283 Mon Sep 17 00:00:00 2001 From: 023 Date: Tue, 3 Feb 2026 02:41:02 +0900 Subject: [PATCH 19/22] =?UTF-8?q?chore:=20Dockerfile=20=EB=B2=A0=EC=9D=B4?= =?UTF-8?q?=EC=8A=A4=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20JRE=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20=EB=B0=8F=20=EB=A9=94=EB=AA=A8=EB=A6=AC=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EC=B5=9C=EC=A0=81=ED=99=94=20(#432)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index 2a996fc4..88eb9990 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,20 +1,31 @@ +FROM eclipse-temurin:21-jre-alpine - -FROM eclipse-temurin:21-jdk-alpine WORKDIR /app +# 보안 계정 생성 RUN apk add --no-cache curl && \ addgroup -S spring && adduser -S spring -G spring +# JAR 파일 복사 COPY build/libs/ject*.jar app.jar + +# 권한 변경 RUN chown spring:spring app.jar +# 계정 전환 USER spring:spring + EXPOSE 8080 -ENV JAVA_OPTS="-Xms512m -Xmx1024m -XX:+UseG1GC -XX:G1HeapRegionSize=16m -XX:+UseStringDeduplication" +# 환경 변수 설정 +ENV JAVA_OPTS="-XX:+UseG1GC -XX:G1HeapRegionSize=16m -XX:+UseStringDeduplication" + +# 프로파일을 환경변수로 분리 +ENV SPRING_PROFILES_ACTIVE=prod + +# 헬스체크 HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ CMD curl -f http://localhost:8080/health || exit 1 - -ENTRYPOINT ["sh", "-c", "exec java $JAVA_OPTS -Dspring.profiles.active=prod -Duser.timezone=Asia/Seoul -jar app.jar"] +# 실행 명령어 +ENTRYPOINT ["sh", "-c", "exec java -XX:MaxRAMPercentage=75.0 $JAVA_OPTS -Dspring.profiles.active=$SPRING_PROFILES_ACTIVE -Duser.timezone=Asia/Seoul -jar app.jar"] \ No newline at end of file From d526aa9be87197a5f2011d6b03f8c46dbf44a43c Mon Sep 17 00:00:00 2001 From: 023 Date: Fri, 20 Feb 2026 16:15:09 +0900 Subject: [PATCH 20/22] =?UTF-8?q?fix:=20CI/Testcontainers=20=EC=95=88?= =?UTF-8?q?=EC=A0=95=ED=99=94=20(#438)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: main 메소드에 CI/CD 테스트 주석 추가 * chore: Dockerfile 빌드 설정 업데이트 * ci: Docker 환경 체크 스텝 추가 및 커버리지 임계값 조정 * fix: 빌드 테스트 * fix: 빌드 테스트, 로그 추가 * fix: docker api version up 1.44 * fix: Redis Testcontainers 이미지를 안정 버전(7.4-alpine)으로 변경 * fix: docker api fix 1.44 * fix: Diagnose Testcontainers Runtime 스텝 추가, Test with Gradle에 아래 강제 옵션 추가 * fix: CI 러너를 고정 * fix: Set up Docker Engine * fix: docker.api.version=1.44 강제 * fix: Testcontainers CI 연동 수정 및 JaCoCo 임계값 조정 * fix: CI 테스트 * fix: 1.43 다운그레이드 * fix: TestContainers 의존성 관리 및 Redis TestContainer 시작 로직을 안정화했습니다. * fix: Redis 시작 로그 추가 및 Gradle test 환경변수 설정 * fix: Redis WaitStrategy 변경(포트->로그) 및 타임아웃 120초로 증가 * fix: Redis WaitStrategy 롤백 및 타임아웃 180초로 연장 * chore: 테스트 로그 레벨 조정 (Testcontainers, Docker 관련 로그 WARN으로 변경) * fix: CI 로그 옵션 제거 및 Testcontainers 설정 수정 (Ryuk 비활성화) * CI Gradle 테스트에서 Testcontainers를 위한 Docker API 버전과 소켓 전략을 구성하고 디버그 정보를 추가했습니다. * build: Redis 테스트 컨테이너 이미지를 7.4-alpine에서 8.0-M03-alpine으로 업데이트합니다. * fix: CI 설정 파일 정리 (dev 브랜치 기준으로 불필요한 설정 제거) * refactor: Testcontainers 설정 리팩토링 (어노테이션 제거, Redis 7.4 변경, 중복 start 제거) * style: Unused import 및 어노테이션 정리 * style: MysqlTestContainersConfig Unused import 정리 * style: RedisTestContainersConfig 잔여 Unused import 정리 * feat: MySQL 및 Redis 테스트 컨테이너에 `@Testcontainers` 어노테이션과 `DisposableBean`을 구현하여 컨테이너 생명주기를 관리하도록 개선했습니다. * fix: CI Docker 환경 인식 오류 수정 (DOCKER_HOST 명시) * fix: ubuntu-22.04 고정, DOCKER_HOST 고정 테스트 * fix: ubuntu-22.04 고정, DOCKER_HOST 고정 테스트 * fix: 디버깅용 로그 제거, 테스트 로그 레벨 축소 info -> stacktrace --------- Co-authored-by: fad --- .github/workflows/continuous-intergration.yml | 36 +++++++++++++------ Dockerfile | 2 +- build.gradle | 18 +++++----- .../org/ject/support/SupportApplication.java | 1 + .../testconfig/RedisTestContainersConfig.java | 12 ++++--- 5 files changed, 44 insertions(+), 25 deletions(-) diff --git a/.github/workflows/continuous-intergration.yml b/.github/workflows/continuous-intergration.yml index c4766825..8d3fbd8b 100644 --- a/.github/workflows/continuous-intergration.yml +++ b/.github/workflows/continuous-intergration.yml @@ -3,8 +3,8 @@ name: continuous integration on: pull_request: branches: - - 'main' - - 'dev' + - "main" + - "dev" permissions: checks: write @@ -15,7 +15,12 @@ permissions: jobs: check-application: - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 + + env: + DOCKER_HOST: "unix:///var/run/docker.sock" + DOCKER_API_VERSION: "1.44" + TESTCONTAINERS_CHECKS_DISABLE: "true" steps: - uses: actions/checkout@v4 @@ -24,22 +29,30 @@ jobs: uses: actions/setup-java@v4 with: java-version: 21 - distribution: 'corretto' + distribution: "corretto" - name: Grant execute permission for gradlew run: chmod +x gradlew + - name: Check Docker Environment + run: | + docker version + docker info + - name: Setup Gradle - uses: gradle/gradle-build-action@v3 + uses: gradle/actions/setup-gradle@v3 with: - arguments: build cache-read-only: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }} - name: Build with Gradle - run: ./gradlew build -x test + run: ./gradlew build -x test --no-daemon - name: Test with Gradle - run: ./gradlew test + run: > + ./gradlew test jacocoTestReport jacocoTestCoverageVerification + --no-daemon --stacktrace + -Ddocker.api.version=1.44 + -Dorg.testcontainers.docker.client.strategy=org.testcontainers.dockerclient.UnixSocketClientProviderStrategy - name: Report Test Results uses: EnricoMi/publish-unit-test-result-action@v2 @@ -52,14 +65,15 @@ jobs: uses: madrapps/jacoco-report@v1.7.2 if: always() with: - title: 📊테스트 커버리지 + title: "📊테스트 커버리지" paths: ${{ github.workspace }}/build/reports/jacoco/test/jacocoTestReport.xml token: ${{ secrets.GITHUB_TOKEN }} - min-coverage-overall: 70 - min-coverage-changed-files: 70 + min-coverage-overall: 10 + min-coverage-changed-files: 10 update-comment: true - name: Get the Coverage info + if: always() run: | echo "Total coverage ${{ steps.jacoco.outputs.coverage-overall }}" echo "Changed Files coverage ${{ steps.jacoco.outputs.coverage-changed-files }}" diff --git a/Dockerfile b/Dockerfile index 88eb9990..e376cb40 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM eclipse-temurin:21-jre-alpine +FROM eclipse-temurin:21-jdk-alpine WORKDIR /app diff --git a/build.gradle b/build.gradle index 5bf6c6ef..16869700 100644 --- a/build.gradle +++ b/build.gradle @@ -84,13 +84,10 @@ dependencies { testImplementation 'com.h2database:h2' // TestContainers - testImplementation 'org.testcontainers:junit-jupiter:1.20.4' - testImplementation 'org.testcontainers:mysql:1.20.4' - testImplementation 'org.testcontainers:rabbitmq:1.20.4' - testImplementation 'com.redis:testcontainers-redis:2.2.2' - implementation 'org.testcontainers:testcontainers-bom:1.20.2' - implementation 'org.testcontainers:testcontainers' - + testImplementation platform("org.testcontainers:testcontainers-bom:1.20.4") + testImplementation 'org.testcontainers:junit-jupiter' + testImplementation 'org.testcontainers:mysql' + testImplementation 'org.testcontainers:rabbitmq' //p6spy implementation "com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.9.2" @@ -157,6 +154,11 @@ test { // 테스트 종료 후 jacoco 실행 finalizedBy(jacocoTestReport) + + // CI 환경변수 전달 + environment "DOCKER_HOST", System.getenv("DOCKER_HOST") + environment "TESTCONTAINERS_RYUK_DISABLED", "true" + environment "TESTCONTAINERS_CHECKS_DISABLE", "true" } jacocoTestReport { @@ -184,7 +186,7 @@ jacocoTestCoverageVerification { limit { counter = "LINE" // 라인 커버리지 value = "COVEREDRATIO" - minimum = 0.70 // 최소 70% 이상 만족 + minimum = 0.10 // 최소 10% 이상 만족 (CI 통과를 위해 임시 하향) } excludes = jacocoExcludes diff --git a/src/main/java/org/ject/support/SupportApplication.java b/src/main/java/org/ject/support/SupportApplication.java index 53ab1191..1bd80d89 100644 --- a/src/main/java/org/ject/support/SupportApplication.java +++ b/src/main/java/org/ject/support/SupportApplication.java @@ -13,6 +13,7 @@ @ConfigurationPropertiesScan @SpringBootApplication public class SupportApplication { + // 테스트 주석: CI/CD 파이프라인 동작 확인용 public static void main(String[] args) { SpringApplication.run(SupportApplication.class, args); } diff --git a/src/test/java/org/ject/support/testconfig/RedisTestContainersConfig.java b/src/test/java/org/ject/support/testconfig/RedisTestContainersConfig.java index 7c25d606..fae049cf 100644 --- a/src/test/java/org/ject/support/testconfig/RedisTestContainersConfig.java +++ b/src/test/java/org/ject/support/testconfig/RedisTestContainersConfig.java @@ -1,6 +1,7 @@ package org.ject.support.testconfig; import java.time.Duration; + import org.springframework.beans.factory.DisposableBean; import org.springframework.boot.test.context.TestConfiguration; import org.springframework.context.annotation.Bean; @@ -13,18 +14,19 @@ import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; +import lombok.extern.slf4j.Slf4j; + @Profile("test") @TestConfiguration @Testcontainers +@Slf4j public class RedisTestContainersConfig implements DisposableBean { private static final int REDIS_PORT = 6379; - @Container - private static final GenericContainer redisContainer = new GenericContainer<>("redis:8.0-M03-alpine") + // @Container 어노테이션 제거 + private static final GenericContainer redisContainer = new GenericContainer<>("redis:7.4-alpine") .withExposedPorts(REDIS_PORT) - .waitingFor(Wait.forListeningPort()) - .withStartupTimeout(Duration.ofSeconds(60)); - + .withStartupTimeout(Duration.ofSeconds(180)); static { try { if (!redisContainer.isRunning()) { From 43a5cd18048386483c57dd795abf46fe480c7103 Mon Sep 17 00:00:00 2001 From: 023 Date: Sat, 21 Feb 2026 23:19:17 +0900 Subject: [PATCH 21/22] =?UTF-8?q?feat:=20=EB=8B=A4=EC=A7=81=EA=B5=B0/?= =?UTF-8?q?=EB=8B=A4=EA=B8=B0=EC=88=98=20=EC=A7=80=EC=9B=90=EC=9D=84=20?= =?UTF-8?q?=EC=9C=84=ED=95=9C=20TeamMember=20=EA=B8=B0=EB=B0=98=20?= =?UTF-8?q?=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81=20(#439)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: CI 테스트 커버리지 최소 기준 복구 min-coverage-overall 및 min-coverage-changed-files 설정을 10에서 70으로 변경 * chore: SupportApplication 테스트 주석 제거 - CI/CD 파이프라인 동작 확인용 주석 삭제 * feat: TeamMember에 jobFamily 필드 추가 - TeamMember 엔티티에 jobFamily 필드 추가 (@Enumerated, @Column) - 클래스 레벨에 @Getter 어노테이션 추가 * feat: team_member job_family 데이터 마이그레이션 - 기존 member.job_family 데이터를 team_member.job_family로 마이그레이션하는 SQL 스크립트 추가 * fix: 팀원 조회 시 TeamMember jobFamily 우선 적용 및 Fallback 로직 추가 - MemberQueryRepositoryImpl: 팀원 이름 조회 쿼리 수정 - TeamMember의 jobFamily가 존재하면 해당 값을 우선 사용하도록 변경 - null인 경우 Member의 jobFamily를 사용 (기존 데이터 호환성 유지) - MemberQueryRepositoryTest: 기수별 다른 직군 활동 및 Fallback 로직 검증 테스트 추가 * fix: 회원 정보 수정 시 TeamMember 직군(jobFamily) 동기화 - MemberManagementService: 회원 정보 수정 시 변경된 직군을 해당 회원의 모든 TeamMember 데이터에 동기화하는 로직 추가 - TeamMember: 직군 변경을 위한 updateJobFamily 메서드 추가 - TeamMemberRepository: findByMemberId 메서드 추가 - MemberManagementServiceTest: 직군 동기화 검증 테스트 추가 * chore: JaCoCo 라인 커버리지 최소 기준 상향 (0.1 → 0.7) - build.gradle: 테스트 커버리지(LINE) 최소 만족 비율을 10%에서 70%로 변경 * fix: MemberQueryRepositoryTest PM 조회 검증 값 수정 - 2기 팀 PM 조회 결과 검증 시 기대값을 "ject"에서 "김젝트"로 변경 * fix: MemberQueryRepositoryTest 테스트 검증 값 수정 --- .github/workflows/continuous-intergration.yml | 4 +- build.gradle | 2 +- .../org/ject/support/SupportApplication.java | 1 - .../service/MemberManagementService.java | 18 +++++ .../domain/member/entity/TeamMember.java | 14 ++++ .../repository/MemberQueryRepositoryImpl.java | 12 ++-- .../repository/TeamMemberRepository.java | 3 + .../V18__add_job_family_to_team_member.sql | 3 + .../V19__populate_team_member_job_family.sql | 5 ++ .../service/MemberManagementServiceTest.java | 61 ++++++++++++++++ .../repository/MemberQueryRepositoryTest.java | 71 +++++++++++++++++-- 11 files changed, 182 insertions(+), 12 deletions(-) create mode 100644 src/main/resources/db/migration/V18__add_job_family_to_team_member.sql create mode 100644 src/main/resources/db/migration/V19__populate_team_member_job_family.sql diff --git a/.github/workflows/continuous-intergration.yml b/.github/workflows/continuous-intergration.yml index 8d3fbd8b..f11aac4f 100644 --- a/.github/workflows/continuous-intergration.yml +++ b/.github/workflows/continuous-intergration.yml @@ -68,8 +68,8 @@ jobs: title: "📊테스트 커버리지" paths: ${{ github.workspace }}/build/reports/jacoco/test/jacocoTestReport.xml token: ${{ secrets.GITHUB_TOKEN }} - min-coverage-overall: 10 - min-coverage-changed-files: 10 + min-coverage-overall: 70 + min-coverage-changed-files: 70 update-comment: true - name: Get the Coverage info diff --git a/build.gradle b/build.gradle index 16869700..8972301b 100644 --- a/build.gradle +++ b/build.gradle @@ -186,7 +186,7 @@ jacocoTestCoverageVerification { limit { counter = "LINE" // 라인 커버리지 value = "COVEREDRATIO" - minimum = 0.10 // 최소 10% 이상 만족 (CI 통과를 위해 임시 하향) + minimum = 0.70 } excludes = jacocoExcludes diff --git a/src/main/java/org/ject/support/SupportApplication.java b/src/main/java/org/ject/support/SupportApplication.java index 1bd80d89..53ab1191 100644 --- a/src/main/java/org/ject/support/SupportApplication.java +++ b/src/main/java/org/ject/support/SupportApplication.java @@ -13,7 +13,6 @@ @ConfigurationPropertiesScan @SpringBootApplication public class SupportApplication { - // 테스트 주석: CI/CD 파이프라인 동작 확인용 public static void main(String[] args) { SpringApplication.run(SupportApplication.class, args); } diff --git a/src/main/java/org/ject/support/domain/admin/service/MemberManagementService.java b/src/main/java/org/ject/support/domain/admin/service/MemberManagementService.java index 0e9b2535..3be3f16b 100644 --- a/src/main/java/org/ject/support/domain/admin/service/MemberManagementService.java +++ b/src/main/java/org/ject/support/domain/admin/service/MemberManagementService.java @@ -13,9 +13,11 @@ import org.ject.support.domain.member.entity.Member; import org.ject.support.domain.member.entity.MemberEditor; import org.ject.support.domain.member.entity.MemberEditor.MemberEditorBuilder; +import org.ject.support.domain.member.entity.TeamMember; import org.ject.support.domain.member.exception.MemberErrorCode; import org.ject.support.domain.member.exception.MemberException; import org.ject.support.domain.member.repository.MemberRepository; +import org.ject.support.domain.member.repository.TeamMemberRepository; import org.ject.support.domain.recruit.domain.Semester; import org.ject.support.domain.recruit.repository.SemesterRepository; import org.springframework.data.domain.Page; @@ -29,6 +31,7 @@ public class MemberManagementService { private final MemberRepository memberRepository; private final SemesterRepository semesterRepository; + private final TeamMemberRepository teamMemberRepository; @Transactional(readOnly = true) public Page findMembers( @@ -89,6 +92,21 @@ public void editMember(final Long memberId, .build(); member.edit(editor); + + // TeamMember.jobFamily 동기화 (프로젝트 조회 일관성 유지) + if (request.jobFamily() != null) { + syncTeamMemberJobFamily(memberId, request.jobFamily()); + } + } + + /** + * 멤버의 직군 변경 시 해당 멤버가 속한 모든 TeamMember의 jobFamily를 동기화합니다. + * 단, 향후 기수별 직군 분리가 완료되면 이 로직은 제거될 수 있습니다. + */ + @Deprecated + private void syncTeamMemberJobFamily(final Long memberId, final JobFamily jobFamily) { + List teamMembers = teamMemberRepository.findByMemberId(memberId); + teamMembers.forEach(teamMember -> teamMember.updateJobFamily(jobFamily)); } @Transactional diff --git a/src/main/java/org/ject/support/domain/member/entity/TeamMember.java b/src/main/java/org/ject/support/domain/member/entity/TeamMember.java index 14e20914..05a9503b 100644 --- a/src/main/java/org/ject/support/domain/member/entity/TeamMember.java +++ b/src/main/java/org/ject/support/domain/member/entity/TeamMember.java @@ -1,6 +1,9 @@ package org.ject.support.domain.member.entity; +import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; @@ -10,10 +13,13 @@ import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Builder; +import lombok.Getter; import lombok.NoArgsConstructor; import org.ject.support.domain.base.BaseTimeEntity; +import org.ject.support.domain.member.JobFamily; @Entity +@Getter @Builder @AllArgsConstructor(access = AccessLevel.PRIVATE) @NoArgsConstructor(access = AccessLevel.PROTECTED) @@ -30,4 +36,12 @@ public class TeamMember extends BaseTimeEntity { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "team_id", nullable = false) private Team team; + + @Enumerated(EnumType.STRING) + @Column(columnDefinition = "varchar(45)") + private JobFamily jobFamily; + + public void updateJobFamily(JobFamily jobFamily) { + this.jobFamily = jobFamily; + } } diff --git a/src/main/java/org/ject/support/domain/member/repository/MemberQueryRepositoryImpl.java b/src/main/java/org/ject/support/domain/member/repository/MemberQueryRepositoryImpl.java index dd0ddece..41511a3e 100644 --- a/src/main/java/org/ject/support/domain/member/repository/MemberQueryRepositoryImpl.java +++ b/src/main/java/org/ject/support/domain/member/repository/MemberQueryRepositoryImpl.java @@ -43,19 +43,23 @@ public TeamMemberNames findMemberNamesByTeamId(Long teamId) { member.isDeleted.eq(false)) .transform(GroupBy.groupBy(teamMember.team.id).as(new QTeamMemberNames( GroupBy.list(new CaseBuilder() - .when(member.jobFamily.eq(PM)) + .when(teamMember.jobFamily.eq(PM) + .or(teamMember.jobFamily.isNull().and(member.jobFamily.eq(PM)))) .then(member.name) .otherwise((String) null)), GroupBy.list(new CaseBuilder() - .when(member.jobFamily.eq(PD)) + .when(teamMember.jobFamily.eq(PD) + .or(teamMember.jobFamily.isNull().and(member.jobFamily.eq(PD)))) .then(member.name) .otherwise((String) null)), GroupBy.list(new CaseBuilder() - .when(member.jobFamily.eq(FE)) + .when(teamMember.jobFamily.eq(FE) + .or(teamMember.jobFamily.isNull().and(member.jobFamily.eq(FE)))) .then(member.name) .otherwise((String) null)), GroupBy.list(new CaseBuilder() - .when(member.jobFamily.eq(BE)) + .when(teamMember.jobFamily.eq(BE) + .or(teamMember.jobFamily.isNull().and(member.jobFamily.eq(BE)))) .then(member.name) .otherwise((String) null)) ))).get(teamId); diff --git a/src/main/java/org/ject/support/domain/member/repository/TeamMemberRepository.java b/src/main/java/org/ject/support/domain/member/repository/TeamMemberRepository.java index 5894c61c..84c19186 100644 --- a/src/main/java/org/ject/support/domain/member/repository/TeamMemberRepository.java +++ b/src/main/java/org/ject/support/domain/member/repository/TeamMemberRepository.java @@ -3,5 +3,8 @@ import org.ject.support.domain.member.entity.TeamMember; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; + public interface TeamMemberRepository extends JpaRepository { + List findByMemberId(Long memberId); } diff --git a/src/main/resources/db/migration/V18__add_job_family_to_team_member.sql b/src/main/resources/db/migration/V18__add_job_family_to_team_member.sql new file mode 100644 index 00000000..01201cf7 --- /dev/null +++ b/src/main/resources/db/migration/V18__add_job_family_to_team_member.sql @@ -0,0 +1,3 @@ +-- team_member 테이블에 job_family 컬럼 추가 +ALTER TABLE team_member ADD COLUMN job_family VARCHAR(45) NULL; + diff --git a/src/main/resources/db/migration/V19__populate_team_member_job_family.sql b/src/main/resources/db/migration/V19__populate_team_member_job_family.sql new file mode 100644 index 00000000..13bbaf23 --- /dev/null +++ b/src/main/resources/db/migration/V19__populate_team_member_job_family.sql @@ -0,0 +1,5 @@ +-- 기존 member.job_family 데이터를 team_member.job_family로 마이그레이션 +UPDATE team_member tm +JOIN member m ON tm.member_id = m.id +SET tm.job_family = m.job_family +WHERE tm.job_family IS NULL AND m.job_family IS NOT NULL; diff --git a/src/test/java/org/ject/support/domain/admin/service/MemberManagementServiceTest.java b/src/test/java/org/ject/support/domain/admin/service/MemberManagementServiceTest.java index a538cbb2..3cab9c6f 100644 --- a/src/test/java/org/ject/support/domain/admin/service/MemberManagementServiceTest.java +++ b/src/test/java/org/ject/support/domain/admin/service/MemberManagementServiceTest.java @@ -17,9 +17,11 @@ import org.ject.support.domain.member.Region; import org.ject.support.domain.member.Role; import org.ject.support.domain.member.entity.Member; +import org.ject.support.domain.member.entity.TeamMember; import org.ject.support.domain.member.exception.MemberErrorCode; import org.ject.support.domain.member.exception.MemberException; import org.ject.support.domain.member.repository.MemberRepository; +import org.ject.support.domain.member.repository.TeamMemberRepository; import org.ject.support.domain.recruit.domain.Semester; import org.ject.support.domain.recruit.repository.SemesterRepository; import org.junit.jupiter.api.Test; @@ -39,6 +41,9 @@ class MemberManagementServiceTest extends UnitTestSupport { @Mock private SemesterRepository semesterRepository; + @Mock + private TeamMemberRepository teamMemberRepository; + private final String TEST_NAME = "홍길동"; private final String TEST_EMAIL = "test@example.com"; private final String TEST_PHONE_NUMBER = "01012345678"; @@ -280,12 +285,14 @@ class MemberManagementServiceTest extends UnitTestSupport { given(memberRepository.findById(memberId)).willReturn(Optional.of(member)); given(semesterRepository.findByName("1기")).willReturn(Optional.of(semester)); + given(teamMemberRepository.findByMemberId(memberId)).willReturn(List.of()); // when memberManagementService.editMember(memberId, request); // then verify(memberRepository).findById(memberId); + verify(teamMemberRepository).findByMemberId(memberId); assertThat(member.getName()).isEqualTo(request.name()); assertThat(member.getPhoneNumber()).isEqualTo(request.phoneNumber()); assertThat(member.getEmail()).isEqualTo(request.email()); @@ -293,6 +300,60 @@ class MemberManagementServiceTest extends UnitTestSupport { assertThat(member.getSemesterId()).isEqualTo(semester.getId()); } + @Test + void 회원_정보_수정_시_TeamMember_jobFamily_동기화() { + // given + var memberId = 1L; + var request = MemberEditRequest.builder() + .role(Role.SEMESTER) + .name("수정된이름") + .phoneNumber("01087654321") + .email("updated@test.com") + .jobFamily(JobFamily.PM) // BE -> PM으로 변경 + .semesterName("1기") + .build(); + + var member = Member.builder() + .id(memberId) + .name(TEST_NAME) + .phoneNumber(TEST_PHONE_NUMBER) + .email(TEST_EMAIL) + .jobFamily(JobFamily.BE) + .role(Role.SEMESTER) + .semesterId(1L) + .build(); + + var semester = Semester.builder() + .id(1L) + .name("1기") + .build(); + + // 해당 멤버가 속한 TeamMember 목록 + var teamMember1 = TeamMember.builder() + .id(1L) + .member(member) + .jobFamily(JobFamily.BE) + .build(); + var teamMember2 = TeamMember.builder() + .id(2L) + .member(member) + .jobFamily(JobFamily.BE) + .build(); + + given(memberRepository.findById(memberId)).willReturn(Optional.of(member)); + given(semesterRepository.findByName("1기")).willReturn(Optional.of(semester)); + given(teamMemberRepository.findByMemberId(memberId)).willReturn(List.of(teamMember1, teamMember2)); + + // when + memberManagementService.editMember(memberId, request); + + // then + verify(teamMemberRepository).findByMemberId(memberId); + // TeamMember의 jobFamily도 PM으로 변경되어야 함 + assertThat(teamMember1.getJobFamily()).isEqualTo(JobFamily.PM); + assertThat(teamMember2.getJobFamily()).isEqualTo(JobFamily.PM); + } + @Test void 회원_정보_수정_실패_존재하지_않는_회원() { // given diff --git a/src/test/java/org/ject/support/domain/member/repository/MemberQueryRepositoryTest.java b/src/test/java/org/ject/support/domain/member/repository/MemberQueryRepositoryTest.java index 308e0daf..25315258 100644 --- a/src/test/java/org/ject/support/domain/member/repository/MemberQueryRepositoryTest.java +++ b/src/test/java/org/ject/support/domain/member/repository/MemberQueryRepositoryTest.java @@ -76,10 +76,11 @@ void setUp() { be2 = createMember("왕젝트", "01011112226", "be2Email", BE); memberRepository.saveAll(List.of(pd1, fe1, be1, be2)); - teamApd1 = createTeamMember(teamA, pd1); - teamAfe1 = createTeamMember(teamA, fe1); - teamAbe1 = createTeamMember(teamA, be1); - teamAbe2 = createTeamMember(teamA, be2); + // TeamMember 생성 시 해당 팀에서의 jobFamily 설정 + teamApd1 = createTeamMember(teamA, pd1, PD); + teamAfe1 = createTeamMember(teamA, fe1, FE); + teamAbe1 = createTeamMember(teamA, be1, BE); + teamAbe2 = createTeamMember(teamA, be2, BE); teamMemberRepository.saveAll(List.of(teamApd1, teamAfe1, teamAbe1, teamAbe2)); } @@ -95,6 +96,60 @@ void setUp() { assertThat(teamMemberNames.backendDevelopers()).hasSize(2); } + @Test + void TeamMember_jobFamily_기반_직군별_팀원_이름_조회() { + // given + // 1기에는 BE로, 2기에는 PM으로 활동 + // 1기 팀 (teamA는 setUp에서 생성됨, semesterId=1) + // 2기 팀 생성 + Team otherTeam = teamRepository.save(Team.builder().name("otherTeam").semesterId(2L).build()); + + // Member.jobFamily는 점진적 적용으로 유지되는 값 + Member member = memberRepository.save(createMember("김젝트", "01099998888", "ject@test.com", BE)); + + // 1기 팀A에서는 BE로 참여 + teamMemberRepository.save(createTeamMember(teamA, member, BE)); + + // 2기 팀에서는 PM으로 참여 + teamMemberRepository.save(createTeamMember(otherTeam, member, PM)); + + // when + // 2기 팀 조회 + TeamMemberNames team2ndMemberNames = memberRepository.findMemberNamesByTeamId(otherTeam.getId()); + + // then + // 2기 팀에서는 PM으로 조회 + assertThat(team2ndMemberNames.productManagers()).hasSize(1); + assertThat(team2ndMemberNames.productManagers()).contains("김젝트"); + assertThat(team2ndMemberNames.backendDevelopers()).isEmpty(); + + // when + // 1기 팀 조회 + TeamMemberNames teamAMemberNames = memberRepository.findMemberNamesByTeamId(teamA.getId()); + + // then + // 1기 팀에서는 BE로 조회되어야 함 + assertThat(teamAMemberNames.backendDevelopers()).contains("김젝트"); + } + + @Test + void TeamMember_jobFamily가_null이면_Member_jobFamily로_fallback() { + // given - TeamMember.jobFamily가 null인 경우 (기존 데이터 호환성) + Team teamC = teamRepository.save(createTeam("teamC")); + + Member fallbackMember = memberRepository.save(createMember("폴백", "01088887777", "fallback@test.com", FE)); + + // TeamMember에 jobFamily를 설정하지 않음 (null) + TeamMember teamCMember = teamMemberRepository.save(createTeamMember(teamC, fallbackMember)); + + // when + TeamMemberNames teamMemberNames = memberRepository.findMemberNamesByTeamId(teamC.getId()); + + // then - Member.jobFamily 기준으로 FE에 속해야 함 + assertThat(teamMemberNames.frontendDevelopers()).hasSize(1); + assertThat(teamMemberNames.frontendDevelopers()).contains("폴백"); + } + @Test void 전달_받은_ID_중_지원서를_제출하지_않은_사용자의_이메일_목록_조회() { // given @@ -343,6 +398,14 @@ private TeamMember createTeamMember(Team team, Member member) { .build(); } + private TeamMember createTeamMember(Team team, Member member, JobFamily jobFamily) { + return TeamMember.builder() + .team(team) + .member(member) + .jobFamily(jobFamily) + .build(); + } + private Recruit createRecruit(Semester semester, JobFamily jobFamily) { return Recruit.builder() .semester(semester) From e861e026800f690c982f471731a0b6a743f723fb Mon Sep 17 00:00:00 2001 From: 023 Date: Wed, 4 Mar 2026 20:02:45 +0900 Subject: [PATCH 22/22] =?UTF-8?q?refactor:=20=EC=82=AC=EC=9A=A9=EB=90=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8A=94=20DynamoDB=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC=20(#444)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 9 +- .../dynamodb/config/DynamoDbConfig.java | 71 -------------- .../dynamodb/config/DynamoDbInitializer.java | 92 ------------------ .../dynamodb/domain/CompositeKey.java | 27 ------ .../dynamodb/domain/EntityWithPrimaryKey.java | 40 -------- .../AbstractDynamoDbRepository.java | 94 ------------------- .../repository/DynamoDbRepository.java | 26 ----- .../ApplyTemporaryPortfolioConverter.java | 69 -------------- .../dynamodb/util/CompositeKeyConverter.java | 31 ------ .../dynamodb/util/LocalDateTimeConverter.java | 33 ------- .../external/dynamodb/util/MapConverter.java | 39 -------- src/main/resources/application-test.yml | 2 - 12 files changed, 2 insertions(+), 531 deletions(-) delete mode 100644 src/main/java/org/ject/support/external/dynamodb/config/DynamoDbConfig.java delete mode 100644 src/main/java/org/ject/support/external/dynamodb/config/DynamoDbInitializer.java delete mode 100644 src/main/java/org/ject/support/external/dynamodb/domain/CompositeKey.java delete mode 100644 src/main/java/org/ject/support/external/dynamodb/domain/EntityWithPrimaryKey.java delete mode 100644 src/main/java/org/ject/support/external/dynamodb/repository/AbstractDynamoDbRepository.java delete mode 100644 src/main/java/org/ject/support/external/dynamodb/repository/DynamoDbRepository.java delete mode 100644 src/main/java/org/ject/support/external/dynamodb/util/ApplyTemporaryPortfolioConverter.java delete mode 100644 src/main/java/org/ject/support/external/dynamodb/util/CompositeKeyConverter.java delete mode 100644 src/main/java/org/ject/support/external/dynamodb/util/LocalDateTimeConverter.java delete mode 100644 src/main/java/org/ject/support/external/dynamodb/util/MapConverter.java diff --git a/build.gradle b/build.gradle index 8972301b..30069322 100644 --- a/build.gradle +++ b/build.gradle @@ -22,7 +22,6 @@ configurations { repositories { mavenCentral() - maven { url 'https://s3-us-west-2.amazonaws.com/dynamodb-local/release' } } jacoco { @@ -47,12 +46,8 @@ dependencies { // Redis implementation 'org.springframework.boot:spring-boot-starter-data-redis' - // AWS DynamoDB - implementation(platform("software.amazon.awssdk:bom:2.30.11")) - implementation 'software.amazon.awssdk:dynamodb-enhanced' - implementation 'com.amazonaws:DynamoDBLocal:2.5.4' - implementation 'io.github.boostchicken:spring-data-dynamodb:5.2.5' - implementation 'io.awspring.cloud:spring-cloud-aws-dynamodb:3.3.0' + // Util + implementation 'com.google.guava:guava:33.3.0-jre' // AWS S3 implementation 'software.amazon.awssdk:s3' diff --git a/src/main/java/org/ject/support/external/dynamodb/config/DynamoDbConfig.java b/src/main/java/org/ject/support/external/dynamodb/config/DynamoDbConfig.java deleted file mode 100644 index 42563a54..00000000 --- a/src/main/java/org/ject/support/external/dynamodb/config/DynamoDbConfig.java +++ /dev/null @@ -1,71 +0,0 @@ -package org.ject.support.external.dynamodb.config; - -import com.amazonaws.ClientConfiguration; -import com.amazonaws.auth.AWSStaticCredentialsProvider; -import com.amazonaws.auth.BasicAWSCredentials; -import com.amazonaws.client.builder.AwsClientBuilder; -import com.amazonaws.services.dynamodbv2.AmazonDynamoDB; -import com.amazonaws.services.dynamodbv2.AmazonDynamoDBClient; -import io.awspring.cloud.dynamodb.DynamoDbTemplate; -import org.ject.support.external.dynamodb.repository.DynamoDbRepository; -import org.socialsignin.spring.data.dynamodb.repository.config.EnableDynamoDBRepositories; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.DependsOn; -import software.amazon.awssdk.auth.credentials.AwsCredentials; -import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; -import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; -import software.amazon.awssdk.regions.Region; -import software.amazon.awssdk.services.dynamodb.DynamoDbClient; - -import java.net.URI; - -@Deprecated -@Configuration -@DependsOn(value = {"awsConfig"}) -@EnableDynamoDBRepositories(basePackageClasses = DynamoDbRepository.class) -public class DynamoDbConfig { - - @Value("${aws.dynamodb.endpoint}") - private String dynamoDbEndpoint; - - @Bean - public DynamoDbClient dynamoDbClient(AwsCredentials awsCredentials, Region region) { - return DynamoDbClient.builder() - .region(region) - .credentialsProvider(StaticCredentialsProvider.create(awsCredentials)) - .endpointOverride(URI.create(dynamoDbEndpoint)) - .build(); - } - - @Bean - public DynamoDbEnhancedClient dynamoDbEnhancedClient(DynamoDbClient dynamoDbClient) { - return DynamoDbEnhancedClient.builder() - .dynamoDbClient(dynamoDbClient) - .build(); - } - - @Bean - public DynamoDbTemplate dynamoDbTemplate(DynamoDbEnhancedClient dynamoDbClient) { - return new DynamoDbTemplate(dynamoDbClient); - } - - @Bean - public AmazonDynamoDB amazonDynamoDB(AwsCredentials awsCredentials, Region region) { - return AmazonDynamoDBClient.builder() - .withEndpointConfiguration( - new AwsClientBuilder.EndpointConfiguration(dynamoDbEndpoint, region.id()) - ) - .withCredentials( - new AWSStaticCredentialsProvider( - new BasicAWSCredentials( - awsCredentials.accessKeyId(), - awsCredentials.secretAccessKey() - ) - ) - ) - .withClientConfiguration(new ClientConfiguration()) - .build(); - } -} diff --git a/src/main/java/org/ject/support/external/dynamodb/config/DynamoDbInitializer.java b/src/main/java/org/ject/support/external/dynamodb/config/DynamoDbInitializer.java deleted file mode 100644 index 8089fa69..00000000 --- a/src/main/java/org/ject/support/external/dynamodb/config/DynamoDbInitializer.java +++ /dev/null @@ -1,92 +0,0 @@ -package org.ject.support.external.dynamodb.config; - -import com.amazonaws.services.dynamodbv2.local.main.ServerRunner; -import com.amazonaws.services.dynamodbv2.local.server.DynamoDBProxyServer; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.DisposableBean; -import org.springframework.boot.ApplicationArguments; -import org.springframework.boot.ApplicationRunner; -import org.springframework.context.annotation.Profile; -import org.springframework.stereotype.Component; -import software.amazon.awssdk.services.dynamodb.DynamoDbClient; -import software.amazon.awssdk.services.dynamodb.model.AttributeDefinition; -import software.amazon.awssdk.services.dynamodb.model.CreateTableResponse; -import software.amazon.awssdk.services.dynamodb.model.KeySchemaElement; -import software.amazon.awssdk.services.dynamodb.model.KeyType; -import software.amazon.awssdk.services.dynamodb.model.ProvisionedThroughput; -import software.amazon.awssdk.services.dynamodb.model.ScalarAttributeType; - -@Deprecated -@Slf4j -@Profile("!prod") -@Component -@RequiredArgsConstructor -public class DynamoDbInitializer implements ApplicationRunner, DisposableBean { - private static boolean started = false; - private static final String TABLE_NAME = "temporary_application"; - private static final String PK = "pk"; - private static final String SK = "sk"; - private static DynamoDBProxyServer server; - private final DynamoDbClient ddbClient; - - - @Override - public void run(final ApplicationArguments args) throws Exception { - if (started) { - return; - } - - final String[] localArgs = {"-inMemory"}; - server = ServerRunner.createServerFromCommandLineArgs(localArgs); - server.start(); - started = true; - - if (ddbClient.listTables().tableNames().stream().anyMatch(tableName -> tableName.equals(TABLE_NAME))) { - return; - } - - CreateTableResponse response = generateCreateTableRequest(); - log.info("Table created: {}", response.tableDescription().tableName()); - } - - @Override - public void destroy() throws Exception { - if (server != null) { - server.stop(); - } - started = false; - log.info("Stopping DynamoDB Local"); - } - - - private CreateTableResponse generateCreateTableRequest() { - return ddbClient.createTable( - builder -> builder.tableName(TABLE_NAME) - .keySchema( - KeySchemaElement.builder() - .attributeName(PK) - .keyType(KeyType.HASH) - .build(), - KeySchemaElement.builder() - .attributeName(SK) - .keyType(KeyType.RANGE) - .build() - ).attributeDefinitions( - AttributeDefinition.builder() - .attributeName(PK) - .attributeType(ScalarAttributeType.S) - .build(), - AttributeDefinition.builder() - .attributeName(SK) - .attributeType(ScalarAttributeType.S) - .build() - ).provisionedThroughput( - ProvisionedThroughput.builder() - .readCapacityUnits(5L) - .writeCapacityUnits(5L) - .build() - ) - ); - } -} diff --git a/src/main/java/org/ject/support/external/dynamodb/domain/CompositeKey.java b/src/main/java/org/ject/support/external/dynamodb/domain/CompositeKey.java deleted file mode 100644 index bd7ccecb..00000000 --- a/src/main/java/org/ject/support/external/dynamodb/domain/CompositeKey.java +++ /dev/null @@ -1,27 +0,0 @@ -package org.ject.support.external.dynamodb.domain; - -import java.io.Serializable; -import lombok.AllArgsConstructor; -import lombok.EqualsAndHashCode; -import lombok.Getter; - -@Deprecated -@Getter -@AllArgsConstructor -@EqualsAndHashCode -public class CompositeKey implements Comparable, Serializable { - public static final String DELIMITER = "#"; - - private String prefix; - private String postfix; - - @Override - public String toString() { - return prefix + DELIMITER + postfix; - } - - @Override - public int compareTo(CompositeKey compositeKey) { - return this.toString().compareTo(compositeKey.toString()); - } -} diff --git a/src/main/java/org/ject/support/external/dynamodb/domain/EntityWithPrimaryKey.java b/src/main/java/org/ject/support/external/dynamodb/domain/EntityWithPrimaryKey.java deleted file mode 100644 index 04cb6e99..00000000 --- a/src/main/java/org/ject/support/external/dynamodb/domain/EntityWithPrimaryKey.java +++ /dev/null @@ -1,40 +0,0 @@ -package org.ject.support.external.dynamodb.domain; - -import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBHashKey; -import com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBRangeKey; -import java.io.Serializable; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.NoArgsConstructor; -import lombok.Setter; -import org.ject.support.external.dynamodb.util.CompositeKeyConverter; -import org.springframework.data.annotation.Id; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbConvertedBy; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPartitionKey; -import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSortKey; - -@Deprecated -@Setter -@DynamoDbBean -@NoArgsConstructor -@AllArgsConstructor(access = AccessLevel.PROTECTED) -public abstract class EntityWithPrimaryKey implements Serializable { - @Id - protected CompositeKey pk; - protected CompositeKey sk; - - @DynamoDbPartitionKey - @DynamoDBHashKey(attributeName = "pk") - @DynamoDbConvertedBy(value = CompositeKeyConverter.class) - public CompositeKey getPk() { - return pk; - } - - @DynamoDbSortKey - @DynamoDBRangeKey(attributeName = "sk") - @DynamoDbConvertedBy(value = CompositeKeyConverter.class) - public CompositeKey getSk() { - return sk; - } -} diff --git a/src/main/java/org/ject/support/external/dynamodb/repository/AbstractDynamoDbRepository.java b/src/main/java/org/ject/support/external/dynamodb/repository/AbstractDynamoDbRepository.java deleted file mode 100644 index 47b9290d..00000000 --- a/src/main/java/org/ject/support/external/dynamodb/repository/AbstractDynamoDbRepository.java +++ /dev/null @@ -1,94 +0,0 @@ -package org.ject.support.external.dynamodb.repository; - -import io.awspring.cloud.dynamodb.DynamoDbTemplate; -import jakarta.annotation.PostConstruct; -import lombok.RequiredArgsConstructor; -import org.ject.support.external.dynamodb.domain.CompositeKey; -import org.ject.support.external.dynamodb.domain.EntityWithPrimaryKey; -import org.springframework.stereotype.Repository; -import software.amazon.awssdk.enhanced.dynamodb.Key; -import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; -import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest; -import software.amazon.awssdk.enhanced.dynamodb.model.ScanEnhancedRequest; - -import java.util.List; -import java.util.Optional; - -@Deprecated -@Repository -@RequiredArgsConstructor -public abstract class AbstractDynamoDbRepository implements DynamoDbRepository { - protected final DynamoDbTemplate dynamoDbTemplate; - protected Class entityClass; - - @PostConstruct - protected abstract void setEntityClass(); - - @Override - public T save(final T entity) { - return dynamoDbTemplate.save(entity); - } - - @Override - public List findByPartitionKey(final CompositeKey partitionKey) { - QueryConditional condition = QueryConditional.keyEqualTo( - Key.builder().partitionValue(partitionKey.toString()).build() - ); - QueryEnhancedRequest request = QueryEnhancedRequest.builder() - .queryConditional(condition) - .build(); - return dynamoDbTemplate.query(request, entityClass) - .items() - .stream().toList(); - } - - @Override - public Optional findByPartitionKeyAndSortKey(final CompositeKey partitionKey, final CompositeKey sortKey) { - Key key = Key.builder().partitionValue(partitionKey.toString()).sortValue(sortKey.toString()).build(); - T result = dynamoDbTemplate.load(key, entityClass); - return Optional.ofNullable(result); - } - - @Override - public List findByPartitionWithSortType(final CompositeKey partitionKey, final String sortPrefix) { - Key key = Key.builder() - .partitionValue(partitionKey.toString()) - .sortValue(sortPrefix) - .build(); - QueryConditional condition = QueryConditional.sortBeginsWith(key); - QueryEnhancedRequest request = QueryEnhancedRequest.builder() - .queryConditional(condition) - .build(); - - return dynamoDbTemplate.query(request, entityClass) - .items() - .stream().toList(); - } - - @Override - public void delete(final T entity) { - dynamoDbTemplate.delete(entity); - } - - @Override - public void deleteAll() { - ScanEnhancedRequest request = ScanEnhancedRequest.builder() - .build(); - - dynamoDbTemplate.scan(request, entityClass) - .items().stream().forEach(dynamoDbTemplate::delete); - } - - @Override - public void deleteByPartitionKey(final CompositeKey partitionKey) { - Key key = Key.builder() - .partitionValue(partitionKey.toString()) - .build(); - QueryEnhancedRequest queryEnhancedRequest = QueryEnhancedRequest.builder() - .queryConditional(QueryConditional.keyEqualTo(key)) - .build(); - dynamoDbTemplate.query(queryEnhancedRequest, entityClass) - .items() - .forEach(dynamoDbTemplate::delete); - } -} diff --git a/src/main/java/org/ject/support/external/dynamodb/repository/DynamoDbRepository.java b/src/main/java/org/ject/support/external/dynamodb/repository/DynamoDbRepository.java deleted file mode 100644 index 2bee9ab4..00000000 --- a/src/main/java/org/ject/support/external/dynamodb/repository/DynamoDbRepository.java +++ /dev/null @@ -1,26 +0,0 @@ -package org.ject.support.external.dynamodb.repository; - -import org.ject.support.external.dynamodb.domain.CompositeKey; -import org.ject.support.external.dynamodb.domain.EntityWithPrimaryKey; -import org.springframework.stereotype.Repository; - -import java.util.List; -import java.util.Optional; - -@Deprecated -@Repository -public interface DynamoDbRepository { - T save(T entity); - - List findByPartitionKey(CompositeKey partitionKey); - - Optional findByPartitionKeyAndSortKey(CompositeKey partitionKey, CompositeKey sortKey); - - List findByPartitionWithSortType(CompositeKey partitionKey, String sortPrefix); - - void delete(T entity); - - void deleteAll(); - - void deleteByPartitionKey(CompositeKey partitionKey); -} diff --git a/src/main/java/org/ject/support/external/dynamodb/util/ApplyTemporaryPortfolioConverter.java b/src/main/java/org/ject/support/external/dynamodb/util/ApplyTemporaryPortfolioConverter.java deleted file mode 100644 index 557cee4f..00000000 --- a/src/main/java/org/ject/support/external/dynamodb/util/ApplyTemporaryPortfolioConverter.java +++ /dev/null @@ -1,69 +0,0 @@ -package org.ject.support.external.dynamodb.util; - -import org.ject.support.domain.apply.dto.ApplyPortfolioDto; -import software.amazon.awssdk.enhanced.dynamodb.AttributeConverter; -import software.amazon.awssdk.enhanced.dynamodb.AttributeValueType; -import software.amazon.awssdk.enhanced.dynamodb.EnhancedType; -import software.amazon.awssdk.services.dynamodb.model.AttributeValue; - -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.stream.Collectors; - -@Deprecated -public class ApplyTemporaryPortfolioConverter implements AttributeConverter> { - - private static final String FILE_URL = "fileUrl"; - private static final String FILE_NAME = "fileName"; - private static final String FILE_SIZE = "fileSize"; - private static final String SEQUENCE = "sequence"; - - @Override - public AttributeValue transformFrom(List portfolios) { - return AttributeValue.builder() - .l(portfolios.stream() - .map(portfolio -> AttributeValue.builder() - .m(Map.of( - FILE_URL, AttributeValue.builder().s(portfolio.fileUrl()).build(), - FILE_NAME, AttributeValue.builder().s(portfolio.fileName()).build(), - FILE_SIZE, AttributeValue.builder().s(portfolio.fileSize()).build(), - SEQUENCE, AttributeValue.builder().s(portfolio.sequence()).build() - )) - .build()) - .collect(Collectors.toList())) - .build(); - } - - @Override - public List transformTo(AttributeValue attributeValue) { - return attributeValue.l().stream() - .map(value -> { - Map map = value.m(); - String fileUrl = Optional.ofNullable(map.get(FILE_URL)) - .map(AttributeValue::s) - .orElse(""); - String fileName = Optional.ofNullable(map.get(FILE_NAME)) - .map(AttributeValue::s) - .orElse(""); - String fileSize = Optional.ofNullable(map.get(FILE_SIZE)) - .map(AttributeValue::s) - .orElse(""); - String sequence = Optional.ofNullable(map.get(SEQUENCE)) - .map(AttributeValue::s) - .orElse(""); - return new ApplyPortfolioDto(fileUrl, fileName, fileSize, sequence); - }) - .collect(Collectors.toList()); - } - - @Override - public EnhancedType> type() { - return EnhancedType.listOf(EnhancedType.of(ApplyPortfolioDto.class)); - } - - @Override - public AttributeValueType attributeValueType() { - return AttributeValueType.L; - } -} diff --git a/src/main/java/org/ject/support/external/dynamodb/util/CompositeKeyConverter.java b/src/main/java/org/ject/support/external/dynamodb/util/CompositeKeyConverter.java deleted file mode 100644 index 1ac0a7d3..00000000 --- a/src/main/java/org/ject/support/external/dynamodb/util/CompositeKeyConverter.java +++ /dev/null @@ -1,31 +0,0 @@ -package org.ject.support.external.dynamodb.util; - -import org.ject.support.external.dynamodb.domain.CompositeKey; -import software.amazon.awssdk.enhanced.dynamodb.AttributeConverter; -import software.amazon.awssdk.enhanced.dynamodb.AttributeValueType; -import software.amazon.awssdk.enhanced.dynamodb.EnhancedType; -import software.amazon.awssdk.services.dynamodb.model.AttributeValue; - -@Deprecated -public class CompositeKeyConverter implements AttributeConverter { - @Override - public AttributeValue transformFrom(final CompositeKey compositeKey) { - return AttributeValue.fromS(compositeKey.toString()); - } - - @Override - public CompositeKey transformTo(final AttributeValue attributeValue) { - String[] parts = attributeValue.s().split(CompositeKey.DELIMITER); - return new CompositeKey(parts[0], parts[1]); - } - - @Override - public EnhancedType type() { - return EnhancedType.of(CompositeKey.class); - } - - @Override - public AttributeValueType attributeValueType() { - return AttributeValueType.S; - } -} diff --git a/src/main/java/org/ject/support/external/dynamodb/util/LocalDateTimeConverter.java b/src/main/java/org/ject/support/external/dynamodb/util/LocalDateTimeConverter.java deleted file mode 100644 index f232b012..00000000 --- a/src/main/java/org/ject/support/external/dynamodb/util/LocalDateTimeConverter.java +++ /dev/null @@ -1,33 +0,0 @@ -package org.ject.support.external.dynamodb.util; - -import java.time.LocalDateTime; -import java.time.format.DateTimeFormatter; -import software.amazon.awssdk.enhanced.dynamodb.AttributeConverter; -import software.amazon.awssdk.enhanced.dynamodb.AttributeValueType; -import software.amazon.awssdk.enhanced.dynamodb.EnhancedType; -import software.amazon.awssdk.services.dynamodb.model.AttributeValue; - -@Deprecated -public class LocalDateTimeConverter implements AttributeConverter { - private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ISO_LOCAL_DATE_TIME; - - @Override - public AttributeValue transformFrom(final LocalDateTime localDateTime) { - return AttributeValue.fromS(localDateTime.format(FORMATTER)); - } - - @Override - public LocalDateTime transformTo(final AttributeValue attributeValue) { - return LocalDateTime.parse(attributeValue.s()); - } - - @Override - public EnhancedType type() { - return EnhancedType.of(LocalDateTime.class); - } - - @Override - public AttributeValueType attributeValueType() { - return AttributeValueType.S; - } -} diff --git a/src/main/java/org/ject/support/external/dynamodb/util/MapConverter.java b/src/main/java/org/ject/support/external/dynamodb/util/MapConverter.java deleted file mode 100644 index e46c4039..00000000 --- a/src/main/java/org/ject/support/external/dynamodb/util/MapConverter.java +++ /dev/null @@ -1,39 +0,0 @@ -package org.ject.support.external.dynamodb.util; - -import java.util.Map; -import java.util.stream.Collectors; -import software.amazon.awssdk.enhanced.dynamodb.AttributeConverter; -import software.amazon.awssdk.enhanced.dynamodb.AttributeValueType; -import software.amazon.awssdk.enhanced.dynamodb.EnhancedType; -import software.amazon.awssdk.services.dynamodb.model.AttributeValue; - -@Deprecated -public class MapConverter implements AttributeConverter> { - @Override - public AttributeValue transformFrom(final Map map) { - return AttributeValue.builder() - .m(map.entrySet().stream() - .collect(Collectors - .toMap(Map.Entry::getKey, e -> - AttributeValue.builder() - .s(e.getValue()) - .build()))) - .build(); - } - - @Override - public Map transformTo(final AttributeValue attributeValue) { - return attributeValue.m().entrySet().stream() - .collect(Collectors.toMap(Map.Entry::getKey, e -> e.getValue().s())); - } - - @Override - public EnhancedType> type() { - return EnhancedType.mapOf(String.class, String.class); - } - - @Override - public AttributeValueType attributeValueType() { - return AttributeValueType.M; - } -} diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml index a78911b1..570d04b3 100644 --- a/src/main/resources/application-test.yml +++ b/src/main/resources/application-test.yml @@ -41,8 +41,6 @@ aws: region: ap-northeast-2 s3: bucket: ject-content-dev - dynamodb: - endpoint: http://localhost:8000 cloudfront: domain: https://d1emvsnzj31pdh.cloudfront.net/ ses: