From b041df2167f5c9180b83866ca90a60a4e12c2a9b Mon Sep 17 00:00:00 2001 From: pricelees Date: Thu, 11 Sep 2025 19:48:48 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=EA=B0=80=EC=9E=85=20?= =?UTF-8?q?API=20=EB=B0=8F=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../roomescape/member/business/UserService.kt | 41 ++++- .../member/business/UserValidator.kt | 29 ++++ .../kotlin/roomescape/member/docs/UserAPI.kt | 30 ++++ .../member/exception/UserException.kt | 6 +- .../persistence/UserRepositories.kt | 2 + .../roomescape/member/web/UserController.kt | 25 +++ .../kotlin/roomescape/member/web/UserDTO.kt | 43 +++++ .../kotlin/roomescape/user/UserApiTest.kt | 147 ++++++++++++++++++ 8 files changed, 320 insertions(+), 3 deletions(-) create mode 100644 src/main/kotlin/roomescape/member/business/UserValidator.kt create mode 100644 src/main/kotlin/roomescape/member/docs/UserAPI.kt create mode 100644 src/main/kotlin/roomescape/member/web/UserController.kt create mode 100644 src/main/kotlin/roomescape/member/web/UserDTO.kt create mode 100644 src/test/kotlin/roomescape/user/UserApiTest.kt diff --git a/src/main/kotlin/roomescape/member/business/UserService.kt b/src/main/kotlin/roomescape/member/business/UserService.kt index 036f5033..86804401 100644 --- a/src/main/kotlin/roomescape/member/business/UserService.kt +++ b/src/main/kotlin/roomescape/member/business/UserService.kt @@ -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}" } + } + } } diff --git a/src/main/kotlin/roomescape/member/business/UserValidator.kt b/src/main/kotlin/roomescape/member/business/UserValidator.kt new file mode 100644 index 00000000..ede9f9a7 --- /dev/null +++ b/src/main/kotlin/roomescape/member/business/UserValidator.kt @@ -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) + } + } +} diff --git a/src/main/kotlin/roomescape/member/docs/UserAPI.kt b/src/main/kotlin/roomescape/member/docs/UserAPI.kt new file mode 100644 index 00000000..a8011f85 --- /dev/null +++ b/src/main/kotlin/roomescape/member/docs/UserAPI.kt @@ -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> +} diff --git a/src/main/kotlin/roomescape/member/exception/UserException.kt b/src/main/kotlin/roomescape/member/exception/UserException.kt index 93115986..e0421f79 100644 --- a/src/main/kotlin/roomescape/member/exception/UserException.kt +++ b/src/main/kotlin/roomescape/member/exception/UserException.kt @@ -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", "이미 가입된 휴대폰 번호가 있어요."), +} diff --git a/src/main/kotlin/roomescape/member/infrastructure/persistence/UserRepositories.kt b/src/main/kotlin/roomescape/member/infrastructure/persistence/UserRepositories.kt index 13a0adb3..37b6f4e0 100644 --- a/src/main/kotlin/roomescape/member/infrastructure/persistence/UserRepositories.kt +++ b/src/main/kotlin/roomescape/member/infrastructure/persistence/UserRepositories.kt @@ -4,6 +4,8 @@ import org.springframework.data.jpa.repository.JpaRepository interface UserRepository : JpaRepository { + fun existsByEmail(email: String): Boolean + fun existsByPhone(phone: String): Boolean fun findByEmail(email: String): UserEntity? } diff --git a/src/main/kotlin/roomescape/member/web/UserController.kt b/src/main/kotlin/roomescape/member/web/UserController.kt new file mode 100644 index 00000000..30c182c9 --- /dev/null +++ b/src/main/kotlin/roomescape/member/web/UserController.kt @@ -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> { + val response = userService.signup(request) + + return ResponseEntity.ok(CommonApiResponse(response)) + } +} diff --git a/src/main/kotlin/roomescape/member/web/UserDTO.kt b/src/main/kotlin/roomescape/member/web/UserDTO.kt new file mode 100644 index 00000000..ed9fe940 --- /dev/null +++ b/src/main/kotlin/roomescape/member/web/UserDTO.kt @@ -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 +) diff --git a/src/test/kotlin/roomescape/user/UserApiTest.kt b/src/test/kotlin/roomescape/user/UserApiTest.kt new file mode 100644 index 00000000..5bb1657f --- /dev/null +++ b/src/test/kotlin/roomescape/user/UserApiTest.kt @@ -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.") + } +}