From f4e87128fef29093f53d17f94d9d2cc8f6feb759 Mon Sep 17 00:00:00 2001 From: JaeUk Date: Mon, 19 Aug 2024 20:29:01 +0900 Subject: [PATCH 1/4] =?UTF-8?q?refactor:=20#42=20kakao,=20apple=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20use=20case=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/auth/AppleLoginUseCase.kt | 43 ------- ...aoLoginUseCase.kt => OAuthLoginUseCase.kt} | 0 ...aoLoginCommand.kt => OAuthLoginCommand.kt} | 0 .../application/auth/AppleLoginUseCaseTest.kt | 113 ------------------ ...seCaseTest.kt => OAuthLoginUseCaseTest.kt} | 0 5 files changed, 156 deletions(-) delete mode 100644 src/main/kotlin/com/routebox/routebox/application/auth/AppleLoginUseCase.kt rename src/main/kotlin/com/routebox/routebox/application/auth/{KakaoLoginUseCase.kt => OAuthLoginUseCase.kt} (100%) rename src/main/kotlin/com/routebox/routebox/application/auth/dto/{KakaoLoginCommand.kt => OAuthLoginCommand.kt} (100%) delete mode 100644 src/test/kotlin/com/routebox/routebox/application/auth/AppleLoginUseCaseTest.kt rename src/test/kotlin/com/routebox/routebox/application/auth/{KakaoLoginUseCaseTest.kt => OAuthLoginUseCaseTest.kt} (100%) diff --git a/src/main/kotlin/com/routebox/routebox/application/auth/AppleLoginUseCase.kt b/src/main/kotlin/com/routebox/routebox/application/auth/AppleLoginUseCase.kt deleted file mode 100644 index e4ce806..0000000 --- a/src/main/kotlin/com/routebox/routebox/application/auth/AppleLoginUseCase.kt +++ /dev/null @@ -1,43 +0,0 @@ -package com.routebox.routebox.application.auth - -import com.routebox.routebox.application.auth.dto.AppleLoginCommand -import com.routebox.routebox.application.auth.dto.LoginResult -import com.routebox.routebox.domain.auth.AuthService -import com.routebox.routebox.domain.user.UserService -import com.routebox.routebox.domain.user.constant.LoginType -import jakarta.validation.Valid -import org.springframework.stereotype.Component -import org.springframework.transaction.annotation.Transactional - -@Component -class AppleLoginUseCase( - private val userService: UserService, - private val authService: AuthService, -) { - /** - * 애플 로그인. - * - * Social login uid를 조회한 후, 다음 로직을 수행한다. - * - 신규 유저라면: 유저 데이터 생성 및 저장 - * - 기존 유저라면: 유저 데이터 조회 - * - * 이후 생성 또는 조회한 유저 정보로 access token과 refresh token을 생성하여 반환한다. - * - * @param command - * @return 로그인 결과로 신규 유저인지에 대한 정보, access token 정보, refresh token 정보를 응답한다. - */ - @Transactional - operator fun invoke(@Valid command: AppleLoginCommand): LoginResult { - val appleUserInfo = authService.getUserInfo(LoginType.APPLE, command.idToken) - - val user = userService.findUserBySocialLoginUid(appleUserInfo.uid) - ?: userService.createNewUser(LoginType.APPLE, appleUserInfo.uid) - - return LoginResult( - isNew = user.createdAt == user.updatedAt, - loginType = LoginType.APPLE, - accessToken = authService.issueAccessToken(user), - refreshToken = authService.issueRefreshToken(user), - ) - } -} diff --git a/src/main/kotlin/com/routebox/routebox/application/auth/KakaoLoginUseCase.kt b/src/main/kotlin/com/routebox/routebox/application/auth/OAuthLoginUseCase.kt similarity index 100% rename from src/main/kotlin/com/routebox/routebox/application/auth/KakaoLoginUseCase.kt rename to src/main/kotlin/com/routebox/routebox/application/auth/OAuthLoginUseCase.kt diff --git a/src/main/kotlin/com/routebox/routebox/application/auth/dto/KakaoLoginCommand.kt b/src/main/kotlin/com/routebox/routebox/application/auth/dto/OAuthLoginCommand.kt similarity index 100% rename from src/main/kotlin/com/routebox/routebox/application/auth/dto/KakaoLoginCommand.kt rename to src/main/kotlin/com/routebox/routebox/application/auth/dto/OAuthLoginCommand.kt diff --git a/src/test/kotlin/com/routebox/routebox/application/auth/AppleLoginUseCaseTest.kt b/src/test/kotlin/com/routebox/routebox/application/auth/AppleLoginUseCaseTest.kt deleted file mode 100644 index ce79c92..0000000 --- a/src/test/kotlin/com/routebox/routebox/application/auth/AppleLoginUseCaseTest.kt +++ /dev/null @@ -1,113 +0,0 @@ -package com.routebox.routebox.application.auth - -import com.routebox.routebox.application.auth.dto.AppleLoginCommand -import com.routebox.routebox.domain.auth.AuthService -import com.routebox.routebox.domain.auth.OAuthUserInfo -import com.routebox.routebox.domain.user.User -import com.routebox.routebox.domain.user.UserService -import com.routebox.routebox.domain.user.constant.Gender -import com.routebox.routebox.domain.user.constant.LoginType -import com.routebox.routebox.security.JwtInfo -import com.routebox.routebox.security.JwtManager -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.extension.ExtendWith -import org.mockito.InjectMocks -import org.mockito.Mock -import org.mockito.junit.jupiter.MockitoExtension -import org.mockito.kotlin.given -import org.mockito.kotlin.then -import org.springframework.test.util.ReflectionTestUtils -import java.time.LocalDate -import java.time.LocalDateTime -import kotlin.random.Random -import kotlin.test.Test - -@ExtendWith(MockitoExtension::class) -class AppleLoginUseCaseTest { - @InjectMocks - lateinit var sut: AppleLoginUseCase - - @Mock - lateinit var userService: UserService - - @Mock - lateinit var authService: AuthService - - @Mock - lateinit var jwtManager: JwtManager - - @Test - fun `(신규 유저) Apple에서 발행한 id token이 주어지고, 주어진 token으로 유저 정보 조회 및 회원가입을 진행한다`() { - // given - val appleIdToken = Random.toString() - val appleUid = Random.toString() - val newUser = createUser(id = Random.nextLong()) - val expectedAccessTokenResult = JwtInfo(token = Random.toString(), expiresAt = LocalDateTime.now()) - val expectedRefreshTokenResult = JwtInfo(token = Random.toString(), expiresAt = LocalDateTime.now()) - given(authService.getUserInfo(LoginType.APPLE, appleIdToken)).willReturn(OAuthUserInfo(uid = appleUid)) - given(userService.findUserBySocialLoginUid(appleUid)).willReturn(null) - given(userService.createNewUser(LoginType.APPLE, appleUid)).willReturn(newUser) - given(authService.issueAccessToken(newUser)).willReturn(expectedAccessTokenResult) - given(authService.issueRefreshToken(newUser)).willReturn(expectedRefreshTokenResult) - - // when - val result = sut.invoke(AppleLoginCommand(appleIdToken)) - - // then - then(authService).should().getUserInfo(LoginType.APPLE, appleIdToken) - then(userService).should().findUserBySocialLoginUid(appleUid) - then(userService).should().createNewUser(LoginType.APPLE, appleUid) - then(authService).should().issueAccessToken(newUser) - then(authService).should().issueRefreshToken(newUser) - verifyEveryMocksShouldHaveNoMoreInteractions() - assertThat(result.isNew).isTrue() - assertThat(result.accessToken).isEqualTo(expectedAccessTokenResult) - assertThat(result.refreshToken).isEqualTo(expectedRefreshTokenResult) - } - - @Test - fun `(기존 유저) Apple에서 발행한 id token이 주어지고, 주어진 token으로 유저 정보 조회 및 로그인을 진행한다`() { - // given - val appleIdToken = Random.toString() - val appleUid = Random.toString() - val user = createUser(id = Random.nextLong()) - val expectedAccessTokenResult = JwtInfo(token = Random.toString(), expiresAt = LocalDateTime.now()) - val expectedRefreshTokenResult = JwtInfo(token = Random.toString(), expiresAt = LocalDateTime.now()) - - // 기존 유저 데이터를 표현하기 위해 createdAt != updatedAt이 되게끔 set. - ReflectionTestUtils.setField(user, "updatedAt", LocalDateTime.now().plusDays(1)) - - given(authService.getUserInfo(LoginType.APPLE, appleIdToken)).willReturn(OAuthUserInfo(uid = appleUid)) - given(userService.findUserBySocialLoginUid(appleUid)).willReturn(user) - given(authService.issueAccessToken(user)).willReturn(expectedAccessTokenResult) - given(authService.issueRefreshToken(user)).willReturn(expectedRefreshTokenResult) - - // when - val result = sut.invoke(AppleLoginCommand(appleIdToken)) - - // then - then(authService).should().getUserInfo(LoginType.APPLE, appleIdToken) - then(userService).should().findUserBySocialLoginUid(appleUid) - then(authService).should().issueAccessToken(user) - then(authService).should().issueRefreshToken(user) - verifyEveryMocksShouldHaveNoMoreInteractions() - assertThat(result.isNew).isFalse() - assertThat(result.accessToken).isEqualTo(expectedAccessTokenResult) - assertThat(result.refreshToken).isEqualTo(expectedRefreshTokenResult) - } - - private fun verifyEveryMocksShouldHaveNoMoreInteractions() { - then(userService).shouldHaveNoMoreInteractions() - then(authService).shouldHaveNoMoreInteractions() - then(jwtManager).shouldHaveNoMoreInteractions() - } - - private fun createUser(id: Long) = User( - id = id, - loginType = LoginType.APPLE, - socialLoginUid = Random.toString(), - nickname = Random.toString(), - gender = Gender.PRIVATE, - birthDay = LocalDate.of(2024, 1, 1), - ) -} diff --git a/src/test/kotlin/com/routebox/routebox/application/auth/KakaoLoginUseCaseTest.kt b/src/test/kotlin/com/routebox/routebox/application/auth/OAuthLoginUseCaseTest.kt similarity index 100% rename from src/test/kotlin/com/routebox/routebox/application/auth/KakaoLoginUseCaseTest.kt rename to src/test/kotlin/com/routebox/routebox/application/auth/OAuthLoginUseCaseTest.kt From 00920774c293632dddfc1f2ab7a2519828f854f4 Mon Sep 17 00:00:00 2001 From: JaeUk Date: Mon, 19 Aug 2024 20:29:27 +0900 Subject: [PATCH 2/4] =?UTF-8?q?chore:=20#42=20Spring=20Retry=20=EC=9D=98?= =?UTF-8?q?=EC=A1=B4=EC=84=B1=20=EB=B0=8F=20=EC=84=A4=EC=A0=95=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 2 ++ .../kotlin/com/routebox/routebox/RouteBoxServerApplication.kt | 2 ++ 2 files changed, 4 insertions(+) diff --git a/build.gradle.kts b/build.gradle.kts index 820df6e..06567e8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -46,6 +46,8 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-aop") + implementation("org.springframework.retry:spring-retry") + annotationProcessor("org.springframework.boot:spring-boot-configuration-processor") implementation("org.springframework.cloud:spring-cloud-starter-openfeign:4.1.3") diff --git a/src/main/kotlin/com/routebox/routebox/RouteBoxServerApplication.kt b/src/main/kotlin/com/routebox/routebox/RouteBoxServerApplication.kt index d5f206c..0cba8d9 100644 --- a/src/main/kotlin/com/routebox/routebox/RouteBoxServerApplication.kt +++ b/src/main/kotlin/com/routebox/routebox/RouteBoxServerApplication.kt @@ -4,7 +4,9 @@ import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.context.properties.ConfigurationPropertiesScan import org.springframework.boot.runApplication import org.springframework.context.annotation.PropertySource +import org.springframework.retry.annotation.EnableRetry +@EnableRetry @ConfigurationPropertiesScan @PropertySource("classpath:/env.properties") @SpringBootApplication From 79d04e2c62c86cd2eb2304fb790b42b258909a74 Mon Sep 17 00:00:00 2001 From: JaeUk Date: Mon, 19 Aug 2024 20:30:13 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20#42=20=EC=BF=A0=ED=8F=B0(`Coupon`)?= =?UTF-8?q?=20entity=20=EC=A0=95=EC=9D=98=20=EB=B0=8F=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../routebox/routebox/domain/coupon/Coupon.kt | 57 +++++++++++++++++++ .../domain/coupon/constant/CouponStatus.kt | 8 +++ .../domain/coupon/constant/CouponType.kt | 5 ++ .../infrastructure/coupon/CouponRepository.kt | 6 ++ src/main/resources/schema.sql | 17 ++++++ 5 files changed, 93 insertions(+) create mode 100644 src/main/kotlin/com/routebox/routebox/domain/coupon/Coupon.kt create mode 100644 src/main/kotlin/com/routebox/routebox/domain/coupon/constant/CouponStatus.kt create mode 100644 src/main/kotlin/com/routebox/routebox/domain/coupon/constant/CouponType.kt create mode 100644 src/main/kotlin/com/routebox/routebox/infrastructure/coupon/CouponRepository.kt diff --git a/src/main/kotlin/com/routebox/routebox/domain/coupon/Coupon.kt b/src/main/kotlin/com/routebox/routebox/domain/coupon/Coupon.kt new file mode 100644 index 0000000..2915188 --- /dev/null +++ b/src/main/kotlin/com/routebox/routebox/domain/coupon/Coupon.kt @@ -0,0 +1,57 @@ +package com.routebox.routebox.domain.coupon + +import com.routebox.routebox.domain.common.TimeTrackedBaseEntity +import com.routebox.routebox.domain.coupon.constant.CouponStatus +import com.routebox.routebox.domain.coupon.constant.CouponType +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.Table +import java.time.LocalDateTime + +@Table(name = "coupon") +@Entity +class Coupon( + id: Long = 0, + userId: Long, + title: String, + type: CouponType, + status: CouponStatus, + startedAt: LocalDateTime, + endedAt: LocalDateTime?, + expiredAt: LocalDateTime? = null, +) : TimeTrackedBaseEntity() { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "coupon_id") + var id: Long = id + private set + + var userId: Long = userId + private set + + var title: String = title + private set + + @Enumerated(EnumType.STRING) + var type: CouponType = type + private set + + @Enumerated(EnumType.STRING) + var status: CouponStatus = status + private set + + var startedAt: LocalDateTime = startedAt + private set + + var endedAt: LocalDateTime? = endedAt + private set + + var expiredAt: LocalDateTime? = expiredAt + private set +} diff --git a/src/main/kotlin/com/routebox/routebox/domain/coupon/constant/CouponStatus.kt b/src/main/kotlin/com/routebox/routebox/domain/coupon/constant/CouponStatus.kt new file mode 100644 index 0000000..31fd7c6 --- /dev/null +++ b/src/main/kotlin/com/routebox/routebox/domain/coupon/constant/CouponStatus.kt @@ -0,0 +1,8 @@ +package com.routebox.routebox.domain.coupon.constant + +enum class CouponStatus { + READY, // 대기 상태. 아직 쿠폰을 사용할 수 없음 + AVAILABLE, // 쿠폰 사용 가능 + USED, // 사용된 쿠폰 + EXPIRED, // 만료된 쿠폰 +} diff --git a/src/main/kotlin/com/routebox/routebox/domain/coupon/constant/CouponType.kt b/src/main/kotlin/com/routebox/routebox/domain/coupon/constant/CouponType.kt new file mode 100644 index 0000000..953feb9 --- /dev/null +++ b/src/main/kotlin/com/routebox/routebox/domain/coupon/constant/CouponType.kt @@ -0,0 +1,5 @@ +package com.routebox.routebox.domain.coupon.constant + +enum class CouponType { + BUY_ROUTE, +} diff --git a/src/main/kotlin/com/routebox/routebox/infrastructure/coupon/CouponRepository.kt b/src/main/kotlin/com/routebox/routebox/infrastructure/coupon/CouponRepository.kt new file mode 100644 index 0000000..11681c6 --- /dev/null +++ b/src/main/kotlin/com/routebox/routebox/infrastructure/coupon/CouponRepository.kt @@ -0,0 +1,6 @@ +package com.routebox.routebox.infrastructure.coupon + +import com.routebox.routebox.domain.coupon.Coupon +import org.springframework.data.jpa.repository.JpaRepository + +interface CouponRepository : JpaRepository diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql index 2dc6ef6..8f810c6 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -2,6 +2,7 @@ DROP TABLE IF EXISTS notification; DROP TABLE IF EXISTS inquiry_response; DROP TABLE IF EXISTS inquiry_image; DROP TABLE IF EXISTS inquiry; +DROP TABLE IF EXISTS coupon; DROP TABLE IF EXISTS user_profile_image; DROP TABLE IF EXISTS user_point_history; DROP TABLE IF EXISTS users; @@ -53,6 +54,22 @@ CREATE TABLE user_point_history ); CREATE INDEX idx__user_point_history__user_id ON user_point_history (user_id); +CREATE TABLE coupon +( + coupon_id BIGINT NOT NULL AUTO_INCREMENT, + user_id BIGINT NOT NULL, + title VARCHAR(255) NOT NULL, + type VARCHAR(10) NOT NULL, + status VARCHAR(10) NOT NULL, + started_at DATETIME NOT NULL COMMENT '쿠폰 이용 시작 시각', + ended_at DATETIME COMMENT '쿠폰 이용 종료 시각. NULL인 경우 무제한', + expired_at DATETIME COMMENT '쿠폰 만료 시각', + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + PRIMARY KEY (coupon_id) +); +CREATE INDEX idx__coupon__user_id ON coupon (user_id); + CREATE TABLE notification ( notification_id BIGINT NOT NULL AUTO_INCREMENT, From 511bfbcbf9cdda4fb9e3ec46ec5cf972faa2aa89 Mon Sep 17 00:00:00 2001 From: JaeUk Date: Mon, 19 Aug 2024 20:30:34 +0900 Subject: [PATCH 4/4] =?UTF-8?q?feat:=20#42=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EC=8B=9C=20=ED=9A=8C=EC=9B=90=EA=B0=80=EC=9E=85=20?= =?UTF-8?q?=EA=B8=B0=EB=85=90=20=EC=BF=A0=ED=8F=B0=20=EB=B0=9C=ED=96=89=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/auth/OAuthLoginUseCase.kt | 56 +++++++++++++++---- .../application/auth/dto/OAuthLoginCommand.kt | 12 +++- .../controller/auth/AuthController.kt | 14 ++--- .../coupon/event/CouponIssuedEventListener.kt | 34 +++++++++++ .../domain/coupon/event/CouponsIssuedEvent.kt | 5 ++ .../com/routebox/routebox/domain/user/User.kt | 10 ++++ .../application/auth/OAuthLoginUseCaseTest.kt | 31 ++++++---- .../controller/auth/AuthControllerTest.kt | 23 +++----- 8 files changed, 139 insertions(+), 46 deletions(-) create mode 100644 src/main/kotlin/com/routebox/routebox/domain/coupon/event/CouponIssuedEventListener.kt create mode 100644 src/main/kotlin/com/routebox/routebox/domain/coupon/event/CouponsIssuedEvent.kt diff --git a/src/main/kotlin/com/routebox/routebox/application/auth/OAuthLoginUseCase.kt b/src/main/kotlin/com/routebox/routebox/application/auth/OAuthLoginUseCase.kt index e83b8af..47f68c1 100644 --- a/src/main/kotlin/com/routebox/routebox/application/auth/OAuthLoginUseCase.kt +++ b/src/main/kotlin/com/routebox/routebox/application/auth/OAuthLoginUseCase.kt @@ -1,25 +1,32 @@ package com.routebox.routebox.application.auth -import com.routebox.routebox.application.auth.dto.KakaoLoginCommand import com.routebox.routebox.application.auth.dto.LoginResult +import com.routebox.routebox.application.auth.dto.OAuthLoginCommand import com.routebox.routebox.domain.auth.AuthService +import com.routebox.routebox.domain.coupon.Coupon +import com.routebox.routebox.domain.coupon.constant.CouponStatus +import com.routebox.routebox.domain.coupon.constant.CouponType +import com.routebox.routebox.domain.coupon.event.CouponsIssuedEvent import com.routebox.routebox.domain.user.UserService -import com.routebox.routebox.domain.user.constant.LoginType import jakarta.validation.Valid +import org.springframework.context.ApplicationEventPublisher import org.springframework.stereotype.Component import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime @Component -class KakaoLoginUseCase( +class OAuthLoginUseCase( private val userService: UserService, private val authService: AuthService, + private val eventPublisher: ApplicationEventPublisher, ) { /** - * 카카오 로그인. + * OAuth(Kakao, Apple) 로그인. * * Social login uid를 조회한 후, 다음 로직을 수행한다. * - 신규 유저라면: 유저 데이터 생성 및 저장 * - 기존 유저라면: 유저 데이터 조회 + * 만약 유저 데이터를 생성했을 경우, 회원가입 기념 쿠폰을 세 장 지급할 수 있도록 이벤트를 발행한다. * * 이후 생성 또는 조회한 유저 정보로 access token과 refresh token을 생성하여 반환한다. * @@ -27,17 +34,44 @@ class KakaoLoginUseCase( * @return 로그인 결과로 신규 유저인지에 대한 정보, access token 정보, refresh token 정보를 응답한다. */ @Transactional - operator fun invoke(@Valid command: KakaoLoginCommand): LoginResult { - val kakaoUserInfo = authService.getUserInfo(LoginType.KAKAO, command.kakaoAccessToken) + operator fun invoke(@Valid command: OAuthLoginCommand): LoginResult { + val oAuthUserInfo = authService.getUserInfo(command.loginType, command.token) - val user = userService.findUserBySocialLoginUid(kakaoUserInfo.uid) - ?: userService.createNewUser(LoginType.KAKAO, kakaoUserInfo.uid) + var isSignUpProceeded = false + val user = userService.findUserBySocialLoginUid(oAuthUserInfo.uid) + ?: userService.createNewUser(command.loginType, oAuthUserInfo.uid) + .also { isSignUpProceeded = true } - return LoginResult( - isNew = user.createdAt == user.updatedAt, - loginType = LoginType.KAKAO, + val result = LoginResult( + isNew = user.isOnboardingComplete(), + loginType = command.loginType, accessToken = authService.issueAccessToken(user), refreshToken = authService.issueRefreshToken(user), ) + + if (isSignUpProceeded) { + issueSingUpCoupons(userId = user.id) + } + + return result + } + + /** + * 회원 가입 시 3개의 쿠폰 발행 + * + * @param userId 쿠폰을 발행할 대상(사용자)의 id + */ + private fun issueSingUpCoupons(userId: Long) { + val coupons = List(3) { + Coupon( + userId = userId, + title = "회원가입 감사 쿠폰", + type = CouponType.BUY_ROUTE, + status = CouponStatus.AVAILABLE, + startedAt = LocalDateTime.now(), + endedAt = null, + ) + } + eventPublisher.publishEvent(CouponsIssuedEvent(coupons)) } } diff --git a/src/main/kotlin/com/routebox/routebox/application/auth/dto/OAuthLoginCommand.kt b/src/main/kotlin/com/routebox/routebox/application/auth/dto/OAuthLoginCommand.kt index 5c7014b..ed51cef 100644 --- a/src/main/kotlin/com/routebox/routebox/application/auth/dto/OAuthLoginCommand.kt +++ b/src/main/kotlin/com/routebox/routebox/application/auth/dto/OAuthLoginCommand.kt @@ -1,3 +1,13 @@ package com.routebox.routebox.application.auth.dto -data class KakaoLoginCommand(val kakaoAccessToken: String) +import com.routebox.routebox.domain.user.constant.LoginType + +/** + * `token` + * - Kakao: access token + * - Apple: identity token + */ +data class OAuthLoginCommand( + val loginType: LoginType, + val token: String, +) diff --git a/src/main/kotlin/com/routebox/routebox/controller/auth/AuthController.kt b/src/main/kotlin/com/routebox/routebox/controller/auth/AuthController.kt index fe86a99..7622820 100644 --- a/src/main/kotlin/com/routebox/routebox/controller/auth/AuthController.kt +++ b/src/main/kotlin/com/routebox/routebox/controller/auth/AuthController.kt @@ -1,15 +1,14 @@ package com.routebox.routebox.controller.auth -import com.routebox.routebox.application.auth.AppleLoginUseCase -import com.routebox.routebox.application.auth.KakaoLoginUseCase +import com.routebox.routebox.application.auth.OAuthLoginUseCase import com.routebox.routebox.application.auth.RefreshTokensUseCase -import com.routebox.routebox.application.auth.dto.AppleLoginCommand -import com.routebox.routebox.application.auth.dto.KakaoLoginCommand +import com.routebox.routebox.application.auth.dto.OAuthLoginCommand import com.routebox.routebox.controller.auth.dto.AppleLoginRequest import com.routebox.routebox.controller.auth.dto.KakaoLoginRequest import com.routebox.routebox.controller.auth.dto.LoginResponse import com.routebox.routebox.controller.auth.dto.RefreshTokensRequest import com.routebox.routebox.controller.auth.dto.RefreshTokensResponse +import com.routebox.routebox.domain.user.constant.LoginType import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.media.Content import io.swagger.v3.oas.annotations.responses.ApiResponse @@ -25,8 +24,7 @@ import org.springframework.web.bind.annotation.RestController @RequestMapping("/api") @RestController class AuthController( - private val kakaoLoginUseCase: KakaoLoginUseCase, - private val appleLoginUseCase: AppleLoginUseCase, + private val oAuthLoginUseCase: OAuthLoginUseCase, private val refreshTokensUseCase: RefreshTokensUseCase, ) { @Operation( @@ -39,7 +37,7 @@ class AuthController( ) @PostMapping("/v1/auth/login/kakao") fun kakaoLoginV1(@RequestBody @Valid request: KakaoLoginRequest): LoginResponse { - val result = kakaoLoginUseCase(KakaoLoginCommand(request.kakaoAccessToken)) + val result = oAuthLoginUseCase(OAuthLoginCommand(loginType = LoginType.KAKAO, token = request.kakaoAccessToken)) return LoginResponse.from(result) } @@ -54,7 +52,7 @@ class AuthController( ) @PostMapping("/v1/auth/login/apple") fun appleLoginV1(@RequestBody @Valid request: AppleLoginRequest): LoginResponse { - val result = appleLoginUseCase(AppleLoginCommand(request.idToken)) + val result = oAuthLoginUseCase(OAuthLoginCommand(loginType = LoginType.APPLE, token = request.idToken)) return LoginResponse.from(result) } diff --git a/src/main/kotlin/com/routebox/routebox/domain/coupon/event/CouponIssuedEventListener.kt b/src/main/kotlin/com/routebox/routebox/domain/coupon/event/CouponIssuedEventListener.kt new file mode 100644 index 0000000..f3b2531 --- /dev/null +++ b/src/main/kotlin/com/routebox/routebox/domain/coupon/event/CouponIssuedEventListener.kt @@ -0,0 +1,34 @@ +package com.routebox.routebox.domain.coupon.event + +import com.routebox.routebox.infrastructure.coupon.CouponRepository +import org.springframework.retry.annotation.Backoff +import org.springframework.retry.annotation.Retryable +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Propagation +import org.springframework.transaction.annotation.Transactional +import org.springframework.transaction.event.TransactionPhase +import org.springframework.transaction.event.TransactionalEventListener + +@Component +class CouponIssuedEventListener(private val couponRepository: CouponRepository) { + + /* + TODO: 쿠폰 발행 기능은 이벤트를 발행한 기능과 별도의 transaction에서 동작하므로 쿠폰 발행에 실패했을 때에 대한 복구/대응 방법을 고려해야 한다. + 쿠폰 발행이 실패했을 때 조치 방법 후보 + - Slack 연동을 통해 관리자에게 알림 보내기 + - Scheduler를 통해 회원가입을 진행한 유저에게 쿠폰이 정상적으로 발행되었는지 확인 + 이를 위해 outbox pattern 등 부가적인 기법을 사용하는 것 까지는 적절하지 않아보임. + `handle()`의 기능 자체가 단순하고, DB가 정상 동작하지 않는 경우를 제외하면 쿠폰 발행에 실패하는 경우가 거의 없을 것으로 보이기 때문. + */ + + @Retryable( + value = [Exception::class], + maxAttempts = 3, + backoff = Backoff(delay = 1500), + ) + @Transactional(propagation = Propagation.REQUIRES_NEW) + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + fun handle(event: CouponsIssuedEvent) { + couponRepository.saveAll(event.coupons) + } +} diff --git a/src/main/kotlin/com/routebox/routebox/domain/coupon/event/CouponsIssuedEvent.kt b/src/main/kotlin/com/routebox/routebox/domain/coupon/event/CouponsIssuedEvent.kt new file mode 100644 index 0000000..2eab574 --- /dev/null +++ b/src/main/kotlin/com/routebox/routebox/domain/coupon/event/CouponsIssuedEvent.kt @@ -0,0 +1,5 @@ +package com.routebox.routebox.domain.coupon.event + +import com.routebox.routebox.domain.coupon.Coupon + +data class CouponsIssuedEvent(val coupons: Collection) diff --git a/src/main/kotlin/com/routebox/routebox/domain/user/User.kt b/src/main/kotlin/com/routebox/routebox/domain/user/User.kt index 8d7ca9a..4a05b34 100644 --- a/src/main/kotlin/com/routebox/routebox/domain/user/User.kt +++ b/src/main/kotlin/com/routebox/routebox/domain/user/User.kt @@ -80,6 +80,16 @@ class User( var deletedAt: LocalDateTime? = deletedAt private set + /** + * 유저가 온보딩 과정을 완료했는지 확인한다. + * 온보딩 과정이란 회원가입 후 사용자로부터 닉네임, 생일, 성별을 입력받는 과정을 의미한다. + * `User` entity가 수정된 적이 없거나 `birthDay`가 기본값(0001-01-01)으로 설정되어 있다면 온보딩 과정이 아직 진행되지 않은 것으로 간주한다. + * + * @return 유저가 온보딩 과정을 완료했는지 여부 + */ + fun isOnboardingComplete(): Boolean = + this.createdAt == this.updatedAt || this.birthDay == LocalDate.of(1, 1, 1) + fun updateNickname(nickname: String) { this.nickname = nickname } diff --git a/src/test/kotlin/com/routebox/routebox/application/auth/OAuthLoginUseCaseTest.kt b/src/test/kotlin/com/routebox/routebox/application/auth/OAuthLoginUseCaseTest.kt index 5ec8d4a..8a32133 100644 --- a/src/test/kotlin/com/routebox/routebox/application/auth/OAuthLoginUseCaseTest.kt +++ b/src/test/kotlin/com/routebox/routebox/application/auth/OAuthLoginUseCaseTest.kt @@ -1,21 +1,25 @@ package com.routebox.routebox.application.auth -import com.routebox.routebox.application.auth.dto.KakaoLoginCommand +import com.routebox.routebox.application.auth.dto.OAuthLoginCommand import com.routebox.routebox.domain.auth.AuthService import com.routebox.routebox.domain.auth.OAuthUserInfo +import com.routebox.routebox.domain.coupon.event.CouponsIssuedEvent import com.routebox.routebox.domain.user.User import com.routebox.routebox.domain.user.UserService import com.routebox.routebox.domain.user.constant.Gender import com.routebox.routebox.domain.user.constant.LoginType import com.routebox.routebox.security.JwtInfo -import com.routebox.routebox.security.JwtManager +import org.apache.commons.lang3.RandomStringUtils import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.ArgumentMatchers.any +import org.mockito.BDDMockito.given +import org.mockito.BDDMockito.then +import org.mockito.BDDMockito.willDoNothing import org.mockito.InjectMocks import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension -import org.mockito.kotlin.given -import org.mockito.kotlin.then +import org.springframework.context.ApplicationEventPublisher import org.springframework.test.util.ReflectionTestUtils import java.time.LocalDate import java.time.LocalDateTime @@ -23,9 +27,10 @@ import kotlin.random.Random import kotlin.test.Test @ExtendWith(MockitoExtension::class) -class KakaoLoginUseCaseTest { +class OAuthLoginUseCaseTest { + @InjectMocks - lateinit var sut: KakaoLoginUseCase + lateinit var sut: OAuthLoginUseCase @Mock lateinit var userService: UserService @@ -34,7 +39,7 @@ class KakaoLoginUseCaseTest { lateinit var authService: AuthService @Mock - lateinit var jwtManager: JwtManager + lateinit var eventPublisher: ApplicationEventPublisher @Test fun `(신규 유저) Kakao에서 발행한 access token이 주어지고, 주어진 token으로 유저 정보 조회 및 회원가입을 진행한다`() { @@ -49,9 +54,10 @@ class KakaoLoginUseCaseTest { given(userService.createNewUser(LoginType.KAKAO, kakaoUid)).willReturn(newUser) given(authService.issueAccessToken(newUser)).willReturn(expectedAccessTokenResult) given(authService.issueRefreshToken(newUser)).willReturn(expectedRefreshTokenResult) + willDoNothing().given(eventPublisher).publishEvent(any(CouponsIssuedEvent::class.java)) // when - val result = sut.invoke(KakaoLoginCommand(kakaoAccessToken)) + val result = sut.invoke(OAuthLoginCommand(loginType = LoginType.KAKAO, token = kakaoAccessToken)) // then then(authService).should().getUserInfo(LoginType.KAKAO, kakaoAccessToken) @@ -59,6 +65,7 @@ class KakaoLoginUseCaseTest { then(userService).should().createNewUser(LoginType.KAKAO, kakaoUid) then(authService).should().issueAccessToken(newUser) then(authService).should().issueRefreshToken(newUser) + then(eventPublisher).should().publishEvent(any(CouponsIssuedEvent::class.java)) verifyEveryMocksShouldHaveNoMoreInteractions() assertThat(result.isNew).isTrue() assertThat(result.accessToken).isEqualTo(expectedAccessTokenResult) @@ -83,7 +90,7 @@ class KakaoLoginUseCaseTest { given(authService.issueRefreshToken(user)).willReturn(expectedRefreshTokenResult) // when - val result = sut.invoke(KakaoLoginCommand(kakaoAccessToken)) + val result = sut.invoke(OAuthLoginCommand(loginType = LoginType.KAKAO, token = kakaoAccessToken)) // then then(authService).should().getUserInfo(LoginType.KAKAO, kakaoAccessToken) @@ -99,14 +106,14 @@ class KakaoLoginUseCaseTest { private fun verifyEveryMocksShouldHaveNoMoreInteractions() { then(userService).shouldHaveNoMoreInteractions() then(authService).shouldHaveNoMoreInteractions() - then(jwtManager).shouldHaveNoMoreInteractions() + then(eventPublisher).shouldHaveNoMoreInteractions() } private fun createUser(id: Long) = User( id = id, loginType = LoginType.KAKAO, - socialLoginUid = Random.toString(), - nickname = Random.toString(), + socialLoginUid = RandomStringUtils.random(10), + nickname = RandomStringUtils.random(10), gender = Gender.PRIVATE, birthDay = LocalDate.of(2024, 1, 1), ) diff --git a/src/test/kotlin/com/routebox/routebox/controller/auth/AuthControllerTest.kt b/src/test/kotlin/com/routebox/routebox/controller/auth/AuthControllerTest.kt index 3225f34..bcb9abb 100644 --- a/src/test/kotlin/com/routebox/routebox/controller/auth/AuthControllerTest.kt +++ b/src/test/kotlin/com/routebox/routebox/controller/auth/AuthControllerTest.kt @@ -1,12 +1,10 @@ package com.routebox.routebox.controller.auth import com.fasterxml.jackson.databind.ObjectMapper -import com.routebox.routebox.application.auth.AppleLoginUseCase -import com.routebox.routebox.application.auth.KakaoLoginUseCase +import com.routebox.routebox.application.auth.OAuthLoginUseCase import com.routebox.routebox.application.auth.RefreshTokensUseCase -import com.routebox.routebox.application.auth.dto.AppleLoginCommand -import com.routebox.routebox.application.auth.dto.KakaoLoginCommand import com.routebox.routebox.application.auth.dto.LoginResult +import com.routebox.routebox.application.auth.dto.OAuthLoginCommand import com.routebox.routebox.config.ControllerTestConfig import com.routebox.routebox.controller.auth.dto.AppleLoginRequest import com.routebox.routebox.controller.auth.dto.KakaoLoginRequest @@ -36,10 +34,7 @@ class AuthControllerTest @Autowired constructor( private val mapper: ObjectMapper, ) { @MockBean - lateinit var kakaoLoginUseCase: KakaoLoginUseCase - - @MockBean - lateinit var appleLoginUseCase: AppleLoginUseCase + lateinit var oAuthLoginUseCase: OAuthLoginUseCase @MockBean lateinit var refreshTokensUseCase: RefreshTokensUseCase @@ -54,7 +49,7 @@ class AuthControllerTest @Autowired constructor( accessToken = JwtInfo(token = toString(), expiresAt = LocalDateTime.now()), refreshToken = JwtInfo(token = toString(), expiresAt = LocalDateTime.now()), ) - given(kakaoLoginUseCase.invoke(KakaoLoginCommand(kakaoAccessToken))).willReturn(expectedResult) + given(oAuthLoginUseCase.invoke(OAuthLoginCommand(LoginType.KAKAO, kakaoAccessToken))).willReturn(expectedResult) // when & then mvc.perform( @@ -66,7 +61,7 @@ class AuthControllerTest @Autowired constructor( .andExpect(jsonPath("$.loginType").value(expectedResult.loginType.toString())) .andExpect(jsonPath("$.accessToken.token").value(expectedResult.accessToken.token)) .andExpect(jsonPath("$.refreshToken.token").value(expectedResult.refreshToken.token)) - then(kakaoLoginUseCase).should().invoke(KakaoLoginCommand(kakaoAccessToken)) + then(oAuthLoginUseCase).should().invoke(OAuthLoginCommand(LoginType.KAKAO, kakaoAccessToken)) verifyEveryMocksShouldHaveNoMoreInteractions() } @@ -80,7 +75,7 @@ class AuthControllerTest @Autowired constructor( accessToken = JwtInfo(token = toString(), expiresAt = LocalDateTime.now()), refreshToken = JwtInfo(token = toString(), expiresAt = LocalDateTime.now()), ) - given(appleLoginUseCase.invoke(AppleLoginCommand(appleIdToken))).willReturn(expectedResult) + given(oAuthLoginUseCase.invoke(OAuthLoginCommand(LoginType.APPLE, appleIdToken))).willReturn(expectedResult) // when & then mvc.perform( @@ -92,7 +87,7 @@ class AuthControllerTest @Autowired constructor( .andExpect(jsonPath("$.loginType").value(expectedResult.loginType.toString())) .andExpect(jsonPath("$.accessToken.token").value(expectedResult.accessToken.token)) .andExpect(jsonPath("$.refreshToken.token").value(expectedResult.refreshToken.token)) - then(appleLoginUseCase).should().invoke(AppleLoginCommand(appleIdToken)) + then(oAuthLoginUseCase).should().invoke(OAuthLoginCommand(LoginType.APPLE, appleIdToken)) verifyEveryMocksShouldHaveNoMoreInteractions() } @@ -116,7 +111,7 @@ class AuthControllerTest @Autowired constructor( } private fun verifyEveryMocksShouldHaveNoMoreInteractions() { - then(kakaoLoginUseCase).shouldHaveNoMoreInteractions() - then(appleLoginUseCase).shouldHaveNoMoreInteractions() + then(oAuthLoginUseCase).shouldHaveNoMoreInteractions() + then(refreshTokensUseCase).shouldHaveNoMoreInteractions() } }