[#34] 회원 / 인증 도메인 재정의 #43

Merged
pricelees merged 73 commits from refactor/#34 into main 2025-09-13 10:13:45 +00:00
8 changed files with 320 additions and 3 deletions
Showing only changes of commit b041df2167 - Show all commits

View File

@ -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}" }
}
}
}

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

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

View File

@ -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", "이미 가입된 휴대폰 번호가 있어요."),
}

View File

@ -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?
}

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

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

View 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.")
}
}