generated from pricelees/issue-pr-template
[#34] 회원 / 인증 도메인 재정의 #43
@ -1,23 +1,32 @@
|
|||||||
package roomescape.member.business
|
package roomescape.member.business
|
||||||
|
|
||||||
|
import com.github.f4b6a3.tsid.TsidFactory
|
||||||
import io.github.oshai.kotlinlogging.KLogger
|
import io.github.oshai.kotlinlogging.KLogger
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
import org.springframework.data.repository.findByIdOrNull
|
import org.springframework.data.repository.findByIdOrNull
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import roomescape.common.config.next
|
||||||
import roomescape.common.dto.CurrentUserContext
|
import roomescape.common.dto.CurrentUserContext
|
||||||
import roomescape.common.dto.PrincipalType
|
import roomescape.common.dto.PrincipalType
|
||||||
import roomescape.common.dto.UserLoginCredentials
|
import roomescape.common.dto.UserLoginCredentials
|
||||||
import roomescape.member.exception.UserErrorCode
|
import roomescape.member.exception.UserErrorCode
|
||||||
import roomescape.member.exception.UserException
|
import roomescape.member.exception.UserException
|
||||||
import roomescape.member.infrastructure.persistence.UserEntity
|
import roomescape.member.infrastructure.persistence.*
|
||||||
import roomescape.member.infrastructure.persistence.UserRepository
|
import roomescape.member.web.UserCreateRequest
|
||||||
|
import roomescape.member.web.UserCreateResponse
|
||||||
|
import roomescape.member.web.toEntity
|
||||||
|
|
||||||
private val log: KLogger = KotlinLogging.logger {}
|
private val log: KLogger = KotlinLogging.logger {}
|
||||||
|
|
||||||
|
const val SIGNUP: String = "최초 가입"
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
class UserService(
|
class UserService(
|
||||||
private val userRepository: UserRepository,
|
private val userRepository: UserRepository,
|
||||||
|
private val userStatusHistoryRepository: UserStatusHistoryRepository,
|
||||||
|
private val userValidator: UserValidator,
|
||||||
|
private val tsidFactory: TsidFactory
|
||||||
) {
|
) {
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun findContextById(id: Long): CurrentUserContext {
|
fun findContextById(id: Long): CurrentUserContext {
|
||||||
@ -45,8 +54,36 @@ class UserService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun signup(request: UserCreateRequest): UserCreateResponse {
|
||||||
|
log.info { "[UserService.signup] 회원가입 시작: request:$request" }
|
||||||
|
|
||||||
|
userValidator.validateCanSignup(request.email, request.phone)
|
||||||
|
|
||||||
|
val user: UserEntity = userRepository.save(
|
||||||
|
request.toEntity(id = tsidFactory.next(), status = UserStatus.ACTIVE)
|
||||||
|
).also {
|
||||||
|
log.info { "[UserService.signup] 회원 저장 완료: id:${it.id}" }
|
||||||
|
}.also {
|
||||||
|
createHistory(user = it, reason = SIGNUP)
|
||||||
|
}
|
||||||
|
|
||||||
|
return UserCreateResponse(user.id, user.name)
|
||||||
|
.also {
|
||||||
|
log.info { "[UserService.signup] 회원가입 완료: id:${it.id}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun findOrThrow(id: Long): UserEntity {
|
private fun findOrThrow(id: Long): UserEntity {
|
||||||
return userRepository.findByIdOrNull(id)
|
return userRepository.findByIdOrNull(id)
|
||||||
?: throw UserException(UserErrorCode.USER_NOT_FOUND)
|
?: throw UserException(UserErrorCode.USER_NOT_FOUND)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun createHistory(user: UserEntity, reason: String): UserStatusHistoryEntity {
|
||||||
|
return userStatusHistoryRepository.save(
|
||||||
|
UserStatusHistoryEntity(id = tsidFactory.next(), userId = user.id, reason = reason, status = user.status)
|
||||||
|
).also {
|
||||||
|
log.info { "[UserService.signup] 회원 상태 이력 저장 완료: userStatusHistoryId:${it.id}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
29
src/main/kotlin/roomescape/member/business/UserValidator.kt
Normal file
29
src/main/kotlin/roomescape/member/business/UserValidator.kt
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
package roomescape.member.business
|
||||||
|
|
||||||
|
import io.github.oshai.kotlinlogging.KLogger
|
||||||
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
|
import org.springframework.stereotype.Component
|
||||||
|
import roomescape.member.exception.UserErrorCode
|
||||||
|
import roomescape.member.exception.UserException
|
||||||
|
import roomescape.member.infrastructure.persistence.UserRepository
|
||||||
|
|
||||||
|
private val log: KLogger = KotlinLogging.logger {}
|
||||||
|
|
||||||
|
@Component
|
||||||
|
class UserValidator(
|
||||||
|
private val userRepository: UserRepository,
|
||||||
|
) {
|
||||||
|
|
||||||
|
fun validateCanSignup(email: String, phone: String) {
|
||||||
|
log.info { "[UserValidator.validateCanSignup] 회원가입 가능 여부 검증 시작: email:$email / phone:$phone" }
|
||||||
|
|
||||||
|
if (userRepository.existsByEmail(email)) {
|
||||||
|
log.info { "[UserValidator.validateCanSignup] 중복된 이메일 입력으로 인한 실패: email:$email" }
|
||||||
|
throw UserException(UserErrorCode.EMAIL_ALREADY_EXISTS)
|
||||||
|
}
|
||||||
|
if (userRepository.existsByPhone(phone)) {
|
||||||
|
log.info { "[UserValidator.validateCanSignup] 중복된 휴대폰 번호 입력으로 인한 실패: phone:$phone" }
|
||||||
|
throw UserException(UserErrorCode.PHONE_ALREADY_EXISTS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
30
src/main/kotlin/roomescape/member/docs/UserAPI.kt
Normal file
30
src/main/kotlin/roomescape/member/docs/UserAPI.kt
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
package roomescape.member.docs
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.Operation
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponse
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponses
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag
|
||||||
|
import jakarta.validation.Valid
|
||||||
|
import org.springframework.http.ResponseEntity
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
|
import roomescape.auth.web.support.Public
|
||||||
|
import roomescape.common.dto.response.CommonApiResponse
|
||||||
|
import roomescape.member.web.UserCreateRequest
|
||||||
|
import roomescape.member.web.UserCreateResponse
|
||||||
|
|
||||||
|
@Tag(name = "2. 회원 API", description = "회원 정보를 관리할 때 사용합니다.")
|
||||||
|
interface UserAPI {
|
||||||
|
|
||||||
|
@Public
|
||||||
|
@Operation(summary = "회원 가입")
|
||||||
|
@ApiResponses(
|
||||||
|
ApiResponse(
|
||||||
|
responseCode = "200",
|
||||||
|
description = "성공",
|
||||||
|
useReturnTypeSchema = true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
fun signup(
|
||||||
|
@Valid @RequestBody request: UserCreateRequest
|
||||||
|
): ResponseEntity<CommonApiResponse<UserCreateResponse>>
|
||||||
|
}
|
||||||
@ -13,4 +13,8 @@ enum class UserErrorCode(
|
|||||||
override val httpStatus: HttpStatus,
|
override val httpStatus: HttpStatus,
|
||||||
override val errorCode: String,
|
override val errorCode: String,
|
||||||
override val message: String
|
override val message: String
|
||||||
) : ErrorCode
|
) : ErrorCode {
|
||||||
|
USER_NOT_FOUND(HttpStatus.NOT_FOUND, "U001", "회원을 찾을 수 없어요."),
|
||||||
|
EMAIL_ALREADY_EXISTS(HttpStatus.CONFLICT, "U002", "이미 가입된 이메일이에요."),
|
||||||
|
PHONE_ALREADY_EXISTS(HttpStatus.CONFLICT, "U003", "이미 가입된 휴대폰 번호가 있어요."),
|
||||||
|
}
|
||||||
|
|||||||
@ -4,6 +4,8 @@ import org.springframework.data.jpa.repository.JpaRepository
|
|||||||
|
|
||||||
interface UserRepository : JpaRepository<UserEntity, Long> {
|
interface UserRepository : JpaRepository<UserEntity, Long> {
|
||||||
|
|
||||||
|
fun existsByEmail(email: String): Boolean
|
||||||
|
fun existsByPhone(phone: String): Boolean
|
||||||
fun findByEmail(email: String): UserEntity?
|
fun findByEmail(email: String): UserEntity?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
25
src/main/kotlin/roomescape/member/web/UserController.kt
Normal file
25
src/main/kotlin/roomescape/member/web/UserController.kt
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
package roomescape.member.web
|
||||||
|
|
||||||
|
import jakarta.validation.Valid
|
||||||
|
import org.springframework.http.ResponseEntity
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
import roomescape.common.dto.response.CommonApiResponse
|
||||||
|
import roomescape.member.business.UserService
|
||||||
|
import roomescape.member.docs.UserAPI
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
class UserController(
|
||||||
|
private val userService: UserService
|
||||||
|
) : UserAPI {
|
||||||
|
|
||||||
|
@PostMapping("/users")
|
||||||
|
override fun signup(
|
||||||
|
@Valid @RequestBody request: UserCreateRequest
|
||||||
|
): ResponseEntity<CommonApiResponse<UserCreateResponse>> {
|
||||||
|
val response = userService.signup(request)
|
||||||
|
|
||||||
|
return ResponseEntity.ok(CommonApiResponse(response))
|
||||||
|
}
|
||||||
|
}
|
||||||
43
src/main/kotlin/roomescape/member/web/UserDTO.kt
Normal file
43
src/main/kotlin/roomescape/member/web/UserDTO.kt
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
package roomescape.member.web
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.Email
|
||||||
|
import jakarta.validation.constraints.NotEmpty
|
||||||
|
import jakarta.validation.constraints.Pattern
|
||||||
|
import jakarta.validation.constraints.Size
|
||||||
|
import roomescape.member.infrastructure.persistence.UserEntity
|
||||||
|
import roomescape.member.infrastructure.persistence.UserStatus
|
||||||
|
|
||||||
|
const val MIN_PASSWORD_LENGTH = 8
|
||||||
|
|
||||||
|
data class UserCreateRequest(
|
||||||
|
@NotEmpty
|
||||||
|
val name: String,
|
||||||
|
|
||||||
|
@NotEmpty
|
||||||
|
@Email
|
||||||
|
val email: String,
|
||||||
|
|
||||||
|
@Size(min = MIN_PASSWORD_LENGTH)
|
||||||
|
val password: String,
|
||||||
|
|
||||||
|
@NotEmpty
|
||||||
|
@Pattern(regexp = "^010([0-9]{3,4})([0-9]{4})$")
|
||||||
|
val phone: String,
|
||||||
|
|
||||||
|
val regionCode: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun UserCreateRequest.toEntity(id: Long, status: UserStatus) = UserEntity(
|
||||||
|
id = id,
|
||||||
|
name = this.name,
|
||||||
|
email = this.email,
|
||||||
|
password = this.password,
|
||||||
|
phone = this.phone,
|
||||||
|
regionCode = this.regionCode,
|
||||||
|
status = status
|
||||||
|
)
|
||||||
|
|
||||||
|
data class UserCreateResponse(
|
||||||
|
val id: Long,
|
||||||
|
val name: String
|
||||||
|
)
|
||||||
147
src/test/kotlin/roomescape/user/UserApiTest.kt
Normal file
147
src/test/kotlin/roomescape/user/UserApiTest.kt
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
package roomescape.user
|
||||||
|
|
||||||
|
import io.kotest.assertions.assertSoftly
|
||||||
|
import io.kotest.matchers.shouldBe
|
||||||
|
import io.restassured.module.kotlin.extensions.Extract
|
||||||
|
import io.restassured.module.kotlin.extensions.Given
|
||||||
|
import io.restassured.module.kotlin.extensions.Then
|
||||||
|
import io.restassured.module.kotlin.extensions.When
|
||||||
|
import org.hamcrest.CoreMatchers.equalTo
|
||||||
|
import org.springframework.data.repository.findByIdOrNull
|
||||||
|
import org.springframework.http.HttpStatus
|
||||||
|
import org.springframework.http.MediaType
|
||||||
|
import roomescape.common.exception.CommonErrorCode
|
||||||
|
import roomescape.member.business.SIGNUP
|
||||||
|
import roomescape.member.exception.UserErrorCode
|
||||||
|
import roomescape.member.infrastructure.persistence.*
|
||||||
|
import roomescape.member.web.MIN_PASSWORD_LENGTH
|
||||||
|
import roomescape.member.web.UserCreateRequest
|
||||||
|
import roomescape.util.FunSpecSpringbootTest
|
||||||
|
import roomescape.util.UserFixture
|
||||||
|
import roomescape.util.runTest
|
||||||
|
|
||||||
|
class UserApiTest(
|
||||||
|
private val userRepository: UserRepository,
|
||||||
|
private val userStatusHistoryRepository: UserStatusHistoryRepository
|
||||||
|
) : FunSpecSpringbootTest() {
|
||||||
|
|
||||||
|
init {
|
||||||
|
context("회원가입 및 상태 이력을 저장한다.") {
|
||||||
|
val request = UserFixture.createRequest
|
||||||
|
|
||||||
|
test("정상 응답") {
|
||||||
|
runTest(
|
||||||
|
using = {
|
||||||
|
body(request)
|
||||||
|
},
|
||||||
|
on = {
|
||||||
|
post("/users")
|
||||||
|
},
|
||||||
|
expect = {
|
||||||
|
statusCode(HttpStatus.OK.value())
|
||||||
|
body("data.name", equalTo(request.name))
|
||||||
|
}
|
||||||
|
).also {
|
||||||
|
val user: UserEntity = userRepository.findByIdOrNull(it.extract().path("data.id"))
|
||||||
|
?: throw AssertionError("Unexpected Exception Occurred.")
|
||||||
|
val history: UserStatusHistoryEntity = userStatusHistoryRepository.findAll()[0]
|
||||||
|
?: throw AssertionError("Unexpected Exception Occurred.")
|
||||||
|
|
||||||
|
assertSoftly(user) { createdUser ->
|
||||||
|
createdUser.email shouldBe request.email
|
||||||
|
createdUser.status shouldBe UserStatus.ACTIVE
|
||||||
|
}
|
||||||
|
|
||||||
|
assertSoftly(history) { history ->
|
||||||
|
history.userId shouldBe user.id
|
||||||
|
history.status shouldBe UserStatus.ACTIVE
|
||||||
|
history.reason shouldBe SIGNUP
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("이미 사용중인 이메일을 입력하면 실패한다.") {
|
||||||
|
val request = UserFixture.createRequest.also {
|
||||||
|
signup(it)
|
||||||
|
}
|
||||||
|
|
||||||
|
runTest(
|
||||||
|
using = {
|
||||||
|
body(request)
|
||||||
|
},
|
||||||
|
on = {
|
||||||
|
post("/users")
|
||||||
|
},
|
||||||
|
expect = {
|
||||||
|
statusCode(HttpStatus.CONFLICT.value())
|
||||||
|
body("code", equalTo(UserErrorCode.EMAIL_ALREADY_EXISTS.errorCode))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("이미 사용중인 휴대폰 번호를 입력하면 실패한다.") {
|
||||||
|
val createdRequest = UserFixture.createRequest.also { signup(it) }
|
||||||
|
|
||||||
|
runTest(
|
||||||
|
using = {
|
||||||
|
body(createdRequest.copy(email = "another@example.com"))
|
||||||
|
},
|
||||||
|
on = {
|
||||||
|
post("/users")
|
||||||
|
},
|
||||||
|
expect = {
|
||||||
|
statusCode(HttpStatus.CONFLICT.value())
|
||||||
|
body("code", equalTo(UserErrorCode.PHONE_ALREADY_EXISTS.errorCode))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
context("요청 형식이 잘못되면 실패한다.") {
|
||||||
|
val commonRequest = UserFixture.createRequest
|
||||||
|
|
||||||
|
fun runCommonTest(request: UserCreateRequest) {
|
||||||
|
runTest(
|
||||||
|
using = {
|
||||||
|
body(request)
|
||||||
|
},
|
||||||
|
on = {
|
||||||
|
post("/users")
|
||||||
|
},
|
||||||
|
expect = {
|
||||||
|
statusCode(HttpStatus.BAD_REQUEST.value())
|
||||||
|
body("code", equalTo(CommonErrorCode.INVALID_INPUT_VALUE.errorCode))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("빈 이름") { runCommonTest(commonRequest.copy(name = "")) }
|
||||||
|
|
||||||
|
test("이메일 형식") { runCommonTest(commonRequest.copy(email = "account123")) }
|
||||||
|
test("빈 이메일") { runCommonTest(commonRequest.copy(email = "")) }
|
||||||
|
|
||||||
|
test("${MIN_PASSWORD_LENGTH}글자 미만 비밀번호.") {
|
||||||
|
runCommonTest(commonRequest.copy(password = "a".repeat((MIN_PASSWORD_LENGTH - 1))))
|
||||||
|
}
|
||||||
|
|
||||||
|
test("010123(4)5678 형식이 아닌 전화번호") { runCommonTest(commonRequest.copy(phone = "01112345678")) }
|
||||||
|
test("빈 전화번호") { runCommonTest(commonRequest.copy(phone = "")) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun signup(request: UserCreateRequest): UserEntity {
|
||||||
|
val userId: Long = Given {
|
||||||
|
contentType(MediaType.APPLICATION_JSON_VALUE)
|
||||||
|
body(request)
|
||||||
|
} When {
|
||||||
|
post("/users")
|
||||||
|
} Then {
|
||||||
|
statusCode(HttpStatus.OK.value())
|
||||||
|
} Extract {
|
||||||
|
path("data.id")
|
||||||
|
}
|
||||||
|
|
||||||
|
return userRepository.findByIdOrNull(userId)
|
||||||
|
?: throw AssertionError("Unexpected Exception Occurred.")
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user