Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

회원가입 시 쿠폰 발행 기능 추가 #44

Merged
merged 4 commits into from
Aug 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

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
Expand All @@ -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(
Expand All @@ -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)
}

Expand All @@ -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)
}

Expand Down
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
Expand Up @@ -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
}
Expand Down
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>
Loading
Loading