diff --git a/bottlenote-batch/src/main/java/app/batch/bottlenote/BatchApplication.java b/bottlenote-batch/src/main/java/app/batch/bottlenote/BatchApplication.java index ece17edc1..acc1734c3 100644 --- a/bottlenote-batch/src/main/java/app/batch/bottlenote/BatchApplication.java +++ b/bottlenote-batch/src/main/java/app/batch/bottlenote/BatchApplication.java @@ -1,14 +1,15 @@ package app.batch.bottlenote; +import app.bottlenote.support.report.service.DailyDataReportService; +import app.external.version.config.AppInfoConfig; +import app.external.webhook.config.WebhookConfig; import java.util.TimeZone; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.boot.autoconfigure.domain.EntityScan; -import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Import; -@EntityScan(basePackages = "app") -@SpringBootApplication(scanBasePackages = "app") -@ComponentScan(basePackages = "app") +@SpringBootApplication(scanBasePackages = "app.batch.bottlenote") +@Import({DailyDataReportService.class, WebhookConfig.class, AppInfoConfig.class}) public class BatchApplication { public static void main(String[] args) { TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul")); diff --git a/bottlenote-batch/src/test/java/app/batch/bottlenote/BatchApplicationContextTest.java b/bottlenote-batch/src/test/java/app/batch/bottlenote/BatchApplicationContextTest.java new file mode 100644 index 000000000..45277a6f8 --- /dev/null +++ b/bottlenote-batch/src/test/java/app/batch/bottlenote/BatchApplicationContextTest.java @@ -0,0 +1,89 @@ +package app.batch.bottlenote; + +import static org.assertj.core.api.Assertions.assertThat; + +import app.batch.bottlenote.job.ranking.BestReviewSelectionJobConfig.BestReviewQuartzJob; +import app.batch.bottlenote.job.ranking.PopularAlcoholSelectionJobConfig.PopularAlcoholQuartzJob; +import app.batch.bottlenote.job.report.DailyDataReportJobConfig.DailyDataReportQuartzJob; +import app.bottlenote.review.service.ReviewService; +import app.bottlenote.support.report.service.DailyDataReportService; +import app.bottlenote.support.report.service.ReviewReportService; +import app.bottlenote.support.report.service.UserReportService; +import app.bottlenote.user.service.AdminUserService; +import app.bottlenote.user.service.OauthService; +import app.external.version.config.AppInfoConfig; +import app.external.webhook.config.DiscordWebhookProperties; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.batch.core.Job; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.ApplicationContext; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.web.client.RestTemplate; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +@Tag("batch") +@Testcontainers +@SpringBootTest(classes = BatchApplication.class) +@DisplayName("[batch] BatchApplication context") +class BatchApplicationContextTest { + + @Container + static final MySQLContainer MYSQL = + new MySQLContainer<>(DockerImageName.parse("mysql:8.0.32")) + .withDatabaseName("bottlenote_batch") + .withUsername("root") + .withPassword("root"); + + @Autowired private ApplicationContext context; + + @DynamicPropertySource + static void registerProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", MYSQL::getJdbcUrl); + registry.add("spring.datasource.username", MYSQL::getUsername); + registry.add("spring.datasource.password", MYSQL::getPassword); + registry.add("spring.datasource.driver-class-name", MYSQL::getDriverClassName); + registry.add("spring.jpa.hibernate.ddl-auto", () -> "none"); + registry.add("spring.quartz.jdbc.initialize-schema", () -> "always"); + registry.add("spring.batch.jdbc.initialize-schema", () -> "always"); + registry.add("webhook.discord.url", () -> "https://discord.test.webhook.url"); + } + + @Test + @DisplayName("배치에 필요한 Job과 Quartz binding을 로드한다") + void contextLoadsBatchJobsAndQuartzBindings() { + assertThat(context.getBean("bestReviewSelectedJob", Job.class)).isNotNull(); + assertThat(context.getBean("popularAlcoholJob", Job.class)).isNotNull(); + assertThat(context.getBean("dailyDataReportJob", Job.class)).isNotNull(); + + assertThat(context.getBean(BestReviewQuartzJob.class)).isNotNull(); + assertThat(context.getBean(PopularAlcoholQuartzJob.class)).isNotNull(); + assertThat(context.getBean(DailyDataReportQuartzJob.class)).isNotNull(); + } + + @Test + @DisplayName("일일 리포트에 필요한 mono/external bean wiring을 유지한다") + void contextKeepsDailyReportDependencies() { + assertThat(context.getBean(DailyDataReportService.class)).isNotNull(); + assertThat(context.getBean("webhookRestTemplate", RestTemplate.class)).isNotNull(); + assertThat(context.getBean(DiscordWebhookProperties.class).getUrl()) + .isEqualTo("https://discord.test.webhook.url"); + assertThat(context.getBean(AppInfoConfig.class).getEnvironment()).isEqualTo("test"); + } + + @Test + @DisplayName("batch와 무관한 product/admin 성격의 mono bean은 로드하지 않는다") + void contextDoesNotLoadUnnecessaryMonoBeans() { + assertThat(context.getBeansOfType(OauthService.class)).isEmpty(); + assertThat(context.getBeansOfType(AdminUserService.class)).isEmpty(); + assertThat(context.getBeansOfType(ReviewService.class)).isEmpty(); + assertThat(context.getBeansOfType(UserReportService.class)).isEmpty(); + assertThat(context.getBeansOfType(ReviewReportService.class)).isEmpty(); + } +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/global/security/jwt/JwtAuthenticationFilter.java b/bottlenote-mono/src/main/java/app/bottlenote/global/security/jwt/JwtAuthenticationFilter.java index 9e7ca76f8..d300cafce 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/global/security/jwt/JwtAuthenticationFilter.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/global/security/jwt/JwtAuthenticationFilter.java @@ -122,7 +122,7 @@ private Optional resolveToken(HttpServletRequest request) { @Override protected boolean shouldNotFilter(HttpServletRequest request) { String path = request.getRequestURI(); - List excludePath = List.of("login", "guest-login", "regions", "alcohols/categories"); + List excludePath = List.of("login", "regions", "alcohols/categories"); return excludePath.stream().anyMatch(path::contains); } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/global/security/jwt/JwtTokenProvider.java b/bottlenote-mono/src/main/java/app/bottlenote/global/security/jwt/JwtTokenProvider.java index 4ec3f0ab4..c0736b622 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/global/security/jwt/JwtTokenProvider.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/global/security/jwt/JwtTokenProvider.java @@ -68,20 +68,6 @@ public String createAccessToken(String userEmail, UserType role, Long userId) { .compact(); } - public String createGuestToken(Long userId, int expireTime) { - Claims claims = Jwts.claims(); - claims.put("userId", userId); - claims.put(KEY_ROLES, UserType.ROLE_GUEST.name()); - Date now = new Date(); - return Jwts.builder() - .setClaims(claims) - .setSubject("guest") - .setIssuedAt(now) - .setExpiration(new Date(now.getTime() + expireTime)) - .signWith(secretKey, SignatureAlgorithm.HS512) - .compact(); - } - /** * 필수적인 파라미터를 받아 리프레시 토큰을 생성하는 메소드 * diff --git a/bottlenote-mono/src/main/java/app/bottlenote/user/config/OauthConfigProperties.java b/bottlenote-mono/src/main/java/app/bottlenote/user/config/OauthConfigProperties.java index 52c4247fd..738de45d2 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/user/config/OauthConfigProperties.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/user/config/OauthConfigProperties.java @@ -23,11 +23,9 @@ public class OauthConfigProperties { private String refreshTokenHeaderPrefix; private int cookieExpireTime; - private String guestCode; public void printConfigs() { log.info("refreshTokenHeaderPrefix: {}", refreshTokenHeaderPrefix); log.info("cookieExpireTime: {}", cookieExpireTime); - log.info("guestCode: {}", guestCode); } } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/user/dto/request/BasicAccountRequest.java b/bottlenote-mono/src/main/java/app/bottlenote/user/dto/request/BasicAccountRequest.java deleted file mode 100644 index 06f3291c0..000000000 --- a/bottlenote-mono/src/main/java/app/bottlenote/user/dto/request/BasicAccountRequest.java +++ /dev/null @@ -1,27 +0,0 @@ -package app.bottlenote.user.dto.request; - -import app.bottlenote.user.constant.GenderType; -import jakarta.validation.constraints.Min; -import jakarta.validation.constraints.NotBlank; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -/** 해당 클래스는 사용자의 기본 계정 정보를 요청하는 DTO 클래스입니다. */ -@Builder -@Getter -@AllArgsConstructor -@NoArgsConstructor -public class BasicAccountRequest { - @NotBlank(message = "EMAIL_IS_REQUIRED") - private String email; - - @NotBlank(message = "PASSWORD_IS_REQUIRED") - private String password; - - @Min(value = 0, message = "AGE_NEED_OVER_19") - private Integer age; - - private GenderType gender; -} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/user/dto/request/GuestCodeRequest.java b/bottlenote-mono/src/main/java/app/bottlenote/user/dto/request/GuestCodeRequest.java deleted file mode 100644 index 85e56650b..000000000 --- a/bottlenote-mono/src/main/java/app/bottlenote/user/dto/request/GuestCodeRequest.java +++ /dev/null @@ -1,9 +0,0 @@ -package app.bottlenote.user.dto.request; - -import jakarta.validation.constraints.NotBlank; - -public record GuestCodeRequest(@NotBlank(message = "REQUIRED_GUEST_CODE") String code) { - public static GuestCodeRequest of(String code) { - return new GuestCodeRequest(code); - } -} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/user/dto/response/BasicAccountResponse.java b/bottlenote-mono/src/main/java/app/bottlenote/user/dto/response/BasicAccountResponse.java deleted file mode 100644 index 82f03429a..000000000 --- a/bottlenote-mono/src/main/java/app/bottlenote/user/dto/response/BasicAccountResponse.java +++ /dev/null @@ -1,7 +0,0 @@ -package app.bottlenote.user.dto.response; - -import lombok.Builder; - -@Builder -public record BasicAccountResponse( - String message, String email, String nickname, String accessToken, String refreshToken) {} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/user/repository/OauthRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/user/repository/OauthRepository.java index 7043f2629..04ea1b08d 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/user/repository/OauthRepository.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/user/repository/OauthRepository.java @@ -31,16 +31,6 @@ Optional findByEmailAndSocialType( @Query("select u from users u order by u.id limit 1") Optional getFirstUser(); - @Query( - """ - SELECT u - FROM users u - WHERE u.role = 'ROLE_GUEST' - ORDER BY u.id - LIMIT 1 - """) - Optional loadGuestUser(); - @Query("select count (u)+1 from users u") String getNextNicknameSequence(); } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/user/service/OauthService.java b/bottlenote-mono/src/main/java/app/bottlenote/user/service/OauthService.java index 5d2fdc994..a586897db 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/user/service/OauthService.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/user/service/OauthService.java @@ -6,12 +6,10 @@ import app.bottlenote.global.security.jwt.JwtAuthenticationManager; import app.bottlenote.global.security.jwt.JwtTokenProvider; import app.bottlenote.global.security.jwt.TokenValidator; -import app.bottlenote.user.constant.GenderType; import app.bottlenote.user.constant.SocialType; import app.bottlenote.user.constant.UserType; import app.bottlenote.user.domain.User; import app.bottlenote.user.dto.request.OauthRequest; -import app.bottlenote.user.dto.response.BasicAccountResponse; import app.bottlenote.user.dto.response.TokenItem; import app.bottlenote.user.exception.UserException; import app.bottlenote.user.exception.UserExceptionCode; @@ -156,23 +154,6 @@ public void restoreUser(String email, String password) { user.restore(); } - @Transactional - public String guestLogin() { - final int expireTime = 1000 * 60 * 60 * 24; - OauthRequest oauthRequest = - new OauthRequest( - "guest" + UUID.randomUUID() + "@bottlenote.com", - "socialUniqueId" + UUID.randomUUID(), - SocialType.APPLE, - GenderType.MALE, - 30); - User guest = - oauthRepository - .loadGuestUser() - .orElseGet(() -> oauthSignUp(oauthRequest, UserType.ROLE_GUEST)); - return tokenProvider.createGuestToken(guest.getId(), expireTime); - } - private User oauthSignUp(OauthRequest oauthRequest, UserType userType) { String socialUniqueId = oauthRequest.socialUniqueId(); @@ -228,64 +209,6 @@ public TokenItem refresh(String refreshToken) { return reissuedToken; } - @Transactional - public BasicAccountResponse basicSignup( - String email, String password, Integer age, GenderType gender) { - oauthRepository - .findByEmail(email) - .ifPresent( - user -> { - throw new UserException(UserExceptionCode.USER_ALREADY_EXISTS); - }); - - String encodePassword = passwordEncoder.encode(password); - User user = - oauthRepository.save( - User.builder() - .email(email) - .password(encodePassword) - .role(UserType.ROLE_USER) - .socialType(List.of(SocialType.BASIC)) - .nickName(generateNickname()) - .age(age) - .gender(gender) - .build()); - - TokenItem token = tokenProvider.generateToken(user.getEmail(), user.getRole(), user.getId()); - user.updateRefreshToken(token.refreshToken()); - - log.info( - "기본 회원가입 완료: email={}, userId={}, nickname={}", - user.getEmail(), - user.getId(), - user.getNickName()); - - return BasicAccountResponse.builder() - .message(user.getNickName() + "님 환영합니다!") - .email(user.getEmail()) - .nickname(user.getNickName()) - .accessToken(token.accessToken()) - .refreshToken(token.refreshToken()) - .build(); - } - - @Transactional - public TokenItem basicLogin(String email, String password) { - User user = - oauthRepository - .findByEmail(email) - .orElseThrow(() -> new UserException(UserExceptionCode.USER_NOT_FOUND)); - if (!passwordEncoder.matches(password, user.getPassword())) { - throw new UserException(UserExceptionCode.INVALID_PASSWORD); - } - - checkActiveUser(user); - - TokenItem token = tokenProvider.generateToken(user.getEmail(), user.getRole(), user.getId()); - user.updateRefreshToken(token.refreshToken()); - return token; - } - protected String generateNickname() { List a = Arrays.asList("부드러운", "향기로운", "숙성된", "풍부한", "깊은", "황금빛", "오크향의", "스모키한", "달콤한", "강렬한"); diff --git a/bottlenote-mono/src/test/java/app/bottlenote/config/TestConfigProperties.java b/bottlenote-mono/src/test/java/app/bottlenote/config/TestConfigProperties.java index ff8a0d30b..db5dcd894 100644 --- a/bottlenote-mono/src/test/java/app/bottlenote/config/TestConfigProperties.java +++ b/bottlenote-mono/src/test/java/app/bottlenote/config/TestConfigProperties.java @@ -10,7 +10,6 @@ public class TestConfigProperties { @Bean public OauthConfigProperties oauthConfigProperties() { return OauthConfigProperties.builder() - .guestCode("guest-code-for-test") .cookieExpireTime(100000) .refreshTokenHeaderPrefix("refresh-token") .build(); diff --git a/bottlenote-product-api/src/docs/asciidoc/api/follow/follow-search-follower-list.adoc b/bottlenote-product-api/src/docs/asciidoc/api/follow/follow-search-follower-list.adoc index 9eb14552d..b8d69933c 100644 --- a/bottlenote-product-api/src/docs/asciidoc/api/follow/follow-search-follower-list.adoc +++ b/bottlenote-product-api/src/docs/asciidoc/api/follow/follow-search-follower-list.adoc @@ -24,12 +24,11 @@ GET /api/v1/follow/{userId}/following-list [discrete] ==== 요청 파라미터 ==== -include::{snippets}/follow/search/query-parameters.adoc[] +include::{snippets}/follow/follower-list/query-parameters.adoc[] [discrete] ==== 응답 파라미터 ==== -include::{snippets}/follow/search/response-fields.adoc[] -include::{snippets}/follow/search/response-body.adoc[] - +include::{snippets}/follow/follower-list/response-fields.adoc[] +include::{snippets}/follow/follower-list/response-body.adoc[] diff --git a/bottlenote-product-api/src/docs/asciidoc/api/follow/follow-search-following-list.adoc b/bottlenote-product-api/src/docs/asciidoc/api/follow/follow-search-following-list.adoc index 666064a7a..81e933320 100644 --- a/bottlenote-product-api/src/docs/asciidoc/api/follow/follow-search-following-list.adoc +++ b/bottlenote-product-api/src/docs/asciidoc/api/follow/follow-search-following-list.adoc @@ -24,12 +24,11 @@ GET /api/v1/follow/{userId}/follower-list [discrete] ==== 요청 파라미터 ==== -include::{snippets}/follow/search/query-parameters.adoc[] +include::{snippets}/follow/following-list/query-parameters.adoc[] [discrete] ==== 응답 파라미터 ==== -include::{snippets}/follow/search/response-fields.adoc[] -include::{snippets}/follow/search/response-body.adoc[] - +include::{snippets}/follow/following-list/response-fields.adoc[] +include::{snippets}/follow/following-list/response-body.adoc[] diff --git a/bottlenote-product-api/src/docs/asciidoc/api/user/user-guest-login.adoc b/bottlenote-product-api/src/docs/asciidoc/api/user/user-guest-login.adoc deleted file mode 100644 index a832707fb..000000000 --- a/bottlenote-product-api/src/docs/asciidoc/api/user/user-guest-login.adoc +++ /dev/null @@ -1,52 +0,0 @@ -=== 베이직 회원가입 === - -[.line-through]#게스트 로그인 API# - -베이직 회원가입 API - -[source] ----- -POST /api/v1/oauth/basic/signup ----- - -include::{snippets}/user/basic-signup/curl-request.adoc[] - -[discrete] -==== 요청 파라미터 ==== - -[discrete] -include::{snippets}/user/basic-signup/request-fields.adoc[] -include::{snippets}/user/basic-signup/request-body.adoc[] - -[discrete] -==== 응답 파라미터 ==== - -[discrete] -include::{snippets}/user/basic-signup/response-fields.adoc[] -include::{snippets}/user/basic-signup/response-body.adoc[] - -=== 베이직 로그인 === - -베이직 회원가입 API - -[source] ----- -POST /api/v1/oauth/basic/login ----- - -include::{snippets}/user/basic-login/curl-request.adoc[] - - -[discrete] -==== 요청 파라미터 ==== - -[discrete] -include::{snippets}/user/basic-login/request-fields.adoc[] -include::{snippets}/user/basic-login/request-body.adoc[] - -[discrete] -==== 응답 파라미터 ==== - -[discrete] -include::{snippets}/user/basic-login/response-fields.adoc[] -include::{snippets}/user/basic-login/response-body.adoc[] diff --git a/bottlenote-product-api/src/docs/asciidoc/product-api.adoc b/bottlenote-product-api/src/docs/asciidoc/product-api.adoc index db4722f19..39b452345 100644 --- a/bottlenote-product-api/src/docs/asciidoc/product-api.adoc +++ b/bottlenote-product-api/src/docs/asciidoc/product-api.adoc @@ -43,9 +43,6 @@ include::api/auth/auth.adoc[] ''' include::api/user/user-login.adoc[] -''' -include::api/user/user-guest-login.adoc[] - ''' include::api/user/user-reissue.adoc[] diff --git a/bottlenote-product-api/src/main/java/app/bottlenote/user/controller/OauthController.java b/bottlenote-product-api/src/main/java/app/bottlenote/user/controller/OauthController.java index c7642f32b..66e314c78 100644 --- a/bottlenote-product-api/src/main/java/app/bottlenote/user/controller/OauthController.java +++ b/bottlenote-product-api/src/main/java/app/bottlenote/user/controller/OauthController.java @@ -2,22 +2,16 @@ import app.bottlenote.global.data.response.GlobalResponse; import app.bottlenote.user.config.OauthConfigProperties; -import app.bottlenote.user.dto.request.BasicAccountRequest; import app.bottlenote.user.dto.request.BasicLoginRequest; -import app.bottlenote.user.dto.request.GuestCodeRequest; import app.bottlenote.user.dto.request.OauthRequest; import app.bottlenote.user.dto.request.TokenVerifyRequest; -import app.bottlenote.user.dto.response.BasicAccountResponse; import app.bottlenote.user.dto.response.OauthResponse; import app.bottlenote.user.dto.response.TokenItem; -import app.bottlenote.user.exception.UserException; -import app.bottlenote.user.exception.UserExceptionCode; import app.bottlenote.user.service.OauthService; import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; -import java.util.Base64; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; @@ -36,24 +30,6 @@ public class OauthController { private final OauthService oauthService; private final OauthConfigProperties configProperties; - @PostMapping("/basic/signup") - public ResponseEntity executeBasicSignup( - @RequestBody @Valid BasicAccountRequest request, HttpServletResponse response) { - BasicAccountResponse token = - oauthService.basicSignup( - request.getEmail(), request.getPassword(), request.getAge(), request.getGender()); - setRefreshTokenInCookie(response, token.refreshToken()); - return GlobalResponse.ok(token); - } - - @PostMapping("/basic/login") - public ResponseEntity executeBasicLogin( - @RequestBody @Valid BasicLoginRequest request, HttpServletResponse response) { - TokenItem token = oauthService.basicLogin(request.getEmail(), request.getPassword()); - setRefreshTokenInCookie(response, token.refreshToken()); - return GlobalResponse.ok(OauthResponse.of(token.accessToken())); - } - @PostMapping("/login") public ResponseEntity executeOauthLogin( @RequestBody @Valid OauthRequest oauthReq, HttpServletResponse response) { @@ -62,21 +38,6 @@ public ResponseEntity executeOauthLogin( return GlobalResponse.ok(OauthResponse.of(token.accessToken())); } - @PostMapping("/guest-login") - public ResponseEntity executeGuestLogin(@RequestBody @Valid GuestCodeRequest guestCode) { - final String key = - Base64.getEncoder().encodeToString(configProperties.getGuestCode().getBytes()); - final String code = guestCode.code(); - - if (!code.equals(key)) { - throw new UserException(UserExceptionCode.NOT_MATCH_GUEST_CODE); - } - - final String token = oauthService.guestLogin(); - final OauthResponse oauthResponse = OauthResponse.of(token); - return GlobalResponse.ok(oauthResponse); - } - @PostMapping("/reissue") public ResponseEntity reissueOauthToken( HttpServletRequest request, HttpServletResponse response) { diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/user/fake/FakeOauthRepository.java b/bottlenote-product-api/src/test/java/app/bottlenote/user/fake/FakeOauthRepository.java index dc29df717..f2353c43d 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/user/fake/FakeOauthRepository.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/user/fake/FakeOauthRepository.java @@ -33,11 +33,6 @@ public Optional getFirstUser() { return Optional.empty(); } - @Override - public Optional loadGuestUser() { - return Optional.empty(); - } - @Override public String getNextNicknameSequence() { return "User" + (nicknameSequence++); diff --git a/bottlenote-product-api/src/test/java/app/docs/follow/RestDocsFollowControllerTest.java b/bottlenote-product-api/src/test/java/app/docs/follow/RestDocsFollowControllerTest.java index 07aaf116c..9e4d6f493 100644 --- a/bottlenote-product-api/src/test/java/app/docs/follow/RestDocsFollowControllerTest.java +++ b/bottlenote-product-api/src/test/java/app/docs/follow/RestDocsFollowControllerTest.java @@ -137,7 +137,7 @@ void docs_2() throws Exception { .andExpect(status().isOk()) .andDo( document( - "follow/search", + "follow/following-list", queryParameters( parameterWithName("type").optional().description("팔로잉 or 팔로워 조회 여부 쿼리파라미터"), parameterWithName("cursor").optional().description("조회 할 시작 기준 위치"), @@ -190,7 +190,7 @@ void docs_3() throws Exception { .andExpect(status().isOk()) .andDo( document( - "follow/search", + "follow/follower-list", queryParameters( parameterWithName("type").optional().description("팔로잉 or 팔로워 조회 여부 쿼리파라미터"), parameterWithName("cursor").optional().description("조회 할 시작 기준 위치"), diff --git a/bottlenote-product-api/src/test/java/app/docs/user/RestOauthControllerTest.java b/bottlenote-product-api/src/test/java/app/docs/user/RestOauthControllerTest.java index d80bf93b3..13e7116d7 100644 --- a/bottlenote-product-api/src/test/java/app/docs/user/RestOauthControllerTest.java +++ b/bottlenote-product-api/src/test/java/app/docs/user/RestOauthControllerTest.java @@ -1,6 +1,5 @@ package app.docs.user; -import static java.util.Base64.getEncoder; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -20,12 +19,9 @@ import app.bottlenote.user.constant.GenderType; import app.bottlenote.user.constant.SocialType; import app.bottlenote.user.controller.OauthController; -import app.bottlenote.user.dto.request.BasicAccountRequest; import app.bottlenote.user.dto.request.BasicLoginRequest; -import app.bottlenote.user.dto.request.GuestCodeRequest; import app.bottlenote.user.dto.request.OauthRequest; import app.bottlenote.user.dto.request.TokenVerifyRequest; -import app.bottlenote.user.dto.response.BasicAccountResponse; import app.bottlenote.user.dto.response.TokenItem; import app.bottlenote.user.service.OauthService; import app.docs.AbstractRestDocs; @@ -44,7 +40,6 @@ class RestOauthControllerTest extends AbstractRestDocs { public RestOauthControllerTest() { this.config = OauthConfigProperties.builder() - .guestCode("TEST_GUEST_CODE") .cookieExpireTime(123456) .refreshTokenHeaderPrefix("refresh-token") .build(); @@ -176,48 +171,6 @@ void reissue_test() throws Exception { responseHeaders(headerWithName("Set-Cookie").description("리프레쉬 토큰")))); } - @Test - @DisplayName("게스트 토큰을 발급 받을수 있다.") - void guest_login() throws Exception { - // given - final String guestCode = config.getGuestCode(); - final var request = GuestCodeRequest.of(getEncoder().encodeToString(guestCode.getBytes())); - final String token = "response-token"; - - // when - when(oauthService.guestLogin()).thenReturn(token); - - // then - mockMvc - .perform( - post("/api/v1/oauth/guest-login") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request)) - .with(csrf())) - .andExpect(status().isOk()) - .andDo( - document( - "user/guest-login", - requestFields(fieldWithPath("code").description("게스트 코드")), - responseFields( - fieldWithPath("success").ignored(), - fieldWithPath("code").ignored(), - fieldWithPath("errors").ignored(), - fieldWithPath("data.accessToken").description("액세스 토큰"), - fieldWithPath("data.isFirstLogin") - .type(JsonFieldType.BOOLEAN) - .description("최초 로그인 여부 (true: 최초 로그인, false: 기존 사용자)") - .optional(), - fieldWithPath("data.nickname") - .type(JsonFieldType.STRING) - .description("사용자 닉네임 (최초 로그인 시 자동 생성된 닉네임)") - .optional(), - fieldWithPath("meta.serverEncoding").description("서버 인코딩 정도"), - fieldWithPath("meta.serverVersion").description("서버 버전"), - fieldWithPath("meta.serverPathVersion").description("서버 경로 버전"), - fieldWithPath("meta.serverResponseTime").description("서버 응답 시간")))); - } - @Test @DisplayName("토큰 유효성을 검사할 수 있다.") void token_validation() throws Exception { @@ -251,111 +204,6 @@ void token_validation() throws Exception { fieldWithPath("meta.serverResponseTime").ignored()))); } - @Test - @DisplayName("베이식 회원가입을 할 수 있다.") - void basic_signup() throws Exception { - // given - final String nickName = "부드러운몰트"; - final BasicAccountRequest request = - BasicAccountRequest.builder() - .email("test@email.com") - .password("test-password") - .age(27) - .gender(null) - .build(); - - final BasicAccountResponse response = - BasicAccountResponse.builder() - .message(nickName + "님 환영합니다!") - .email(request.getEmail()) - .nickname(nickName) - .accessToken("access-token") - .refreshToken("refresh-token") - .build(); - - // when - when(oauthService.basicSignup( - request.getEmail(), request.getPassword(), request.getAge(), request.getGender())) - .thenReturn(response); - - // then - mockMvc - .perform( - post("/api/v1/oauth/basic/signup") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request)) - .with(csrf())) - .andExpect(status().isOk()) - .andDo( - document( - "user/basic-signup", - requestFields( - fieldWithPath("email").description("이메일"), - fieldWithPath("password").description("비밀번호"), - fieldWithPath("age").description("나이"), - fieldWithPath("gender").description("성별")), - responseFields( - fieldWithPath("success").ignored(), - fieldWithPath("code").ignored(), - fieldWithPath("errors").ignored(), - fieldWithPath("data").description("결과"), - fieldWithPath("data.message").description("결과 메시지"), - fieldWithPath("data.email").description("이메일"), - fieldWithPath("data.nickname").description("닉네임"), - fieldWithPath("data.accessToken").description("accessToken"), - fieldWithPath("data.refreshToken").description("refreshToken"), - fieldWithPath("meta.serverEncoding").ignored(), - fieldWithPath("meta.serverVersion").ignored(), - fieldWithPath("meta.serverPathVersion").ignored(), - fieldWithPath("meta.serverResponseTime").ignored()))); - } - - @Test - @DisplayName("베이식 로그인을 할 수 있다.") - void basic_login() throws Exception { - // given - final String nickName = "부드러운몰트"; - final BasicLoginRequest request = - BasicLoginRequest.builder().email("test@email.com").password("test-password").build(); - - final TokenItem response = TokenItem.of("access-token", "refresh-token"); - // when - when(oauthService.basicLogin(request.getEmail(), request.getPassword())).thenReturn(response); - - // then - mockMvc - .perform( - post("/api/v1/oauth/basic/login") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request)) - .with(csrf())) - .andExpect(status().isOk()) - .andDo( - document( - "user/basic-login", - requestFields( - fieldWithPath("email").description("이메일"), - fieldWithPath("password").description("비밀번호")), - responseFields( - fieldWithPath("success").ignored(), - fieldWithPath("code").ignored(), - fieldWithPath("errors").ignored(), - fieldWithPath("data").description("결과"), - fieldWithPath("data.accessToken").description("accessToken"), - fieldWithPath("data.isFirstLogin") - .type(JsonFieldType.BOOLEAN) - .description("최초 로그인 여부 (true: 최초 로그인, false: 기존 사용자)") - .optional(), - fieldWithPath("data.nickname") - .type(JsonFieldType.STRING) - .description("사용자 닉네임 (최초 로그인 시 자동 생성된 닉네임)") - .optional(), - fieldWithPath("meta.serverEncoding").ignored(), - fieldWithPath("meta.serverVersion").ignored(), - fieldWithPath("meta.serverPathVersion").ignored(), - fieldWithPath("meta.serverResponseTime").ignored()))); - } - @Test @DisplayName("회원 탈퇴를 복구 할 수 있다.") void restore() throws Exception { diff --git "a/http/product/01_\355\232\214\354\233\220\352\264\200\353\246\254/\352\260\200\354\236\205_\353\241\234\352\267\270\354\235\270/OAuth\353\240\210\352\261\260\354\213\234.http" "b/http/product/01_\355\232\214\354\233\220\352\264\200\353\246\254/\352\260\200\354\236\205_\353\241\234\352\267\270\354\235\270/OAuth\353\240\210\352\261\260\354\213\234.http" deleted file mode 100644 index c107061a1..000000000 --- "a/http/product/01_\355\232\214\354\233\220\352\264\200\353\246\254/\352\260\200\354\236\205_\353\241\234\352\267\270\354\235\270/OAuth\353\240\210\352\261\260\354\213\234.http" +++ /dev/null @@ -1,40 +0,0 @@ -### 기본 회원가입 (이메일/비밀번호) -POST {{host}}/api/v1/oauth/basic/signup -Content-Type: application/json - -{ - "email": "user@example.com", - "password": "password123!", - "age": 25, - "gender": "MALE" -} - -### 기본 로그인 (이메일/비밀번호) -POST {{host}}/api/v1/oauth/basic/login -Content-Type: application/json - -{ - "email": "user@example.com", - "password": "password123!" -} - -### 토큰 재발급 -POST {{host}}/api/v1/oauth/reissue -refresh-token: {{refreshToken}} - -### 토큰 검증 -PUT {{host}}/api/v1/oauth/token/verify -Content-Type: application/json - -{ - "token": "{{accessToken}}" -} - -### 계정 복구 -POST {{host}}/api/v1/oauth/restore -Content-Type: application/json - -{ - "email": "user@example.com", - "password": "password123!" -} diff --git "a/http/product/01_\355\232\214\354\233\220\352\264\200\353\246\254/\352\260\200\354\236\205_\353\241\234\352\267\270\354\235\270/\352\262\214\354\212\244\355\212\270\355\206\240\355\201\260\353\260\234\352\270\211.http" "b/http/product/01_\355\232\214\354\233\220\352\264\200\353\246\254/\352\260\200\354\236\205_\353\241\234\352\267\270\354\235\270/\352\262\214\354\212\244\355\212\270\355\206\240\355\201\260\353\260\234\352\270\211.http" deleted file mode 100644 index cd95e18ea..000000000 --- "a/http/product/01_\355\232\214\354\233\220\352\264\200\353\246\254/\352\260\200\354\236\205_\353\241\234\352\267\270\354\235\270/\352\262\214\354\212\244\355\212\270\355\206\240\355\201\260\353\260\234\352\270\211.http" +++ /dev/null @@ -1,15 +0,0 @@ -### 게스트 토큰 발급 실패 (토큰이 다른 경우) -POST {{host}}/api/v1/oauth/guest-login -Content-Type: application/json - -{ - "code": "Qk9UVExFTk9URS1HVUVTVC1DT0RFLTIwMjQ=" -} - -### 게스트 토큰 발급 -POST {{host}}/api/v1/oauth/guest-login -Content-Type: application/json - -{ - "code": "aW9zLWd1ZXN0LWNvZGUtNzI5MzE0" -} diff --git a/plan/batch-component-scan-scope.md b/plan/batch-component-scan-scope.md new file mode 100644 index 000000000..7e85cd4d7 --- /dev/null +++ b/plan/batch-component-scan-scope.md @@ -0,0 +1,133 @@ +# Plan: batch component scan 축소 + +## Overview + +`bottlenote-batch`의 `BatchApplication`은 현재 `@SpringBootApplication(scanBasePackages = "app")`, `@ComponentScan(basePackages = "app")`, `@EntityScan(basePackages = "app")`로 전체 `app` 패키지를 스캔한다. 이 범위는 batch 실행에 필요하지 않은 product/admin 성격의 bean, mono 내부 서비스, external 설정, observability component까지 batch application context에 로딩될 수 있게 만든다. + +이 작업은 batch application bootstrap의 component scan 범위를 명시적으로 줄여, batch job 실행에 필요한 bean만 로딩하도록 만드는 것을 목표로 한다. 구현 단계에서는 batch job 3종이 계속 기동되고, context startup이 성공하며, 필요 없는 bean이 로딩되지 않는지 테스트로 확인한다. + +### Assumptions + +- 이 작업의 대상은 `bottlenote-batch`의 application bootstrap과 그에 필요한 최소 mono/external bean wiring이다. +- product-api, admin-api의 application scan 정책은 이번 작업 범위가 아니다. +- batch job 자체의 비즈니스 로직, cron, SQL, 리소스 패키징 정책은 변경하지 않는다. +- `git.environment-variables` 서브모듈 포인터 변경은 사용자/환경 변경으로 간주하고 건드리지 않는다. +- daily report job은 현재 `DailyDataReportService`, `WebhookConfig`, `DiscordWebhookProperties`, `AppInfoConfig`가 필요하므로 이 의존성을 유지해야 한다. +- ranking job 2종은 주로 `JdbcTemplate`, `JobRepository`, `PlatformTransactionManager`, SQL classpath resource에 의존하므로 mono 전체 서비스 스캔이 필요하지 않다고 본다. +- JPA entity/repository 스캔 범위 축소는 component scan 축소와 분리해서 판단한다. 단, batch context 안정성에 직접 필요하면 최소 범위 조정을 포함할 수 있다. +- batch context 검증 테스트는 `@Tag("batch")` 또는 프로젝트 관습에 맞는 batch 전용 테스트로 작성한다. + +### Success Criteria + +- `BatchApplication`에서 전체 `app` component scan을 제거하거나, batch에 필요한 패키지 중심의 명시적 스캔 목록으로 대체한다. +- `bottlenote-batch` application context가 test profile에서 정상 기동한다. +- batch job bean 3종이 계속 등록된다: `bestReviewSelectedJob`, `popularAlcoholJob`, `dailyDataReportJob`. +- Quartz job binding 3종이 계속 등록된다: `BestReviewQuartzJob`, `PopularAlcoholQuartzJob`, `DailyDataReportQuartzJob`. +- daily report에 필요한 `DailyDataReportService`, `webhookRestTemplate`, `DiscordWebhookProperties`, `AppInfoConfig` 의존성이 깨지지 않는다. +- product/admin 전용 bean 또는 batch에 불필요한 대표 bean이 batch context에 로딩되지 않음을 테스트로 확인한다. +- `./gradlew :bottlenote-batch:compileJava :bottlenote-batch:compileTestJava`가 통과한다. +- `./gradlew :bottlenote-batch:batch_test` 또는 batch 전용 context 검증 테스트 실행 명령이 통과한다. +- 필요 시 전체 회귀로 `./gradlew build -x test -x asciidoctor --build-cache --parallel`까지 통과한다. + +### Impact Scope + +- Primary module: `bottlenote-batch` +- Likely files: + - `bottlenote-batch/src/main/java/app/batch/bottlenote/BatchApplication.java` + - `bottlenote-batch/src/main/java/app/batch/bottlenote/config/*` + - `bottlenote-batch/src/main/java/app/batch/bottlenote/job/**/*` + - `bottlenote-batch/src/test/java/**` 신규 context 검증 테스트 + - `bottlenote-batch/src/test/resources/application.yml` +- Neighbor modules/packages: + - `bottlenote-mono/src/main/java/app/bottlenote/support/report/service/DailyDataReportService.java` + - `bottlenote-mono/src/main/java/app/external/webhook/config/*` + - `bottlenote-mono/src/main/java/app/external/version/config/AppInfoConfig.java` + - `bottlenote-mono/src/main/java/app/bottlenote/global/config/jpa/*` +- Persistence: + - DB schema 변경 없음. + - SQL resource 변경 없음. + - JPA repository/entity scan은 구현 설계에서 최소 조정 여부만 검토한다. +- Async/events/cache: + - 신규 event, async flow, cache invalidation 없음. +- External integration: + - Discord webhook 설정과 `RestTemplate` bean wiring은 유지해야 한다. +- Tests: + - batch application context startup test가 필요하다. + - 대표적인 불필요 bean 부재 검증이 필요하다. + - ranking/daily report job bean presence 검증이 필요하다. +- Docs/API contracts: + - 외부 API 계약 변경 없음. + - 필요하면 기술 부채 감사 문서에 완료 상태만 반영한다. + +### Approach Options + +- Recommended: `BatchApplication`의 component scan을 `app.batch.bottlenote`와 batch가 실제 사용하는 mono/external package로 명시적으로 제한한다. 변경 범위가 작고 현재 구조를 유지하면서 불필요한 bean 로딩을 줄일 수 있다. +- Alternative: batch 전용 configuration class를 만들고 필요한 mono/external bean만 `@Import`한다. 가장 명확하지만, 현재 mono 설정이 넓게 묶여 있어 초기 작업량이 커질 수 있다. +- Deferred: mono의 report/webhook 기능을 batch 전용 adapter로 분리한다. 구조적으로 가장 깔끔하지만 별도 리팩토링 성격이 강하므로 이번 부채 처리 범위를 넘을 수 있다. + +## Approval Gate + +위 가정과 성공 기준 승인 후 `/plan` 단계에서 구현 태스크를 분해한다. 이 문서는 아직 구현 계획이 아니며, 현재 단계에서는 코드 변경을 수행하지 않는다. + +## Tasks + +### Task 1: batch scan dependency map 고정 +- Acceptance: batch context에 반드시 필요한 package/bean 목록과 제외해야 할 대표 bean 목록을 테스트 기준으로 확정한다. +- Verification: `rg -n "scanBasePackages|@ComponentScan|@Configuration|@Component|@Service" bottlenote-batch bottlenote-mono/src/main/java/app/bottlenote/support/report bottlenote-mono/src/main/java/app/external` +- Files: `plan/batch-component-scan-scope.md` +- Size: S +- Status: [x] done + +### Task 2: BatchApplication scan 범위 축소 +- Acceptance: `BatchApplication`에서 broad `app` component scan이 제거되고 batch 실행에 필요한 package만 명시된다. +- Verification: `./gradlew :bottlenote-batch:compileJava` +- Files: `bottlenote-batch/src/main/java/app/batch/bottlenote/BatchApplication.java` +- Size: S +- Status: [x] done + +### Task 3: 필요한 mono/external wiring 유지 +- Acceptance: daily report job에 필요한 `DailyDataReportService`, `WebhookConfig`, `DiscordWebhookProperties`, `AppInfoConfig` wiring이 유지된다. +- Verification: `./gradlew :bottlenote-batch:compileJava :bottlenote-batch:compileTestJava` +- Files: `bottlenote-batch/src/main/java/app/batch/bottlenote/BatchApplication.java`, 필요 시 batch 전용 config 1개 +- Size: S +- Status: [x] done + +### Checkpoint: after Tasks 1-3 +- [x] `./gradlew :bottlenote-batch:compileJava :bottlenote-batch:compileTestJava` 통과 +- [x] broad component scan 제거 여부 확인 +- [x] product/admin application code 변경 없음 확인 + +### Task 4: batch context startup 테스트 추가 +- Acceptance: test profile에서 batch application context가 기동되고 batch job bean 3종 및 Quartz binding 3종이 등록됨을 검증한다. +- Verification: `./gradlew :bottlenote-batch:batch_test` +- Files: `bottlenote-batch/src/test/java/**`, 필요 시 `bottlenote-batch/src/test/resources/application.yml` +- Size: M +- Status: [x] done + +### Task 5: 불필요 bean 미로딩 테스트 추가 +- Acceptance: product/admin 전용 또는 batch와 무관한 대표 bean이 batch context에 등록되지 않음을 검증한다. +- Verification: `./gradlew :bottlenote-batch:batch_test` +- Files: `bottlenote-batch/src/test/java/**` +- Size: S +- Status: [x] done + +### Task 6: 회귀 검증 및 문서 상태 정리 +- Acceptance: batch compile/test와 lightweight build 검증 결과를 Progress Log에 남기고, 필요 시 기술 부채 문서 완료 반영 범위를 정리한다. +- Verification: `./gradlew :bottlenote-batch:batch_test` 및 `./gradlew build -x test -x asciidoctor --build-cache --parallel` +- Files: `plan/batch-component-scan-scope.md`, 필요 시 기술 부채 감사 문서 +- Size: S +- Status: [x] done + +### Checkpoint: after Tasks 4-6 +- [x] `./gradlew :bottlenote-batch:batch_test` 통과 +- [x] `./gradlew build -x test -x asciidoctor --build-cache --parallel` 통과 +- [x] `git status --short`에서 AWS SDK v2 작업 및 `git.environment-variables` 변경을 침범하지 않았는지 확인 + +## Progress Log + +- Task 1: `rg`로 batch scan dependency를 확인했다. 필요한 bean은 batch job/config, `DailyDataReportService`, `WebhookConfig`, `DiscordWebhookProperties`, `AppInfoConfig`로 고정했고, 제외 대표 bean은 `OauthService`, `AdminUserService`, `ReviewService`, `UserReportService`, `ReviewReportService`로 잡았다. +- Task 2: `BatchApplication`의 broad `app` scan과 중복 `@ComponentScan`을 제거하고 `app.batch.bottlenote` component scan으로 축소했다. +- Task 3: daily report wiring은 package scan 대신 `@Import({DailyDataReportService.class, WebhookConfig.class, AppInfoConfig.class})`로 명시했다. +- Task 4: `BatchApplicationContextTest`를 추가해 batch job 3종과 Quartz binding 3종 등록을 검증했다. +- Task 5: 동일 context 테스트에서 product/admin 성격의 mono bean 5종이 로드되지 않는지 검증했다. +- Task 6: `./gradlew :bottlenote-batch:compileJava` 통과, `./gradlew :bottlenote-batch:compileTestJava` 통과, `./gradlew :bottlenote-batch:batch_test` 통과, `./gradlew build -x test -x asciidoctor --build-cache --parallel` 통과. worktree의 `git.environment-variables` 서브모듈은 초기화하지 않았고, 테스트용 SQL 리소스만 `src/test/resources`에 추가했다.