generated from pricelees/issue-pr-template
[#34] 회원 / 인증 도메인 재정의 #43
@ -1,23 +1,32 @@
|
||||
package roomescape.member.business
|
||||
|
||||
import com.github.f4b6a3.tsid.TsidFactory
|
||||
import io.github.oshai.kotlinlogging.KLogger
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import org.springframework.data.repository.findByIdOrNull
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import roomescape.common.config.next
|
||||
import roomescape.common.dto.CurrentUserContext
|
||||
import roomescape.common.dto.PrincipalType
|
||||
import roomescape.common.dto.UserLoginCredentials
|
||||
import roomescape.member.exception.UserErrorCode
|
||||
import roomescape.member.exception.UserException
|
||||
import roomescape.member.infrastructure.persistence.UserEntity
|
||||
import roomescape.member.infrastructure.persistence.UserRepository
|
||||
import roomescape.member.infrastructure.persistence.*
|
||||
import roomescape.member.web.UserCreateRequest
|
||||
import roomescape.member.web.UserCreateResponse
|
||||
import roomescape.member.web.toEntity
|
||||
|
||||
private val log: KLogger = KotlinLogging.logger {}
|
||||
|
||||
const val SIGNUP: String = "최초 가입"
|
||||
|
||||
@Service
|
||||
class UserService(
|
||||
private val userRepository: UserRepository,
|
||||
private val userStatusHistoryRepository: UserStatusHistoryRepository,
|
||||
private val userValidator: UserValidator,
|
||||
private val tsidFactory: TsidFactory
|
||||
) {
|
||||
@Transactional(readOnly = true)
|
||||
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 {
|
||||
return userRepository.findByIdOrNull(id)
|
||||
?: 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 errorCode: 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> {
|
||||
|
||||
fun existsByEmail(email: String): Boolean
|
||||
fun existsByPhone(phone: String): Boolean
|
||||
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