diff --git a/build.gradle.kts b/build.gradle.kts index 173f1c0..ca1726f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -23,14 +23,24 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-data-jpa") implementation("org.springframework.boot:spring-boot-starter-security") implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.boot:spring-boot-starter-validation") implementation("com.fasterxml.jackson.module:jackson-module-kotlin") implementation("org.flywaydb:flyway-core") implementation("org.flywaydb:flyway-mysql") implementation("org.jetbrains.kotlin:kotlin-reflect") implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.5.0") + implementation("com.github.f4b6a3:ulid-creator:5.2.3") runtimeOnly("com.mysql:mysql-connector-j") testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.springframework.security:spring-security-test") + + val koTestVersion = "5.8.1" + testImplementation("io.kotest:kotest-runner-junit5-jvm:$koTestVersion") + testImplementation("io.kotest:kotest-assertions-core:$koTestVersion") + testImplementation("io.kotest:kotest-property:$koTestVersion") + + testImplementation("io.mockk:mockk:1.13.10") + } tasks.withType { diff --git a/src/main/kotlin/com/depromeet/makers/DepromeetMakersBeApplication.kt b/src/main/kotlin/com/depromeet/makers/DepromeetMakersBeApplication.kt index 917d7cf..f9049f3 100644 --- a/src/main/kotlin/com/depromeet/makers/DepromeetMakersBeApplication.kt +++ b/src/main/kotlin/com/depromeet/makers/DepromeetMakersBeApplication.kt @@ -8,13 +8,13 @@ import org.springframework.context.annotation.FilterType @SpringBootApplication @ComponentScan( - includeFilters = [ComponentScan.Filter( - type = FilterType.ASSIGNABLE_TYPE, - classes = [UseCase::class] - )] + includeFilters = [ComponentScan.Filter( + type = FilterType.ASSIGNABLE_TYPE, + classes = [UseCase::class] + )] ) class DepromeetMakersBeApplication fun main(args: Array) { - runApplication(*args) + runApplication(*args) } diff --git a/src/main/kotlin/com/depromeet/makers/domain/exception/DefaultException.kt b/src/main/kotlin/com/depromeet/makers/domain/exception/DefaultException.kt new file mode 100644 index 0000000..7a64f77 --- /dev/null +++ b/src/main/kotlin/com/depromeet/makers/domain/exception/DefaultException.kt @@ -0,0 +1,9 @@ +package com.depromeet.makers.domain.exception + +open class DefaultException( + errorCode: ErrorCode, +) : DomainException(errorCode) + +class UnknownErrorException : DefaultException(ErrorCode.UNKNOWN_SERVER_ERROR) +class UnauthorizedException : DefaultException(ErrorCode.UNAUTHORIZED) +class InvalidInputException : DefaultException(ErrorCode.INVALID_INPUT) diff --git a/src/main/kotlin/com/depromeet/makers/domain/exception/DomainException.kt b/src/main/kotlin/com/depromeet/makers/domain/exception/DomainException.kt new file mode 100644 index 0000000..0b49615 --- /dev/null +++ b/src/main/kotlin/com/depromeet/makers/domain/exception/DomainException.kt @@ -0,0 +1,6 @@ +package com.depromeet.makers.domain.exception + +open class DomainException( + val errorCode: ErrorCode, + val data: Any? = null, +) : RuntimeException(errorCode.message) diff --git a/src/main/kotlin/com/depromeet/makers/domain/exception/ErrorCode.kt b/src/main/kotlin/com/depromeet/makers/domain/exception/ErrorCode.kt new file mode 100644 index 0000000..2b6425c --- /dev/null +++ b/src/main/kotlin/com/depromeet/makers/domain/exception/ErrorCode.kt @@ -0,0 +1,21 @@ +package com.depromeet.makers.domain.exception + +enum class ErrorCode( + val code: String, + val message: String, +) { + /** + * 기본 오류 + * @see com.depromeet.makers.domain.exception.DefaultException + */ + UNKNOWN_SERVER_ERROR("DE0001", "알 수 없는 오류가 발생했습니다"), + UNAUTHORIZED("DE0002", "인가되지 않은 접근입니다"), + INVALID_INPUT("DE0003", "입력값(바디 혹은 파라미터)가 누락되었습니다"), + + /** + * 사용자 관련 오류 + * @see com.depromeet.makers.domain.exception.MemberException + */ + MEMBER_ALREADY_EXISTS("ME0001", "해당 이메일의 사용자가 이미 존재합니다"), + PASSCORD_ALREADY_SET("ME0002", "이미 비밀번호가 지정되었습니다. 관리자에게 문의하세요") +} diff --git a/src/main/kotlin/com/depromeet/makers/domain/exception/MemberException.kt b/src/main/kotlin/com/depromeet/makers/domain/exception/MemberException.kt new file mode 100644 index 0000000..3886e9d --- /dev/null +++ b/src/main/kotlin/com/depromeet/makers/domain/exception/MemberException.kt @@ -0,0 +1,8 @@ +package com.depromeet.makers.domain.exception + +open class MemberException( + errorCode: ErrorCode, +) : DomainException(errorCode) + +class MemberAlreadyExistsException : MemberException(ErrorCode.MEMBER_ALREADY_EXISTS) +class PassCordAlreadySetException : MemberException(ErrorCode.PASSCORD_ALREADY_SET) diff --git a/src/main/kotlin/com/depromeet/makers/domain/gateway/MemberGateway.kt b/src/main/kotlin/com/depromeet/makers/domain/gateway/MemberGateway.kt index e37f23d..4a82e4b 100644 --- a/src/main/kotlin/com/depromeet/makers/domain/gateway/MemberGateway.kt +++ b/src/main/kotlin/com/depromeet/makers/domain/gateway/MemberGateway.kt @@ -3,5 +3,7 @@ package com.depromeet.makers.domain.gateway import com.depromeet.makers.domain.model.Member interface MemberGateway { + fun findByEmail(email: String): Member? + fun getById(memberId: String): Member fun save(member: Member): Member } diff --git a/src/main/kotlin/com/depromeet/makers/domain/model/Member.kt b/src/main/kotlin/com/depromeet/makers/domain/model/Member.kt index c9b217d..d561318 100644 --- a/src/main/kotlin/com/depromeet/makers/domain/model/Member.kt +++ b/src/main/kotlin/com/depromeet/makers/domain/model/Member.kt @@ -1,6 +1,31 @@ package com.depromeet.makers.domain.model +import com.depromeet.makers.util.generateULID + data class Member( val memberId: String, val name: String, -) + val email: String, + val passCord: String?, + val generations: Set, +) { + fun hasPassCord() = passCord != null + + fun initializePassCord(passCord: String) = this.copy( + passCord = passCord + ) + + companion object { + fun newMember( + name: String, + email: String, + initialGeneration: MemberGeneration, + ) = Member( + memberId = generateULID(), + name = name, + email = email, + passCord = null, + generations = setOf(initialGeneration) + ) + } +} diff --git a/src/main/kotlin/com/depromeet/makers/domain/model/MemberGeneration.kt b/src/main/kotlin/com/depromeet/makers/domain/model/MemberGeneration.kt new file mode 100644 index 0000000..bc8fc72 --- /dev/null +++ b/src/main/kotlin/com/depromeet/makers/domain/model/MemberGeneration.kt @@ -0,0 +1,11 @@ +package com.depromeet.makers.domain.model + +data class MemberGeneration( + val generationId: Int, + val groupId: Int?, + val role: MemberRole, + val position: MemberPosition, +) { + fun hasGroup() = groupId != null +} + diff --git a/src/main/kotlin/com/depromeet/makers/domain/model/MemberPosition.kt b/src/main/kotlin/com/depromeet/makers/domain/model/MemberPosition.kt new file mode 100644 index 0000000..3ea56e5 --- /dev/null +++ b/src/main/kotlin/com/depromeet/makers/domain/model/MemberPosition.kt @@ -0,0 +1,5 @@ +package com.depromeet.makers.domain.model + +enum class MemberPosition { + BACKEND, IOS, AOS, WEB, DESIGN, UNKNOWN +} diff --git a/src/main/kotlin/com/depromeet/makers/domain/model/MemberRole.kt b/src/main/kotlin/com/depromeet/makers/domain/model/MemberRole.kt new file mode 100644 index 0000000..7d0693c --- /dev/null +++ b/src/main/kotlin/com/depromeet/makers/domain/model/MemberRole.kt @@ -0,0 +1,5 @@ +package com.depromeet.makers.domain.model + +enum class MemberRole { + ORGANIZER, MEMBER +} diff --git a/src/main/kotlin/com/depromeet/makers/domain/usecase/CreateNewMember.kt b/src/main/kotlin/com/depromeet/makers/domain/usecase/CreateNewMember.kt index c089bde..27787de 100644 --- a/src/main/kotlin/com/depromeet/makers/domain/usecase/CreateNewMember.kt +++ b/src/main/kotlin/com/depromeet/makers/domain/usecase/CreateNewMember.kt @@ -1,20 +1,51 @@ package com.depromeet.makers.domain.usecase +import com.depromeet.makers.domain.exception.MemberAlreadyExistsException import com.depromeet.makers.domain.gateway.MemberGateway import com.depromeet.makers.domain.model.Member +import com.depromeet.makers.domain.model.MemberGeneration +import com.depromeet.makers.domain.model.MemberPosition +import com.depromeet.makers.domain.model.MemberRole class CreateNewMember( private val memberGateway: MemberGateway, -): UseCase { +) : UseCase { data class CreateNewMemberInput( - val name: String + val name: String, + val email: String, + val generationId: Int, + val groupId: Int?, + val role: MemberRole, + val position: MemberPosition, ) override fun execute(input: CreateNewMemberInput): Member { - val member = Member( - memberId = "", - name = input.name, + val previousMember = memberGateway.findByEmail(input.email) + val newGeneration = MemberGeneration( + generationId = input.generationId, + role = input.role, + position = input.position, + groupId = input.groupId, ) - return memberGateway.save(member) + + val newMember = if (previousMember != null) { + // 이미 있으면 기존 멤버에 기수 데이터 추가 + if (previousMember.generations.any { it.generationId == newGeneration.generationId }) { + throw MemberAlreadyExistsException() + } + + val newGenerations = previousMember.generations + newGeneration + previousMember.copy( + generations = newGenerations + ) + } else { + // 없으면 새 멤버 + Member.newMember( + name = input.name, + email = input.email, + initialGeneration = newGeneration, + ) + } + return memberGateway.save(newMember) } } diff --git a/src/main/kotlin/com/depromeet/makers/domain/usecase/UpdateDefaultMemberPassCord.kt b/src/main/kotlin/com/depromeet/makers/domain/usecase/UpdateDefaultMemberPassCord.kt new file mode 100644 index 0000000..a28f916 --- /dev/null +++ b/src/main/kotlin/com/depromeet/makers/domain/usecase/UpdateDefaultMemberPassCord.kt @@ -0,0 +1,29 @@ +package com.depromeet.makers.domain.usecase + +import com.depromeet.makers.domain.exception.PassCordAlreadySetException +import com.depromeet.makers.domain.gateway.MemberGateway +import com.depromeet.makers.domain.model.Member + +class UpdateDefaultMemberPassCord( + private val memberGateway: MemberGateway, + private val updateMemberPassCord: UpdateMemberPassCord, +) : UseCase { + data class UpdateDefaultMemberPassCordInput( + val memberId: String, + val passCord: String, + ) + + override fun execute(input: UpdateDefaultMemberPassCordInput): Member { + val member = memberGateway.getById(input.memberId) + if (isPassCordInitialized(member)) throw PassCordAlreadySetException() + + return updateMemberPassCord.execute( + UpdateMemberPassCord.UpdateMemberPassCordInput( + memberId = input.memberId, + passCord = input.passCord, + ) + ) + } + + private fun isPassCordInitialized(member: Member) = member.hasPassCord() +} diff --git a/src/main/kotlin/com/depromeet/makers/domain/usecase/UpdateMemberPassCord.kt b/src/main/kotlin/com/depromeet/makers/domain/usecase/UpdateMemberPassCord.kt new file mode 100644 index 0000000..f49cada --- /dev/null +++ b/src/main/kotlin/com/depromeet/makers/domain/usecase/UpdateMemberPassCord.kt @@ -0,0 +1,20 @@ +package com.depromeet.makers.domain.usecase + +import com.depromeet.makers.domain.gateway.MemberGateway +import com.depromeet.makers.domain.model.Member + +class UpdateMemberPassCord( + private val memberGateway: MemberGateway, +) : UseCase { + data class UpdateMemberPassCordInput( + val memberId: String, + val passCord: String, + ) + + override fun execute(input: UpdateMemberPassCordInput): Member { + val member = memberGateway.getById(input.memberId) + return memberGateway.save( + member.initializePassCord(passCord = input.passCord) + ) + } +} diff --git a/src/main/kotlin/com/depromeet/makers/infrastructure/db/entity/MemberEntity.kt b/src/main/kotlin/com/depromeet/makers/infrastructure/db/entity/MemberEntity.kt index a0c4487..662f769 100644 --- a/src/main/kotlin/com/depromeet/makers/infrastructure/db/entity/MemberEntity.kt +++ b/src/main/kotlin/com/depromeet/makers/infrastructure/db/entity/MemberEntity.kt @@ -1,29 +1,61 @@ package com.depromeet.makers.infrastructure.db.entity import com.depromeet.makers.domain.model.Member -import jakarta.persistence.Column -import jakarta.persistence.Entity -import jakarta.persistence.Id +import jakarta.persistence.* @Entity(name = "member") -data class MemberEntity( +class MemberEntity private constructor( @Id - @Column(name = "id") + @Column(name = "id", length = 26, columnDefinition = "CHAR(26)", nullable = false) val id: String, - @Column(name = "name") + @Column(name = "name", nullable = false) val name: String, + + @Column(name = "email", nullable = false, unique = true) + val email: String, + + @Column(name = "passcord", nullable = true) + var passCord: String?, + + @OneToMany(fetch = FetchType.EAGER, cascade = [CascadeType.ALL]) + @JoinColumn(name = "member_id") + var generations: Set, ) { + fun toDomain() = Member( memberId = id, name = name, + email = email, + passCord = passCord, + generations = generations + .map(MemberGenerationEntity::toDomain) + .toSet() ) + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as MemberEntity + + return id == other.id + } + + override fun hashCode(): Int { + return id.hashCode() + } + companion object { fun fromDomain(member: Member) = with(member) { MemberEntity( id = memberId, name = name, + email = email, + passCord = passCord, + generations = generations + .map { MemberGenerationEntity.fromDomain(memberId, it) } + .toSet() ) } } diff --git a/src/main/kotlin/com/depromeet/makers/infrastructure/db/entity/MemberGenerationEntity.kt b/src/main/kotlin/com/depromeet/makers/infrastructure/db/entity/MemberGenerationEntity.kt new file mode 100644 index 0000000..50f8966 --- /dev/null +++ b/src/main/kotlin/com/depromeet/makers/infrastructure/db/entity/MemberGenerationEntity.kt @@ -0,0 +1,72 @@ +package com.depromeet.makers.infrastructure.db.entity + +import com.depromeet.makers.domain.model.MemberGeneration +import com.depromeet.makers.domain.model.MemberPosition +import com.depromeet.makers.domain.model.MemberRole +import jakarta.persistence.* +import java.io.Serializable + +@IdClass(MemberGenerationEntityKey::class) +@Entity +class MemberGenerationEntity private constructor( + @Id + @Column(name = "member_id", length = 26, columnDefinition = "CHAR(26)", nullable = false) + val memberId: String, + + @Id + @Column(name = "generation_id", nullable = false) + val generationId: Int, + + @Column(name = "group_id", nullable = true) + val groupId: Int?, + + @Enumerated(EnumType.STRING) + @Column(name = "role", nullable = false) + val role: MemberRole, + + @Enumerated(EnumType.STRING) + @Column(name = "position", nullable = false) + val position: MemberPosition, +) { + fun toDomain() = MemberGeneration( + generationId = generationId, + role = role, + position = position, + groupId = groupId, + ) + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as MemberGenerationEntity + + if (memberId != other.memberId) return false + if (generationId != other.generationId) return false + + return true + } + + override fun hashCode(): Int { + var result = memberId.hashCode() + result = 31 * result + generationId + return result + } + + companion object { + fun fromDomain(memberId: String, memberGeneration: MemberGeneration) = with(memberGeneration) { + MemberGenerationEntity( + memberId = memberId, + generationId = generationId, + role = role, + position = position, + groupId = groupId, + ) + } + } +} + +class MemberGenerationEntityKey( + val memberId: String, + val generationId: Int, +) : Serializable diff --git a/src/main/kotlin/com/depromeet/makers/infrastructure/db/repository/JpaMemberRepository.kt b/src/main/kotlin/com/depromeet/makers/infrastructure/db/repository/JpaMemberRepository.kt index 0ffb656..ab35e5d 100644 --- a/src/main/kotlin/com/depromeet/makers/infrastructure/db/repository/JpaMemberRepository.kt +++ b/src/main/kotlin/com/depromeet/makers/infrastructure/db/repository/JpaMemberRepository.kt @@ -3,5 +3,6 @@ package com.depromeet.makers.infrastructure.db.repository import com.depromeet.makers.infrastructure.db.entity.MemberEntity import org.springframework.data.jpa.repository.JpaRepository -interface JpaMemberRepository: JpaRepository { +interface JpaMemberRepository : JpaRepository { + fun findByEmail(email: String): MemberEntity? } diff --git a/src/main/kotlin/com/depromeet/makers/infrastructure/gateway/MemberGatewayImpl.kt b/src/main/kotlin/com/depromeet/makers/infrastructure/gateway/MemberGatewayImpl.kt index 18abc86..e69776b 100644 --- a/src/main/kotlin/com/depromeet/makers/infrastructure/gateway/MemberGatewayImpl.kt +++ b/src/main/kotlin/com/depromeet/makers/infrastructure/gateway/MemberGatewayImpl.kt @@ -10,7 +10,18 @@ import org.springframework.stereotype.Component @Component class MemberGatewayImpl( private val jpaMemberRepository: JpaMemberRepository, -): MemberGateway { +) : MemberGateway { + override fun findByEmail(email: String): Member? { + return jpaMemberRepository + .findByEmail(email) + ?.let(MemberEntity::toDomain) + } + + override fun getById(memberId: String): Member { + return jpaMemberRepository + .getReferenceById(memberId) + .let(MemberEntity::toDomain) + } override fun save(member: Member): Member { val memberEntity = MemberEntity.fromDomain(member) diff --git a/src/main/kotlin/com/depromeet/makers/presentation/restapi/config/SwaggerConfig.kt b/src/main/kotlin/com/depromeet/makers/presentation/restapi/config/SwaggerConfig.kt new file mode 100644 index 0000000..e681123 --- /dev/null +++ b/src/main/kotlin/com/depromeet/makers/presentation/restapi/config/SwaggerConfig.kt @@ -0,0 +1,18 @@ +package com.depromeet.makers.presentation.restapi.config + +import io.swagger.v3.oas.models.Components +import io.swagger.v3.oas.models.OpenAPI +import io.swagger.v3.oas.models.servers.Server +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class SwaggerConfig { + @Bean + fun openAPI(): OpenAPI = OpenAPI() + .components(Components()) + .servers(listOf(Server().apply { + url = "https://depromeet.com" + description = "디프만 API서버" + })) +} diff --git a/src/main/kotlin/com/depromeet/makers/presentation/restapi/config/WebConfig.kt b/src/main/kotlin/com/depromeet/makers/presentation/restapi/config/WebConfig.kt new file mode 100644 index 0000000..c0bcc71 --- /dev/null +++ b/src/main/kotlin/com/depromeet/makers/presentation/restapi/config/WebConfig.kt @@ -0,0 +1,15 @@ +package com.depromeet.makers.presentation.restapi.config + +import com.depromeet.makers.presentation.restapi.config.interceptor.WebRequestLogger +import org.springframework.context.annotation.Configuration +import org.springframework.web.servlet.config.annotation.InterceptorRegistry +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer + +@Configuration +class WebConfig( + private val webRequestLogger: WebRequestLogger, +) : WebMvcConfigurer { + override fun addInterceptors(registry: InterceptorRegistry) { + registry.addInterceptor(webRequestLogger) + } +} diff --git a/src/main/kotlin/com/depromeet/makers/presentation/restapi/config/WebExceptionHandler.kt b/src/main/kotlin/com/depromeet/makers/presentation/restapi/config/WebExceptionHandler.kt new file mode 100644 index 0000000..707a4a1 --- /dev/null +++ b/src/main/kotlin/com/depromeet/makers/presentation/restapi/config/WebExceptionHandler.kt @@ -0,0 +1,74 @@ +package com.depromeet.makers.presentation.restapi.config + +import com.depromeet.makers.domain.exception.DomainException +import com.depromeet.makers.domain.exception.ErrorCode +import com.depromeet.makers.presentation.restapi.dto.response.ErrorResponse +import com.depromeet.makers.util.logger +import jakarta.servlet.http.HttpServletRequest +import jakarta.validation.ConstraintViolationException +import org.springframework.http.ResponseEntity +import org.springframework.http.converter.HttpMessageNotReadableException +import org.springframework.web.bind.MethodArgumentNotValidException +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.RestControllerAdvice +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException +import org.springframework.web.util.BindErrorUtils + +@RestControllerAdvice +class WebExceptionHandler { + val logger = logger() + + @ExceptionHandler(value = [DomainException::class]) + fun handleDomainException( + exception: DomainException, + request: HttpServletRequest, + ): ResponseEntity { + return ResponseEntity + .badRequest() + .body(exception.toErrorResponse()) + } + + @ExceptionHandler( + value = [ + MethodArgumentNotValidException::class, + MethodArgumentTypeMismatchException::class, + HttpMessageNotReadableException::class, + ConstraintViolationException::class, + ] + ) + fun handleInvalidInput( + exception: Exception, + ): ResponseEntity { + val data: Any? = when (exception) { + is MethodArgumentNotValidException -> BindErrorUtils.resolve(exception.allErrors).values + is MethodArgumentTypeMismatchException -> exception.localizedMessage + is HttpMessageNotReadableException -> exception.localizedMessage + else -> null + } + return ResponseEntity + .badRequest() + .body( + ErrorResponse.fromErrorCode( + errorCode = ErrorCode.INVALID_INPUT, + data = data, + ) + ) + } + + @ExceptionHandler(value = [Throwable::class]) + fun handleUnhandledException( + exception: Throwable, + request: HttpServletRequest, + ): ResponseEntity { + logger.error("[UnhandledException] " + exception.stackTraceToString()) + return ResponseEntity + .badRequest() + .body(ErrorResponse.fromErrorCode(ErrorCode.UNKNOWN_SERVER_ERROR)) + } + + private fun DomainException.toErrorResponse() = ErrorResponse.fromErrorCode( + errorCode = errorCode, + data = data, + ) +} + diff --git a/src/main/kotlin/com/depromeet/makers/presentation/restapi/config/WebSecurityConfig.kt b/src/main/kotlin/com/depromeet/makers/presentation/restapi/config/WebSecurityConfig.kt new file mode 100644 index 0000000..3124f11 --- /dev/null +++ b/src/main/kotlin/com/depromeet/makers/presentation/restapi/config/WebSecurityConfig.kt @@ -0,0 +1,19 @@ +package com.depromeet.makers.presentation.restapi.config + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.web.SecurityFilterChain + +@Configuration +class WebSecurityConfig { + @Bean + fun securityFilterChain(httpSecurity: HttpSecurity): SecurityFilterChain = httpSecurity + .csrf { it.disable() } + .authorizeHttpRequests { + it + .requestMatchers("/error").permitAll() + .anyRequest().permitAll() + } + .build() +} diff --git a/src/main/kotlin/com/depromeet/makers/presentation/restapi/config/interceptor/WebRequestLogger.kt b/src/main/kotlin/com/depromeet/makers/presentation/restapi/config/interceptor/WebRequestLogger.kt new file mode 100644 index 0000000..4417309 --- /dev/null +++ b/src/main/kotlin/com/depromeet/makers/presentation/restapi/config/interceptor/WebRequestLogger.kt @@ -0,0 +1,59 @@ +package com.depromeet.makers.presentation.restapi.config.interceptor + +import com.depromeet.makers.util.logger +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.stereotype.Component +import org.springframework.web.servlet.HandlerInterceptor + +@Component +class WebRequestLogger : HandlerInterceptor { + val startTimeAttr = "startTime" + val logger = logger() + + override fun preHandle(request: HttpServletRequest, response: HttpServletResponse, handler: Any): Boolean { + val startTime = System.currentTimeMillis() + request.setAttribute(startTimeAttr, startTime) + return true + } + + override fun afterCompletion( + request: HttpServletRequest, + response: HttpServletResponse, + handler: Any, + ex: Exception?, + ) { + val startTime = request.getAttribute(startTimeAttr) as Long + val endTime = System.currentTimeMillis() + val executionTime = endTime - startTime + + val userAgent = request.getHeader("User-Agent") + val originIp = request.getHeader("X-FORWARDED-FOR") + ?: request.getHeader("CF-Connecting-IP") + ?: request.remoteAddr + + if (ex == null) { + logger.info( + "{} {} {} {} {}ms {}", + request.method, + request.requestURI, + originIp, + response.status, + executionTime, + userAgent, + ) + } else { + logger.info( + "{} {} {} {}[{}] {}ms {}", + request.method, + request.requestURI, + originIp, + response.status, + ex::class.java.name, + executionTime, + userAgent, + ) + } + + } +} diff --git a/src/main/kotlin/com/depromeet/makers/presentation/restapi/controller/CreateMemberController.kt b/src/main/kotlin/com/depromeet/makers/presentation/restapi/controller/CreateMemberController.kt index 9ef5111..5dc8293 100644 --- a/src/main/kotlin/com/depromeet/makers/presentation/restapi/controller/CreateMemberController.kt +++ b/src/main/kotlin/com/depromeet/makers/presentation/restapi/controller/CreateMemberController.kt @@ -3,23 +3,33 @@ package com.depromeet.makers.presentation.restapi.controller import com.depromeet.makers.domain.usecase.CreateNewMember import com.depromeet.makers.presentation.restapi.dto.request.CreateNewMemberRequest import com.depromeet.makers.presentation.restapi.dto.response.CreateNewMemberResponse +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.Valid import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController +@Tag(name = "사용자 관련 API", description = "사용자의 정보를 관리하는 API") @RestController @RequestMapping("/v1/members") class CreateMemberController( private val createNewMember: CreateNewMember, ) { + @Operation(summary = "새로운 사용자 생성", description = "새로운 사용자를 생성합니다.") @PostMapping fun createNewMember( - @RequestBody request: CreateNewMemberRequest, + @RequestBody @Valid request: CreateNewMemberRequest, ): CreateNewMemberResponse { val member = createNewMember.execute( CreateNewMember.CreateNewMemberInput( - name = request.name + name = request.name, + email = request.email, + generationId = request.generationId, + role = request.role, + position = request.position, + groupId = request.groupId, ) ) return CreateNewMemberResponse.fromDomain(member) diff --git a/src/main/kotlin/com/depromeet/makers/presentation/restapi/dto/request/CreateNewMemberRequest.kt b/src/main/kotlin/com/depromeet/makers/presentation/restapi/dto/request/CreateNewMemberRequest.kt index d7f839a..165da6a 100644 --- a/src/main/kotlin/com/depromeet/makers/presentation/restapi/dto/request/CreateNewMemberRequest.kt +++ b/src/main/kotlin/com/depromeet/makers/presentation/restapi/dto/request/CreateNewMemberRequest.kt @@ -1,8 +1,35 @@ package com.depromeet.makers.presentation.restapi.dto.request +import com.depromeet.makers.domain.model.MemberPosition +import com.depromeet.makers.domain.model.MemberRole import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.* -@Schema +@Schema(description = "새로운 사용자 생성 요청") data class CreateNewMemberRequest( + @field:NotBlank + @field:Size(max = 128) + @Schema(description = "사용자 이름", example = "송영민") val name: String, + + @field:NotBlank + @field:Email + @Schema(description = "이메일", example = "yeongmin1061@gmail.com") + val email: String, + + @field:Min(1) @field:Max(100) + @Schema(description = "기수", example = "15") + val generationId: Int, + + @field:Min(1) @field:Max(100) + @Schema(description = "조 번호", example = "1") + val groupId: Int?, + + @NotNull + @Schema(description = "역할", example = "ORGANIZER, MEMBER") + val role: MemberRole, + + @NotNull + @Schema(description = "포지션", example = "BACKEND, IOS, AOS, WEB, DESIGN, UNKNOWN") + val position: MemberPosition, ) diff --git a/src/main/kotlin/com/depromeet/makers/presentation/restapi/dto/response/CreateNewMemberResponse.kt b/src/main/kotlin/com/depromeet/makers/presentation/restapi/dto/response/CreateNewMemberResponse.kt index a08a7e1..85a5c6c 100644 --- a/src/main/kotlin/com/depromeet/makers/presentation/restapi/dto/response/CreateNewMemberResponse.kt +++ b/src/main/kotlin/com/depromeet/makers/presentation/restapi/dto/response/CreateNewMemberResponse.kt @@ -1,18 +1,49 @@ package com.depromeet.makers.presentation.restapi.dto.response import com.depromeet.makers.domain.model.Member +import com.depromeet.makers.domain.model.MemberPosition +import com.depromeet.makers.domain.model.MemberRole import io.swagger.v3.oas.annotations.media.Schema -@Schema +@Schema(description = "사용자 생성 결과 DTO") data class CreateNewMemberResponse( + @Schema(description = "사용자 ID", example = "01HWPNRE5TS9S7VC99WPETE5KE") val id: String, + + @Schema(description = "사용자 이름", example = "송영민") val name: String, + + @Schema(description = "사용자 이메일", example = "yeongmin1061@gmail.com") + val email: String, + + @Schema(description = "기수 정보") + val generations: List, ) { + @Schema(description = "사용자 기수 정보") + data class CreateNewMemberGenerationResponse( + @Schema(description = "기수", example = "15") + val generationId: Int, + + @Schema(description = "역할", example = "ORGANIZER") + val role: MemberRole, + + @Schema(description = "포지션", example = "BACKEND") + val position: MemberPosition, + ) + companion object { fun fromDomain(member: Member) = with(member) { CreateNewMemberResponse( id = memberId, name = name, + email = email, + generations = member.generations.map { + CreateNewMemberGenerationResponse( + generationId = it.generationId, + role = it.role, + position = it.position + ) + } ) } } diff --git a/src/main/kotlin/com/depromeet/makers/presentation/restapi/dto/response/ErrorResponse.kt b/src/main/kotlin/com/depromeet/makers/presentation/restapi/dto/response/ErrorResponse.kt new file mode 100644 index 0000000..e56f208 --- /dev/null +++ b/src/main/kotlin/com/depromeet/makers/presentation/restapi/dto/response/ErrorResponse.kt @@ -0,0 +1,29 @@ +package com.depromeet.makers.presentation.restapi.dto.response + +import com.depromeet.makers.domain.exception.ErrorCode +import io.swagger.v3.oas.annotations.media.Schema + +@Schema(description = "에러(오류) 응답") +data class ErrorResponse( + @Schema(description = "에러 코드", example = "DE0001") + val code: String, + + @Schema(description = "에러 메세지", example = "인가되지 않은 접근입니다.") + val message: String, + + @Schema(description = "관련 데이터") + val data: Any? = null, +) { + companion object { + fun fromErrorCode( + errorCode: ErrorCode, + data: Any? = null, + ) = with(errorCode) { + ErrorResponse( + code = code, + message = message, + data = data, + ) + } + } +} diff --git a/src/main/kotlin/com/depromeet/makers/util/LoggingUtils.kt b/src/main/kotlin/com/depromeet/makers/util/LoggingUtils.kt new file mode 100644 index 0000000..8531af9 --- /dev/null +++ b/src/main/kotlin/com/depromeet/makers/util/LoggingUtils.kt @@ -0,0 +1,5 @@ +package com.depromeet.makers.util + +import org.slf4j.LoggerFactory + +inline fun T.logger() = LoggerFactory.getLogger(T::class.java)!! diff --git a/src/main/kotlin/com/depromeet/makers/util/UlidUtils.kt b/src/main/kotlin/com/depromeet/makers/util/UlidUtils.kt new file mode 100644 index 0000000..9f94af0 --- /dev/null +++ b/src/main/kotlin/com/depromeet/makers/util/UlidUtils.kt @@ -0,0 +1,5 @@ +package com.depromeet.makers.util + +import com.github.f4b6a3.ulid.UlidCreator + +fun generateULID() = UlidCreator.getMonotonicUlid().toString() diff --git a/src/test/kotlin/com/depromeet/makers/DepromeetMakersBeApplicationTests.kt b/src/test/kotlin/com/depromeet/makers/DepromeetMakersBeApplicationTests.kt index c619a12..d660c1a 100644 --- a/src/test/kotlin/com/depromeet/makers/DepromeetMakersBeApplicationTests.kt +++ b/src/test/kotlin/com/depromeet/makers/DepromeetMakersBeApplicationTests.kt @@ -3,11 +3,11 @@ package com.depromeet.makers import org.junit.jupiter.api.Test import org.springframework.boot.test.context.SpringBootTest -@SpringBootTest -class DepromeetMakersBeApplicationTests { - - @Test - fun contextLoads() { - } - -} +//@SpringBootTest +//class DepromeetMakersBeApplicationTests { +// +// @Test +// fun contextLoads() { +// } +// +//} diff --git a/src/test/kotlin/com/depromeet/makers/domain/usecase/CreateNewMemberTest.kt b/src/test/kotlin/com/depromeet/makers/domain/usecase/CreateNewMemberTest.kt new file mode 100644 index 0000000..defddfa --- /dev/null +++ b/src/test/kotlin/com/depromeet/makers/domain/usecase/CreateNewMemberTest.kt @@ -0,0 +1,148 @@ +package com.depromeet.makers.domain.usecase + +import com.depromeet.makers.domain.exception.MemberAlreadyExistsException +import com.depromeet.makers.domain.gateway.MemberGateway +import com.depromeet.makers.domain.model.Member +import com.depromeet.makers.domain.model.MemberGeneration +import com.depromeet.makers.domain.model.MemberPosition +import com.depromeet.makers.domain.model.MemberRole +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.BehaviorSpec +import io.kotest.matchers.equals.shouldBeEqual +import io.kotest.matchers.ints.shouldBeExactly +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk + + +class CreateNewMemberTest: BehaviorSpec({ + Given("이미 존재하는 이메일의 사용자이지만 새로운 기수일 때") { + val memberGateway = mockk() + val createNewMember = CreateNewMember(memberGateway) + + val mockName = "송영민" + val mockMemberId = "1" + val mockEmail = "yeongmin1061@gmail.com" + val mockGenerationId = 14 + val mockPreviousGenerationId = 12 + val mockPreviousGenerations = setOf(MemberGeneration( + generationId = mockPreviousGenerationId, + role = MemberRole.MEMBER, + position = MemberPosition.BACKEND, + groupId = null, + )) + + every { memberGateway.findByEmail(any()) } answers { + val email = firstArg() + Member( + memberId = mockMemberId, + name = mockName, + email = email, + passCord = null, + generations = mockPreviousGenerations + ) + } + every { memberGateway.save(any()) } returnsArgument 0 + + When("execute가 실행하면") { + val result = createNewMember.execute( + CreateNewMember.CreateNewMemberInput( + name = mockName, + email = mockEmail, + generationId = mockGenerationId, + role = MemberRole.MEMBER, + position = MemberPosition.BACKEND, + groupId = null, + ) + ) + Then("기수는 총 두개 (기존 1 + 신규 1)") { + result.generations.size shouldBeExactly mockPreviousGenerations.size + 1 + } + Then("새로운 기수가 존재") { + result.generations.any { it.generationId == mockGenerationId } shouldBe true + } + Then("기존 기수도 존재") { + result.generations.any { it.generationId == mockPreviousGenerationId } shouldBe true + } + } + } + + Given("이미 존재하는 이메일의 사용자이고 이미 등록된 기수일 때") { + val memberGateway = mockk() + val createNewMember = CreateNewMember(memberGateway) + + val mockName = "송영민" + val mockMemberId = "1" + val mockEmail = "yeongmin1061@gmail.com" + val mockGenerationId = 14 + val mockPreviousGenerationId = 14 + val mockPreviousGenerations = setOf(MemberGeneration( + generationId = mockPreviousGenerationId, + role = MemberRole.MEMBER, + position = MemberPosition.BACKEND, + groupId = null, + )) + + every { memberGateway.findByEmail(any()) } answers { + val email = firstArg() + Member( + memberId = mockMemberId, + name = mockName, + email = email, + passCord = null, + generations = mockPreviousGenerations + ) + } + every { memberGateway.save(any()) } returnsArgument 0 + + When("execute가 실행하면") { + val executor = { + createNewMember.execute( + CreateNewMember.CreateNewMemberInput( + name = mockName, + email = mockEmail, + generationId = mockGenerationId, + role = MemberRole.MEMBER, + position = MemberPosition.BACKEND, + groupId = null, + ) + ) + } + Then("MemberAlreadyExistsException를 던짐") { + shouldThrow(executor) + } + } + } + + Given("처음 등록하는 사용자일 때") { + val memberGateway = mockk() + val createNewMember = CreateNewMember(memberGateway) + + val mockName = "송영민" + val mockEmail = "yeongmin1061@gmail.com" + val mockGenerationId = 14 + + every { memberGateway.findByEmail(any()) } returns null + every { memberGateway.save(any()) } returnsArgument 0 + + When("execute가 실행하면") { + val result = createNewMember.execute( + CreateNewMember.CreateNewMemberInput( + name = mockName, + email = mockEmail, + generationId = mockGenerationId, + role = MemberRole.MEMBER, + position = MemberPosition.BACKEND, + groupId = null, + ) + ) + Then("입력한 정보가 전부 일치하고") { + result.name shouldBeEqual mockName + result.email shouldBeEqual mockEmail + } + Then("새로운 기수가 존재") { + result.generations.any { it.generationId == mockGenerationId } shouldBe true + } + } + } +}) diff --git a/src/test/kotlin/com/depromeet/makers/domain/usecase/UpdateDefaultMemberPassCordTest.kt b/src/test/kotlin/com/depromeet/makers/domain/usecase/UpdateDefaultMemberPassCordTest.kt new file mode 100644 index 0000000..66cc46a --- /dev/null +++ b/src/test/kotlin/com/depromeet/makers/domain/usecase/UpdateDefaultMemberPassCordTest.kt @@ -0,0 +1,66 @@ +package com.depromeet.makers.domain.usecase + +import com.depromeet.makers.domain.exception.PassCordAlreadySetException +import com.depromeet.makers.domain.gateway.MemberGateway +import com.depromeet.makers.domain.model.Member +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.BehaviorSpec +import io.mockk.every +import io.mockk.mockk + +class UpdateDefaultMemberPassCordTest: BehaviorSpec({ + Given("이미 비밀번호를 설정한 사용자일 때") { + val memberGateway = mockk() + val updateMemberPassCord = mockk() + val updateDefaultMemberPassCord = UpdateDefaultMemberPassCord(memberGateway, updateMemberPassCord) + val mockMember = Member( + memberId = "", + name = "", + email = "", + passCord = "passpass", + generations = emptySet(), + ) + every { memberGateway.getById(any()) } returns mockMember + every { updateMemberPassCord.execute(any()) } returnsArgument 0 + + When("execute를 실행하면") { + val executor = { + updateDefaultMemberPassCord.execute( + UpdateDefaultMemberPassCord.UpdateDefaultMemberPassCordInput( + memberId = "", + passCord = "newPassword" + ) + ) + } + Then("PassCordAlreadySetException를 던짐") { + shouldThrow(executor) + } + } + } + Given("사용자가 아직 비밀번호를 설정하지 않았을 때") { + val memberGateway = mockk() + val updateMemberPassCord = mockk() + val updateDefaultMemberPassCord = UpdateDefaultMemberPassCord(memberGateway, updateMemberPassCord) + val mockMember = Member( + memberId = "", + name = "", + email = "", + passCord = null, + generations = emptySet(), + ) + every { memberGateway.getById(any()) } returns mockMember + every { updateMemberPassCord.execute(any()) } returns mockMember + + When("execute를 실행하면") { + val result = updateDefaultMemberPassCord.execute( + UpdateDefaultMemberPassCord.UpdateDefaultMemberPassCordInput( + memberId = "", + passCord = "newPassword" + ) + ) + Then("비밀번호가 바뀜") { + // 이건 이 단위테스트 영역이 아님. 예외만 안던졌으면 성공임. + } + } + } +}) diff --git a/src/test/kotlin/com/depromeet/makers/domain/usecase/UpdateMemberPassCordTest.kt b/src/test/kotlin/com/depromeet/makers/domain/usecase/UpdateMemberPassCordTest.kt new file mode 100644 index 0000000..bc1bad6 --- /dev/null +++ b/src/test/kotlin/com/depromeet/makers/domain/usecase/UpdateMemberPassCordTest.kt @@ -0,0 +1,39 @@ +package com.depromeet.makers.domain.usecase + +import com.depromeet.makers.domain.gateway.MemberGateway +import com.depromeet.makers.domain.model.Member +import io.kotest.core.spec.style.BehaviorSpec +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk + +class UpdateMemberPassCordTest: BehaviorSpec({ + Given("이미 존재하는 사용자일 때") { + val memberGateway = mockk() + val updateMemberPassCord = UpdateMemberPassCord(memberGateway) + + val testMemberId = "test" + val testPassCord = "very-secret-password" + val mockMember = Member( + memberId = testMemberId, + name = "", + email = "", + passCord = null, + generations = emptySet(), + ) + every { memberGateway.getById(any()) } returns mockMember + every { memberGateway.save(any()) } returnsArgument 0 + + When("execute를 실행하면") { + val result = updateMemberPassCord.execute( + UpdateMemberPassCord.UpdateMemberPassCordInput( + memberId = testMemberId, + passCord = testPassCord, + ) + ) + Then("비밀번호가 변경되었다") { + result.passCord shouldBe testPassCord + } + } + } +})