diff --git a/submodules/commons/src/main/kotlin/com/sns/commons/utils/IfBoolean.kt b/submodules/commons/src/main/kotlin/com/sns/commons/utils/IfBoolean.kt new file mode 100644 index 0000000..33c18e6 --- /dev/null +++ b/submodules/commons/src/main/kotlin/com/sns/commons/utils/IfBoolean.kt @@ -0,0 +1,15 @@ +package com.sns.commons.utils + +inline fun Boolean?.ifTrue(block: Boolean.() -> Unit): Boolean? { + if (this == true) { + block() + } + return this +} + +inline fun Boolean?.ifFalse(block: Boolean.() -> Unit): Boolean? { + if (this == true) { + block() + } + return this +} diff --git a/user-api/src/main/kotlin/com/sns/user/component/authcode/application/AuthCodeCommand.kt b/user-api/src/main/kotlin/com/sns/user/component/authcode/application/AuthCodeCommand.kt index 11d70d5..e3a467f 100644 --- a/user-api/src/main/kotlin/com/sns/user/component/authcode/application/AuthCodeCommand.kt +++ b/user-api/src/main/kotlin/com/sns/user/component/authcode/application/AuthCodeCommand.kt @@ -13,7 +13,7 @@ import org.springframework.stereotype.Service class AuthCodeCommand( val authCodeRepository: AuthCodeRepository, val mailService: MailService, - val userRepository: DefaultUserRepository + val userRepository: DefaultUserRepository, ) { fun create(userId: String): AuthCode { val user = userRepository.findByIdOrNull(userId) ?: throw NoAuthorityException() @@ -27,9 +27,6 @@ class AuthCodeCommand( fun verify(userId: String, purpose: Purpose, code: String): Boolean { val authCode: AuthCode? = authCodeRepository.findByUserIdAndPurpose(userId, purpose) - return authCode?.isCorrect(userId, code, purpose) - .takeIf { it == true }.apply { - // TOOD update STATUS userRepository.save() - } ?: false + return authCode?.isCorrect(userId, code, purpose) ?: false } } diff --git a/user-api/src/main/kotlin/com/sns/user/component/user/application/UserCommandService.kt b/user-api/src/main/kotlin/com/sns/user/component/user/application/UserCommandService.kt new file mode 100644 index 0000000..63df37b --- /dev/null +++ b/user-api/src/main/kotlin/com/sns/user/component/user/application/UserCommandService.kt @@ -0,0 +1,34 @@ +package com.sns.user.component.user.application + +import com.sns.user.component.user.domains.User +import com.sns.user.component.user.repositories.UserRepository +import com.sns.user.core.exceptions.NoAuthorityException +import org.springframework.context.ApplicationEventPublisher +import org.springframework.data.repository.findByIdOrNull +import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.stereotype.Service + +@Service +class UserCommandService( + private val userRepository: UserRepository, + private val passwordEncoder: PasswordEncoder, + private val eventPublisher: ApplicationEventPublisher +) { + + fun create(name: String, password: String, email: String): User { + val user = User.create(email, passwordEncoder.encode(password), name, email) { + eventPublisher.publishEvent(it) + } + userRepository.save(user) + return user + } + + fun activate(userId: String) { + val user = userRepository.findByIdOrNull(userId) ?: throw NoAuthorityException() + + user.activate() { + eventPublisher.publishEvent(it) + } + userRepository.save(user) + } +} diff --git a/user-api/src/main/kotlin/com/sns/user/component/user/application/UserQueryService.kt b/user-api/src/main/kotlin/com/sns/user/component/user/application/UserQueryService.kt index 35ff64f..a39491e 100644 --- a/user-api/src/main/kotlin/com/sns/user/component/user/application/UserQueryService.kt +++ b/user-api/src/main/kotlin/com/sns/user/component/user/application/UserQueryService.kt @@ -10,4 +10,6 @@ class UserQueryService( private val userRepository: UserRepository ) { fun getById(id: String): User? = userRepository.findByIdOrNull(id) + + fun getByEmail(email: String): User? = userRepository.findByInfoEmailAddressOrNull(email) } diff --git a/user-api/src/main/kotlin/com/sns/user/component/user/domains/User.kt b/user-api/src/main/kotlin/com/sns/user/component/user/domains/User.kt index 0fc32d5..b460d21 100644 --- a/user-api/src/main/kotlin/com/sns/user/component/user/domains/User.kt +++ b/user-api/src/main/kotlin/com/sns/user/component/user/domains/User.kt @@ -1,5 +1,9 @@ package com.sns.user.component.user.domains +import com.sns.commons.DomainEvent +import com.sns.user.component.user.events.UserActivatedEvent +import com.sns.user.component.user.events.UserCreatedEvent +import java.sql.ResultSet import java.time.Instant import javax.validation.constraints.Max import javax.validation.constraints.NotBlank @@ -8,6 +12,7 @@ import org.springframework.data.annotation.Id import org.springframework.data.annotation.LastModifiedDate import org.springframework.data.annotation.Transient import org.springframework.data.domain.Persistable +import org.springframework.jdbc.core.RowMapper data class User( @Id @@ -32,6 +37,9 @@ data class User( @LastModifiedDate var updatedAt: Instant = Instant.MIN, + + @NotBlank + var status: Status = Status.ACTIVATED ) : Persistable { @Transient private var new: Boolean = false @@ -39,20 +47,54 @@ data class User( override fun getId() = this.id override fun isNew() = new + fun activate(publish: (DomainEvent) -> Unit = { _ -> }) { + status = Status.ACTIVATED + publish(UserActivatedEvent(this)) + } + companion object { + val MAPPER: RowMapper = UserRowMapper() + fun create( id: String, password: String, name: String, - infoEmailAddress: String? = null + infoEmailAddress: String? = null, + publish: (DomainEvent) -> Unit = { _ -> } ): User { // TODO validation - return User( + val user = User( id = id, - password = password, // TODO encrypt + password = password, name = name, infoEmailAddress = infoEmailAddress ?: id, + status = Status.ON_SIGN_UP, ).apply { new = true } + + publish(UserCreatedEvent(user)) + + return user } } } + +// purpose enum 매핑이 안되서 수동으로 작성함. 확인필요. +class UserRowMapper : RowMapper { + override fun mapRow(rs: ResultSet, rowNum: Int): User? { + return User( + id = rs.getString("id"), + password = rs.getString("password"), + name = rs.getString("name"), + infoEmailAddress = rs.getString("info_email_address"), + status = Status.valueOf(rs.getString("status")), + createdAt = Instant.ofEpochMilli(rs.getTimestamp("created_at").time), + updatedAt = Instant.ofEpochMilli(rs.getTimestamp("updated_at").time), + ) + } +} + +enum class Status { + ON_SIGN_UP, + ACTIVATED, + // 비활 등등? +} diff --git a/user-api/src/main/kotlin/com/sns/user/component/user/events/UserActivatedEvent.kt b/user-api/src/main/kotlin/com/sns/user/component/user/events/UserActivatedEvent.kt new file mode 100644 index 0000000..7877911 --- /dev/null +++ b/user-api/src/main/kotlin/com/sns/user/component/user/events/UserActivatedEvent.kt @@ -0,0 +1,12 @@ +package com.sns.user.component.user.events + +import com.sns.commons.DomainEvent +import com.sns.user.component.user.domains.User +import com.sns.user.core.config.IntegrationConfig + +class UserActivatedEvent(val user: User) : DomainEvent { + override val eventId: String + get() = "$channel-$user.id-${System.currentTimeMillis()}" + + override val channel = IntegrationConfig.Channels.USER_STATUS +} diff --git a/user-api/src/main/kotlin/com/sns/user/component/user/events/UserCreatedEvent.kt b/user-api/src/main/kotlin/com/sns/user/component/user/events/UserCreatedEvent.kt new file mode 100644 index 0000000..a248b96 --- /dev/null +++ b/user-api/src/main/kotlin/com/sns/user/component/user/events/UserCreatedEvent.kt @@ -0,0 +1,12 @@ +package com.sns.user.component.user.events + +import com.sns.commons.DomainEvent +import com.sns.user.component.user.domains.User +import com.sns.user.core.config.IntegrationConfig + +class UserCreatedEvent(val user: User) : DomainEvent { + override val eventId: String + get() = "$channel-$user.id-${System.currentTimeMillis()}" + + override val channel = IntegrationConfig.Channels.USER_STATUS +} diff --git a/user-api/src/main/kotlin/com/sns/user/component/user/listeners/UserStatusListener.kt b/user-api/src/main/kotlin/com/sns/user/component/user/listeners/UserStatusListener.kt new file mode 100644 index 0000000..0590b8f --- /dev/null +++ b/user-api/src/main/kotlin/com/sns/user/component/user/listeners/UserStatusListener.kt @@ -0,0 +1,19 @@ +package com.sns.user.component.user.listeners + +import com.sns.commons.annotation.CustomEventListener +import com.sns.user.component.authcode.application.AuthCodeCommand +import com.sns.user.component.user.events.UserActivatedEvent +import com.sns.user.component.user.events.UserCreatedEvent + +@CustomEventListener +class UserStatusListener(val authCodeCommand: AuthCodeCommand) { + // 인증 전, 기초 가입만 마친 상태 + fun onCreated(createdEvent: UserCreatedEvent) { + val user = createdEvent.user + authCodeCommand.create(user.id) + } + + fun onActivated(activatedEvent: UserActivatedEvent) { + // TODO 타임라인생성, 프로필생성,, + } +} diff --git a/user-api/src/main/kotlin/com/sns/user/component/user/repositories/DefaultUserRepository.kt b/user-api/src/main/kotlin/com/sns/user/component/user/repositories/DefaultUserRepository.kt index 5c21564..a844c3d 100644 --- a/user-api/src/main/kotlin/com/sns/user/component/user/repositories/DefaultUserRepository.kt +++ b/user-api/src/main/kotlin/com/sns/user/component/user/repositories/DefaultUserRepository.kt @@ -1,9 +1,23 @@ package com.sns.user.component.user.repositories +import com.sns.user.component.user.domains.User +import org.springframework.jdbc.core.JdbcTemplate import org.springframework.stereotype.Repository @Repository class DefaultUserRepository( - userCrudRepository: UserCrudRepository + userCrudRepository: UserCrudRepository, + private val jdbcTemplate: JdbcTemplate ) : UserRepository, - UserCrudRepository by userCrudRepository + UserCrudRepository by userCrudRepository { + + override fun findByInfoEmailAddressOrNull(email: String): User? = jdbcTemplate.queryForObject( + """ + SELECT `id`,`password`,`name`,info_email_address,created_at,updated_at,`status` + FROM `user` + WHERE info_email_address = ? + LIMIT 1 + """.trimIndent(), + User.MAPPER, email, + ) +} diff --git a/user-api/src/main/kotlin/com/sns/user/component/user/repositories/UserRepository.kt b/user-api/src/main/kotlin/com/sns/user/component/user/repositories/UserRepository.kt index eb557ee..d2a2d4f 100644 --- a/user-api/src/main/kotlin/com/sns/user/component/user/repositories/UserRepository.kt +++ b/user-api/src/main/kotlin/com/sns/user/component/user/repositories/UserRepository.kt @@ -5,4 +5,6 @@ import org.springframework.data.repository.CrudRepository import org.springframework.data.repository.NoRepositoryBean @NoRepositoryBean -interface UserRepository : CrudRepository +interface UserRepository : CrudRepository { + fun findByInfoEmailAddressOrNull(email: String): User? +} diff --git a/user-api/src/main/kotlin/com/sns/user/core/config/IntegrationConfig.kt b/user-api/src/main/kotlin/com/sns/user/core/config/IntegrationConfig.kt index b088abf..e18d70c 100644 --- a/user-api/src/main/kotlin/com/sns/user/core/config/IntegrationConfig.kt +++ b/user-api/src/main/kotlin/com/sns/user/core/config/IntegrationConfig.kt @@ -3,6 +3,9 @@ package com.sns.user.core.config import com.sns.commons.config.IntegrationEventBaseConfig import com.sns.user.component.test.dtos.LaughingEvent import com.sns.user.component.test.listeners.EmotionListener +import com.sns.user.component.user.events.UserActivatedEvent +import com.sns.user.component.user.events.UserCreatedEvent +import com.sns.user.component.user.listeners.UserStatusListener import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Import @@ -20,7 +23,19 @@ class IntegrationConfig { } } + @Bean + fun userStatusFlow(userStatusListener: UserStatusListener) = integrationFlow { + channel { publishSubscribe(Channels.USER_STATUS) } + handle { event, _ -> + userStatusListener.onCreated(event) + } + handle { event, _ -> + userStatusListener.onActivated(event) + } + } + object Channels { const val EMOTION = "EMOTION_CHANNEL" + const val USER_STATUS = "USER_STATUS_CHANNEL" } } diff --git a/user-api/src/main/kotlin/com/sns/user/endpoints/user/SignUpController.kt b/user-api/src/main/kotlin/com/sns/user/endpoints/user/SignUpController.kt index 3e47c8c..db92dd7 100644 --- a/user-api/src/main/kotlin/com/sns/user/endpoints/user/SignUpController.kt +++ b/user-api/src/main/kotlin/com/sns/user/endpoints/user/SignUpController.kt @@ -1,7 +1,10 @@ package com.sns.user.endpoints.user +import com.sns.commons.utils.ifTrue import com.sns.user.component.authcode.application.AuthCodeCommand import com.sns.user.component.authcode.domain.Purpose +import com.sns.user.component.user.application.UserCommandService +import com.sns.user.component.user.application.UserQueryService import com.sns.user.core.config.SwaggerTag import com.sns.user.endpoints.user.requests.SignUpRequest import io.swagger.v3.oas.annotations.media.Content @@ -25,13 +28,17 @@ import org.springframework.web.bind.annotation.RestController @RestController @Tag(name = SwaggerTag.SIGN_UP) @RequestMapping("/api") -class SignUpController(val authCodeCommand: AuthCodeCommand) { +class SignUpController( + val authCodeCommand: AuthCodeCommand, + val userQueryService: UserQueryService, + val userCommandService: UserCommandService +) { @ApiResponse(description = "회원 가입", responseCode = "202") @ResponseStatus(HttpStatus.CREATED) @PostMapping("/v1/sign-up") fun signUp(@RequestBody request: SignUpRequest) { - // TODO 패스워드 유효성 검증 + userCommandService.create(request.name, request.password, request.email) } @ApiResponse( @@ -41,15 +48,14 @@ class SignUpController(val authCodeCommand: AuthCodeCommand) { @ResponseStatus(HttpStatus.OK) @GetMapping("/v1/sign-up/verifications/emails/{email}") fun verifyEmail(@Email @PathVariable email: String): ResponseEntity { - // TODO email 중복 검사 - return ResponseEntity.ok(false) + return (userQueryService.getByEmail(email) != null) + .let { ResponseEntity.ok(it) } } @ApiResponse(description = "가입 인증 코드 재발송", responseCode = "202") @ResponseStatus(HttpStatus.CREATED) @PutMapping("/v1/sign-up/verifications/auth-code/ids/{userId}") fun createAuthenticationCode(@PathVariable userId: String) { - authCodeCommand.create(userId) } @@ -60,6 +66,10 @@ class SignUpController(val authCodeCommand: AuthCodeCommand) { @ResponseStatus(HttpStatus.OK) @PostMapping("/v1/sign-up/verifications/auth-code/ids/{userId}") fun verifyAuthenticationCode(@PathVariable userId: String, @RequestBody code: String): ResponseEntity { - return ResponseEntity.ok(authCodeCommand.verify(userId, Purpose.SIGN_UP, code)) + return authCodeCommand.verify(userId, Purpose.SIGN_UP, code) + .ifTrue { userCommandService.activate(userId) } + .let { + ResponseEntity.ok(it) + } } } diff --git a/user-api/src/main/kotlin/com/sns/user/endpoints/user/requests/SignUpRequest.kt b/user-api/src/main/kotlin/com/sns/user/endpoints/user/requests/SignUpRequest.kt index 53df568..f0842f5 100644 --- a/user-api/src/main/kotlin/com/sns/user/endpoints/user/requests/SignUpRequest.kt +++ b/user-api/src/main/kotlin/com/sns/user/endpoints/user/requests/SignUpRequest.kt @@ -1,16 +1,21 @@ package com.sns.user.endpoints.user.requests import javax.validation.constraints.Email +import javax.validation.constraints.Max import javax.validation.constraints.NotEmpty -import org.hibernate.validator.constraints.Length +import javax.validation.constraints.Pattern +import javax.validation.constraints.Size data class SignUpRequest( @NotEmpty - @Length(max = 15) + @Max(15) val name: String, + @NotEmpty - @Length(min = 8, max = 30) + @Size(min = 8, max = 30, message = "비밀번호는 8자 이상 30자 미만이어야 합니다.") + @Pattern(regexp = "(?=.*[A-z])(?=.*[0-9])", message = "비밀번호는 영문자와 숫자가 포함되어야 합니다.") val password: String, + @NotEmpty @Email val email: String, diff --git a/user-api/src/test/kotlin/com/sns/user/component/user/application/UserCommandServiceMockTest.kt b/user-api/src/test/kotlin/com/sns/user/component/user/application/UserCommandServiceMockTest.kt new file mode 100644 index 0000000..40fe9ef --- /dev/null +++ b/user-api/src/test/kotlin/com/sns/user/component/user/application/UserCommandServiceMockTest.kt @@ -0,0 +1,59 @@ +package com.sns.user.component.user.application + +import com.sns.commons.DomainEvent +import com.sns.user.component.user.domains.User +import com.sns.user.component.user.events.UserActivatedEvent +import com.sns.user.component.user.events.UserCreatedEvent +import com.sns.user.component.user.repositories.UserRepository +import io.mockk.MockKAnnotations +import io.mockk.every +import io.mockk.impl.annotations.InjectMockKs +import io.mockk.impl.annotations.MockK +import io.mockk.verify +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.context.ApplicationEventPublisher +import org.springframework.data.repository.findByIdOrNull +import org.springframework.security.crypto.password.PasswordEncoder + +internal class UserCommandServiceMockTest { + @MockK + private lateinit var userRepository: UserRepository + + @MockK + private lateinit var passwordEncoder: PasswordEncoder + + @MockK + private lateinit var eventPublisher: ApplicationEventPublisher + + @InjectMockKs + lateinit var userCommandService: UserCommandService + + @BeforeEach + internal fun setUp() { + MockKAnnotations.init(this) + + val user = User.create("id", "passwd", "이름", "dev123@gmail.com") + every { eventPublisher.publishEvent(ofType(DomainEvent::class)) } returns Unit + every { userRepository.save(any()) } returnsArgument 0 + every { userRepository.findByInfoEmailAddressOrNull(ofType(String::class)) } returns user + every { userRepository.findByIdOrNull(ofType(String::class)) } returns user + every { passwordEncoder.encode(any()) } returnsArgument 0 + } + + @Test + fun create() { + userCommandService.create("이름", "passwd", "dev123@gmail.com") + + verify { eventPublisher.publishEvent(ofType(UserCreatedEvent::class)) } + verify { userRepository.save(ofType(User::class)) } + } + + @Test + fun activate() { + userCommandService.activate("dev123@gmail") + + verify { eventPublisher.publishEvent(ofType(UserActivatedEvent::class)) } + verify { userRepository.save(ofType(User::class)) } + } +} diff --git a/user-api/src/test/kotlin/com/sns/user/components/user/repositories/UserRepositoryTest.kt b/user-api/src/test/kotlin/com/sns/user/components/user/repositories/UserRepositoryTest.kt index 7ab387a..a5b60b7 100644 --- a/user-api/src/test/kotlin/com/sns/user/components/user/repositories/UserRepositoryTest.kt +++ b/user-api/src/test/kotlin/com/sns/user/components/user/repositories/UserRepositoryTest.kt @@ -5,6 +5,7 @@ import com.sns.user.component.user.repositories.UserRepository import com.sns.user.hasValueSatisfying import com.sns.user.isEqualTo import com.sns.user.isNotEqualTo +import com.sns.user.satisfies import java.time.Instant import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired @@ -32,4 +33,9 @@ class UserRepositoryTest { savedUser.updatedAt isNotEqualTo Instant.MIN } } + + @Test + internal fun findByInfoEmailAddress() { + userRepository.findByInfoEmailAddressOrNull("dev@gm1.com")!! satisfies { u -> u.name isEqualTo "김개발" } + } } diff --git a/user-api/src/test/resources/db/users/data.sql b/user-api/src/test/resources/db/users/data.sql index 0fb684e..2637dad 100644 --- a/user-api/src/test/resources/db/users/data.sql +++ b/user-api/src/test/resources/db/users/data.sql @@ -1,2 +1,7 @@ +-- user +INSERT INTO `user` (`id`, `password`, `name`, `info_email_address`, `status`, `created_at`, `updated_at`) +VALUES ('userId1', 'passworrd123', '김개발', 'dev@gm1.com', 'ACTIVATED', NOW(), NOW()); + +-- auth_code INSERT INTO auth_code (`user_id`, `code`, created_at, purpose) VALUES ('userId', 'ABC123', NOW(), 'SIGN_UP'); diff --git a/user-api/src/test/resources/db/users/schema.sql b/user-api/src/test/resources/db/users/schema.sql index c81135d..fc85420 100644 --- a/user-api/src/test/resources/db/users/schema.sql +++ b/user-api/src/test/resources/db/users/schema.sql @@ -1,11 +1,10 @@ CREATE TABLE IF NOT EXISTS `user` ( id VARCHAR(50) NOT NULL PRIMARY KEY COMMENT '아이디 (이메일)', - email_address VARCHAR(50) NOT NULL COMMENT '이메일 주소', password VARCHAR(100) NOT NULL COMMENT '비밀번호', `name` VARCHAR(50) NOT NULL COMMENT '이름', info_email_address VARCHAR(50) NOT NULL COMMENT '서비스 정보 수신 이메일주소', - authenticated_at DATETIME NULL COMMENT '이메일 인증 시각', + status VARCHAR(10) NOT NULL COMMENT '상태', created_at DATETIME NOT NULL COMMENT '생성 시간', updated_at DATETIME NOT NULL COMMENT '마지막 수정 시간' );