Skip to content

Commit

Permalink
Merge pull request #89 from Route-Box/feature/#88
Browse files Browse the repository at this point in the history
Feature/#88 회원탈퇴 API 구현
  • Loading branch information
suyeoniii authored Sep 13, 2024
2 parents 5eb10cf + 24d6908 commit bf91361
Show file tree
Hide file tree
Showing 16 changed files with 187 additions and 5 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ 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.exception.user.UserWithdrawnException
import jakarta.validation.Valid
import org.springframework.context.ApplicationEventPublisher
import org.springframework.stereotype.Component
Expand All @@ -26,6 +27,8 @@ class OAuthLoginUseCase(
* Social login uid를 조회한 후, 다음 로직을 수행한다.
* - 신규 유저라면: 유저 데이터 생성 및 저장
* - 기존 유저라면: 유저 데이터 조회
*
* 탈퇴한 유저라면 탈퇴한 유저 예외를 발생시킨다.
* 만약 유저 데이터를 생성했을 경우, 회원가입 기념 쿠폰을 세 장 지급할 수 있도록 이벤트를 발행한다.
*
* 이후 생성 또는 조회한 유저 정보로 access token과 refresh token을 생성하여 반환한다.
Expand All @@ -42,6 +45,10 @@ class OAuthLoginUseCase(
?: userService.createNewUser(command.loginType, oAuthUserInfo.uid)
.also { isSignUpProceeded = true }

if (user.deletedAt != null) {
throw UserWithdrawnException()
}

val result = LoginResult(
isNew = user.isOnboardingComplete(),
loginType = command.loginType,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.routebox.routebox.application.auth

import com.routebox.routebox.application.auth.dto.WithdrawCommand
import com.routebox.routebox.domain.auth.AuthService
import com.routebox.routebox.domain.user.UserService
import jakarta.validation.Valid
import org.springframework.stereotype.Component
import org.springframework.transaction.annotation.Transactional

@Component
class WithdrawUseCase(
private val userService: UserService,
private val authService: AuthService,
) {
/**
* 회원 탈퇴
* @param command
*/
@Transactional
operator fun invoke(@Valid command: WithdrawCommand) {
val user = userService.getUserById(command.userId)
authService.withdrawUser(user, command.reasonType, command.reasonDetail)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.routebox.routebox.application.auth.dto

import com.routebox.routebox.domain.auth.WithdrawalReasonType

data class WithdrawCommand(
val userId: Long,
val reasonType: WithdrawalReasonType,
val reasonDetail: String?,
)
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,17 @@ package com.routebox.routebox.controller.auth

import com.routebox.routebox.application.auth.OAuthLoginUseCase
import com.routebox.routebox.application.auth.RefreshTokensUseCase
import com.routebox.routebox.application.auth.WithdrawUseCase
import com.routebox.routebox.application.auth.dto.OAuthLoginCommand
import com.routebox.routebox.application.auth.dto.WithdrawCommand
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.controller.auth.dto.WithdrawRequest
import com.routebox.routebox.controller.auth.dto.WithdrawResponse
import com.routebox.routebox.domain.auth.WithdrawalReasonType
import com.routebox.routebox.domain.user.constant.LoginType
import com.routebox.routebox.security.UserPrincipal
import io.swagger.v3.oas.annotations.Operation
Expand All @@ -30,6 +34,7 @@ import org.springframework.web.bind.annotation.RestController
class AuthController(
private val oAuthLoginUseCase: OAuthLoginUseCase,
private val refreshTokensUseCase: RefreshTokensUseCase,
private val withdrawUseCase: WithdrawUseCase,
) {
@Operation(
summary = "카카오 로그인",
Expand Down Expand Up @@ -77,16 +82,28 @@ class AuthController(

@Operation(
summary = "회원 탈퇴",
description = "<p>회원 탈퇴를 진행합니다." +
"<p>탈퇴 시, 회원의 모든 정보가 삭제되며, 복구가 불가능합니다. (미구현)",
description = "<p>회원 탈퇴를 진행합니다.</p>" +
"<p>탈퇴 시, 90일이 지난 후 데이터 삭제.</p>" +
"<p>탈퇴 사유 Enum 값:</p>" +
"<ul>" +
"<li><b>LIMITED_ROUTE_OPTIONS</b>: 루트가 다양하지 않아서 유용하지 않음</li>" +
"<li><b>DIFFICULT_ROUTE_RECORDING</b>: 여행 루트를 기록하기 어려움</li>" +
"<li><b>APP_NOT_AS_EXPECTED</b>: 다운로드 시 기대한 내용과 앱이 다름</li>" +
"<li><b>LOW_SERVICE_TRUST</b>: 서비스 운영의 신뢰도가 낮음</li>" +
"<li><b>ETC</b>: 기타</li>" +
"</ul>",
security = [SecurityRequirement(name = "access-token")],
)
@ApiResponses(
ApiResponse(responseCode = "200"),
ApiResponse(responseCode = "401", description = "[2002] 토큰이 만료되었거나 유효하지 않은 경우", content = [Content()]),
)
@PostMapping("/v1/auth/withdraw")
fun withdraw(@AuthenticationPrincipal principal: UserPrincipal): WithdrawResponse {
@PostMapping("/v1/auth/withdrawal")
fun withdraw(
@AuthenticationPrincipal principal: UserPrincipal,
@RequestBody @Valid request: WithdrawRequest,
): WithdrawResponse {
withdrawUseCase(WithdrawCommand(principal.userId, WithdrawalReasonType.fromString(request.reasonType), request.reasonDetail))
return WithdrawResponse(principal.userId)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.routebox.routebox.controller.auth.dto

import io.swagger.v3.oas.annotations.media.Schema

data class WithdrawRequest(
@Schema(description = "탈퇴 사유", example = "기타")
val reasonType: String,
@Schema(description = "탈퇴 사유 상세", example = "기타")
val reasonDetail: String?,
)
17 changes: 17 additions & 0 deletions src/main/kotlin/com/routebox/routebox/domain/auth/AuthService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import com.routebox.routebox.exception.apple.RequestAppleAuthKeysException
import com.routebox.routebox.exception.kakao.RequestKakaoUserInfoException
import com.routebox.routebox.infrastructure.apple.AppleApiClient
import com.routebox.routebox.infrastructure.apple.AppleAuthKeys
import com.routebox.routebox.infrastructure.auth.WithdrawalHistoryRepository
import com.routebox.routebox.infrastructure.kakao.KakaoApiClient
import com.routebox.routebox.security.JwtInfo
import com.routebox.routebox.security.JwtManager
Expand All @@ -25,6 +26,7 @@ class AuthService(
private val appleApiClient: AppleApiClient,
private val jwtManager: JwtManager,
private val refreshTokenRepository: RefreshTokenRepository,
private val withdrawalHistoryRepository: WithdrawalHistoryRepository,
) {
/**
* OAuth 로그인을 위해, 사용자 정보를 조회한다.
Expand Down Expand Up @@ -138,6 +140,21 @@ class AuthService(
refreshTokenRepository.save(RefreshToken(user.id, refreshToken.token))
return refreshToken
}

/**
* 회원 탈퇴 처리
*
* @param user 탈퇴할 유저 정보
* @param reasonType 탈퇴 사유 유형
* @param reasonDetail 탈퇴 사유 상세
*
* @return 탈퇴 처리 결과
*/
@Transactional
fun withdrawUser(user: User, reasonType: WithdrawalReasonType?, reasonDetail: String?) {
user.deleteUser()
withdrawalHistoryRepository.save(WithdrawalHistory(userId = user.id, reasonType = reasonType, reasonDetail = reasonDetail))
}
}

data class OAuthUserInfo(val uid: String)
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.routebox.routebox.domain.auth

import com.routebox.routebox.domain.common.TimeTrackedBaseEntity
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

@Table(name = "withdrawal_histories")
@Entity
class WithdrawalHistory(
id: Long = 0,
userId: Long,
reasonType: WithdrawalReasonType?,
reasonDetail: String?,
) : TimeTrackedBaseEntity() {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "withdrawal_history_id")
val id: Long = id

var userId: Long = userId

@Enumerated(EnumType.STRING)
var reasonType: WithdrawalReasonType? = reasonType
private set

var reasonDetail: String? = reasonDetail
private set
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.routebox.routebox.domain.auth

enum class WithdrawalReasonType(val description: String) {
LIMITED_ROUTE_OPTIONS("루트가 다양하지 않아서 유용하지 않음"),
DIFFICULT_ROUTE_RECORDING("여행 루트를 기록하기 어려움"),
APP_NOT_AS_EXPECTED("다운로드 시 기대한 내용과 앱이 다름"),
LOW_SERVICE_TRUST("서비스 운영의 신뢰도가 낮음"),
ETC("기타"),
;

companion object {
fun fromString(value: String): WithdrawalReasonType {
return when (value) {
"LIMITED_ROUTE_OPTIONS" -> LIMITED_ROUTE_OPTIONS
"DIFFICULT_ROUTE_RECORDING" -> DIFFICULT_ROUTE_RECORDING
"APP_NOT_AS_EXPECTED" -> APP_NOT_AS_EXPECTED
"LOW_SERVICE_TRUST" -> LOW_SERVICE_TRUST
"ETC" -> ETC
else -> throw IllegalArgumentException("Invalid value: $value")
}
}
}
}
4 changes: 4 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 @@ -109,4 +109,8 @@ class User(
fun updateProfileImageUrl(profileImageUrl: String) {
this.profileImageUrl = profileImageUrl
}

fun deleteUser() {
this.deletedAt = LocalDateTime.now()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ enum class CustomExceptionType(
USER_SOCIAL_LOGIN_UID_DUPLICATION(3001, "이미 가입된 계정입니다."),
USER_NICKNAME_DUPLICATION(3002, "이미 사용중인 닉네임입니다."),
NO_AVAILABLE_COUPON(3003, "이용 가능한 쿠폰이 없습니다."),
USER_WITHDRAWN(3004, "탈퇴한 유저입니다."),

/**
* 루트 관련 예외
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.routebox.routebox.exception.user

import com.routebox.routebox.exception.CustomExceptionType
import com.routebox.routebox.exception.common.ConflictException
class UserWithdrawnException : ConflictException(CustomExceptionType.USER_WITHDRAWN)
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.routebox.routebox.infrastructure.auth

import com.routebox.routebox.domain.auth.WithdrawalHistory
import org.springframework.data.jpa.repository.JpaRepository

interface WithdrawalHistoryRepository : JpaRepository<WithdrawalHistory, Long>
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.routebox.routebox.security

import com.routebox.routebox.domain.user.UserService
import com.routebox.routebox.exception.user.UserWithdrawnException
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.security.core.userdetails.UserDetailsService
Expand All @@ -12,6 +13,11 @@ class CustomUserDetailsService {
fun userDetailsService(userService: UserService): UserDetailsService =
UserDetailsService { username ->
val user = userService.getUserById(username.toLong())

if (user.deletedAt != null) {
throw UserWithdrawnException()
}

UserPrincipal(
userId = user.id,
socialLoginUid = user.socialLoginUid,
Expand Down
12 changes: 11 additions & 1 deletion src/main/resources/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,16 @@ CREATE TABLE route_report
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
PRIMARY KEY (route_report_id)
)
);
-- CREATE INDEX idx__route_report__reporter_id ON route_report (reporter_id);
-- CREATE INDEX idx__route_report__reported_route_id ON route_report (reported_route_id);

CREATE TABLE withdrawal_histories
(
withdrawal_history_id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '탈퇴 기록 ID',
user_id BIGINT NOT NULL COMMENT '사용자 ID',
reason_type VARCHAR(255) NULL COMMENT '탈퇴 사유',
reason_detail VARCHAR(1600) NULL COMMENT '탈퇴 상세 사유',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 시간',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '업데이트 시간'
);
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.routebox.routebox.controller.auth
import com.fasterxml.jackson.databind.ObjectMapper
import com.routebox.routebox.application.auth.OAuthLoginUseCase
import com.routebox.routebox.application.auth.RefreshTokensUseCase
import com.routebox.routebox.application.auth.WithdrawUseCase
import com.routebox.routebox.application.auth.dto.LoginResult
import com.routebox.routebox.application.auth.dto.OAuthLoginCommand
import com.routebox.routebox.config.ControllerTestConfig
Expand Down Expand Up @@ -39,6 +40,9 @@ class AuthControllerTest @Autowired constructor(
@MockBean
lateinit var refreshTokensUseCase: RefreshTokensUseCase

@MockBean
lateinit var withdrawUseCase: WithdrawUseCase

@Test
fun `카카오에서 발급받은 access token이 주어지고, 주어진 token으로 로그인한다`() {
// given
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import com.routebox.routebox.exception.kakao.RequestKakaoUserInfoException
import com.routebox.routebox.infrastructure.apple.AppleApiClient
import com.routebox.routebox.infrastructure.apple.AppleAuthKey
import com.routebox.routebox.infrastructure.apple.AppleAuthKeys
import com.routebox.routebox.infrastructure.auth.WithdrawalHistoryRepository
import com.routebox.routebox.infrastructure.kakao.KakaoApiClient
import com.routebox.routebox.infrastructure.kakao.KakaoUserInfo
import com.routebox.routebox.security.JwtInfo
Expand Down Expand Up @@ -42,6 +43,9 @@ class AuthServiceTest {
@Mock
lateinit var refreshTokenRepository: RefreshTokenRepository

@Mock
lateinit var withdrawalHistoryRepository: WithdrawalHistoryRepository

@Mock
lateinit var jwtManager: JwtManager

Expand Down

0 comments on commit bf91361

Please sign in to comment.