Skip to content

Commit

Permalink
Merge pull request #44 from Route-Box/feature/#42
Browse files Browse the repository at this point in the history
회원가입 시 쿠폰 발행 기능 추가
Wo-ogie authored Aug 19, 2024
2 parents fcd1562 + 511bfbc commit 7850e4e
Showing 19 changed files with 270 additions and 236 deletions.
2 changes: 2 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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")
Original file line number Diff line number Diff line change
@@ -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

This file was deleted.

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package com.routebox.routebox.application.auth

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 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 OAuthLoginUseCase(
private val userService: UserService,
private val authService: AuthService,
private val eventPublisher: ApplicationEventPublisher,
) {
/**
* OAuth(Kakao, Apple) 로그인.
*
* Social login uid를 조회한 후, 다음 로직을 수행한다.
* - 신규 유저라면: 유저 데이터 생성 및 저장
* - 기존 유저라면: 유저 데이터 조회
* 만약 유저 데이터를 생성했을 경우, 회원가입 기념 쿠폰을 세 장 지급할 수 있도록 이벤트를 발행한다.
*
* 이후 생성 또는 조회한 유저 정보로 access token과 refresh token을 생성하여 반환한다.
*
* @param command
* @return 로그인 결과로 신규 유저인지에 대한 정보, access token 정보, refresh token 정보를 응답한다.
*/
@Transactional
operator fun invoke(@Valid command: OAuthLoginCommand): LoginResult {
val oAuthUserInfo = authService.getUserInfo(command.loginType, command.token)

var isSignUpProceeded = false
val user = userService.findUserBySocialLoginUid(oAuthUserInfo.uid)
?: userService.createNewUser(command.loginType, oAuthUserInfo.uid)
.also { isSignUpProceeded = true }

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))
}
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.routebox.routebox.application.auth.dto

import com.routebox.routebox.domain.user.constant.LoginType

/**
* `token`
* - Kakao: access token
* - Apple: identity token
*/
data class OAuthLoginCommand(
val loginType: LoginType,
val token: String,
)
Original file line number Diff line number Diff line change
@@ -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)
}

57 changes: 57 additions & 0 deletions src/main/kotlin/com/routebox/routebox/domain/coupon/Coupon.kt
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.routebox.routebox.domain.coupon.constant

enum class CouponStatus {
READY, // 대기 상태. 아직 쿠폰을 사용할 수 없음
AVAILABLE, // 쿠폰 사용 가능
USED, // 사용된 쿠폰
EXPIRED, // 만료된 쿠폰
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.routebox.routebox.domain.coupon.constant

enum class CouponType {
BUY_ROUTE,
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.routebox.routebox.domain.coupon.event

import com.routebox.routebox.domain.coupon.Coupon

data class CouponsIssuedEvent(val coupons: Collection<Coupon>)
10 changes: 10 additions & 0 deletions src/main/kotlin/com/routebox/routebox/domain/user/User.kt
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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<Coupon, Long>
17 changes: 17 additions & 0 deletions src/main/resources/schema.sql
Original file line number Diff line number Diff line change
@@ -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,

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,31 +1,36 @@
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
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,16 +54,18 @@ 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)
then(userService).should().findUserBySocialLoginUid(kakaoUid)
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),
)
Original file line number Diff line number Diff line change
@@ -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()
}
}

0 comments on commit 7850e4e

Please sign in to comment.