diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 460baa1..456661a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -7,18 +7,22 @@ redisson = "3.52.0" springdoc = "2.8.4" connector = "8.3.0" mapstruct = "1.6.2" +lombok = "1.18.42" +transactional = "6.1.0" [libraries] +lombok = { module = "org.projectlombok:lombok", version.ref = "lombok" } + jackson-databind = { module = "com.fasterxml.jackson.core:jackson-databind", version.ref = "jackson" } jackson-datatype-jsr310 = { module = "com.fasterxml.jackson.datatype:jackson-datatype-jsr310", version.ref = "jackson" } -jackson-module-kotlin = { module = "com.fasterxml.jackson.module:jackson-module-kotlin", version.ref = "jackson" } jwt-api = { module = "io.jsonwebtoken:jjwt-api", version.ref = "jwt" } jwt-impl = { module = "io.jsonwebtoken:jjwt-impl", version.ref = "jwt" } jwt-jackson = { module = "io.jsonwebtoken:jjwt-jackson", version.ref = "jwt" } mapstruct = { module = "org.mapstruct:mapstruct", version.ref = "mapstruct" } +mapstruct-processor = { module = "org.mapstruct:mapstruct-processor", version.ref = "mapstruct" } org-redisson-starter = {module = "org.redisson:redisson-spring-boot-starter", version.ref = "redisson"} @@ -33,6 +37,7 @@ spring-boot-starter-oauth2-client = { module = "org.springframework.boot:spring- spring-boot-starter-security = { module = "org.springframework.boot:spring-boot-starter-security" } spring-boot-starter-redis = { module = "org.springframework.boot:spring-boot-starter-data-redis" } spring-boot-starter-kafka = { module = "org.springframework.kafka:spring-kafka" } +spring-boot-starter-transactional = { module = "org.springframework:spring-tx", version.ref = "transactional" } spring-boot-spring-doc = { module = "org.springdoc:springdoc-openapi-starter-webmvc-ui", version.ref = "springdoc" } diff --git a/main-modules/bootstrap/build.gradle b/main-modules/bootstrap/build.gradle index 839bcd5..5ad031a 100644 --- a/main-modules/bootstrap/build.gradle +++ b/main-modules/bootstrap/build.gradle @@ -14,11 +14,10 @@ tasks.named('bootJar') { dependencies { implementation project(':main-modules:common') + implementation project(':main-modules:user:infrastructure') implementation libs.spring.boot.starter.jpa implementation libs.spring.boot.starter.security - implementation libs.spring.boot.starter.web - implementation libs.spring.boot.spring.doc implementation libs.bundles.bootstrap testImplementation libs.bundles.test diff --git a/main-modules/bootstrap/src/main/java/com/sidework/config/SecurityConfig.java b/main-modules/bootstrap/src/main/java/com/sidework/config/SecurityConfig.java new file mode 100644 index 0000000..ae97dee --- /dev/null +++ b/main-modules/bootstrap/src/main/java/com/sidework/config/SecurityConfig.java @@ -0,0 +1,31 @@ +package com.sidework.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.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) // CSRF 비활성화 + .authorizeHttpRequests(auth -> auth + .anyRequest().permitAll() // 모든 요청 허용 + ); + + return http.build(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} \ No newline at end of file diff --git a/main-modules/common/build.gradle b/main-modules/common/build.gradle index 0fd5944..6eae6dc 100644 --- a/main-modules/common/build.gradle +++ b/main-modules/common/build.gradle @@ -4,11 +4,11 @@ plugins { id 'io.spring.dependency-management' } tasks.named('jar') { - enabled = false + enabled = true } tasks.named('bootJar') { - enabled = true + enabled = false } dependencies { @@ -16,6 +16,12 @@ dependencies { implementation libs.spring.boot.starter.logging implementation libs.spring.boot.starter.web implementation libs.spring.boot.starter.validation + implementation libs.spring.boot.starter.jpa + + compileOnly libs.lombok + annotationProcessor libs.lombok + + implementation("jakarta.validation:jakarta.validation-api:3.1.1") } repositories { diff --git a/main-modules/common/src/main/java/com/sidework/common/entity/BaseEntity.java b/main-modules/common/src/main/java/com/sidework/common/entity/BaseEntity.java new file mode 100644 index 0000000..98240d9 --- /dev/null +++ b/main-modules/common/src/main/java/com/sidework/common/entity/BaseEntity.java @@ -0,0 +1,24 @@ +package com.sidework.common.entity; + + +import jakarta.persistence.*; +import lombok.Getter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.Instant; + +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +@Getter +public abstract class BaseEntity { + + @CreatedDate + @Column(updatable = false) + private Instant createdAt; + + @LastModifiedDate + private Instant updatedAt; + +} \ No newline at end of file diff --git a/main-modules/common/src/main/java/com/sidework/common/response/ApiResponse.java b/main-modules/common/src/main/java/com/sidework/common/response/ApiResponse.java new file mode 100644 index 0000000..556f576 --- /dev/null +++ b/main-modules/common/src/main/java/com/sidework/common/response/ApiResponse.java @@ -0,0 +1,60 @@ +package com.sidework.common.response; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import com.sidework.common.response.status.SuccessStatus; + + +@JsonPropertyOrder({"code", "message", "result", "isSuccess"}) +@JsonInclude(JsonInclude.Include.NON_NULL) +public record ApiResponse(boolean isSuccess, String code, String message, T result, String path) { + + // 성공 - 데이터 반환 + public static ApiResponse onSuccess(T result) { + return new ApiResponse<>( + true, + SuccessStatus.OK.getCode(), + SuccessStatus.OK.getMessage(), + result, + null + ); + } + + // 성공 - 생성됨 (201) + public static ApiResponse onSuccessCreated() { + return new ApiResponse<>( + true, + SuccessStatus.CREATED.getCode(), + SuccessStatus.CREATED.getMessage(), + null, + null + ); + } + + // 성공 - void 응답 + public static ApiResponse onSuccessVoid() { + return new ApiResponse<>( + true, + SuccessStatus.OK.getCode(), + SuccessStatus.OK.getMessage(), + null, + null + ); + } + + // 실패 응답 + public static ApiResponse onFailure( + String code, + String message, + T data, + String requestUri + ) { + return new ApiResponse<>( + false, + code, + message, + data, + requestUri + ); + } +} \ No newline at end of file diff --git a/main-modules/common/src/main/java/com/sidework/common/response/ErrorDetail.java b/main-modules/common/src/main/java/com/sidework/common/response/ErrorDetail.java new file mode 100644 index 0000000..247d16b --- /dev/null +++ b/main-modules/common/src/main/java/com/sidework/common/response/ErrorDetail.java @@ -0,0 +1,15 @@ +package com.sidework.common.response; + +import com.sidework.common.response.status.BaseStatusCode; +import org.springframework.http.HttpStatus; + +public record ErrorDetail(HttpStatus httpStatus, String code, String message) { + + public static ErrorDetail from(BaseStatusCode code) { + return new ErrorDetail( + code.getHttpStatus(), + code.getCode(), + code.getMessage() + ); + } +} diff --git a/main-modules/common/src/main/java/com/sidework/common/response/exception/ExceptionAdvice.java b/main-modules/common/src/main/java/com/sidework/common/response/exception/ExceptionAdvice.java new file mode 100644 index 0000000..197b608 --- /dev/null +++ b/main-modules/common/src/main/java/com/sidework/common/response/exception/ExceptionAdvice.java @@ -0,0 +1,241 @@ +package com.sidework.common.response.exception; + + +import com.sidework.common.response.ApiResponse; +import com.sidework.common.response.ErrorDetail; +import com.sidework.common.response.status.ErrorStatus; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.ConstraintViolationException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.context.request.ServletWebRequest; +import org.springframework.web.context.request.WebRequest; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; + +import java.util.Objects; + +@RestControllerAdvice(annotations = RestController.class) +@Slf4j +@Order(Ordered.HIGHEST_PRECEDENCE) +public class ExceptionAdvice extends ResponseEntityExceptionHandler { + + @ExceptionHandler(ConstraintViolationException.class) + public ResponseEntity validation( + ConstraintViolationException e, + WebRequest request + ) { + + String errorMessage = e.getConstraintViolations() + .stream() + .map((ConstraintViolation violation) -> + String.format( + "prop '%s' | val '%s' | msg %s", + violation.getPropertyPath(), + violation.getInvalidValue(), + violation.getMessage() + ) + ) + .findFirst() + .orElseThrow(() -> + new RuntimeException("ConstraintViolationException 추출 도중 에러 발생") + ); + + return handleExceptionInternalConstraint( + e, + HttpHeaders.EMPTY, + request, + errorMessage + ); + } + + @Override + protected ResponseEntity handleHttpMessageNotReadable( + HttpMessageNotReadableException ex, + HttpHeaders headers, + HttpStatusCode status, + WebRequest request + ) { + String path = ((ServletWebRequest) request).getRequest().getRequestURI(); + String message = ex.getMostSpecificCause() != null + ? ex.getMostSpecificCause().getMessage() + : "요청 본문을 읽을 수 없습니다."; + + ApiResponse body = ApiResponse.onFailure( + ErrorStatus.BAD_REQUEST.getCode(), + ErrorStatus.BAD_REQUEST.getMessage(), + message, + path + ); + + return handleExceptionInternal( + ex, + body, + headers, + ErrorStatus.BAD_REQUEST.getHttpStatus(), + request + ); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity exception( + Exception e, + WebRequest request + ) { + StackTraceElement[] stackTrace = e.getStackTrace(); + String errorPoint = (stackTrace == null || stackTrace.length == 0) + ? "No Stack Trace Error." + : e.getStackTrace()[0].toString(); + + return handleExceptionInternalFalse( + e, + ErrorStatus.INTERNAL_SERVER_ERROR, + HttpHeaders.EMPTY, + ErrorStatus.INTERNAL_SERVER_ERROR.getHttpStatus(), + request, + errorPoint + ); + } + + @ExceptionHandler(GlobalException.class) + public ResponseEntity handleGlobalException( + GlobalException ex, + HttpServletRequest request + ) { + ErrorDetail detail = ex.getErrorDetail(); + + ApiResponse body = ApiResponse.onFailure( + detail.code(), + detail.message(), + null, + request.getRequestURI() + ); + + return new ResponseEntity<>(body, detail.httpStatus()); + } + + @Override + protected ResponseEntity handleHttpRequestMethodNotSupported( + HttpRequestMethodNotSupportedException ex, + HttpHeaders headers, + HttpStatusCode status, + WebRequest request + ) { + String path = ((ServletWebRequest) request).getRequest().getRequestURI(); + + ApiResponse body = ApiResponse.onFailure( + ErrorStatus.METHOD_NOT_ALLOWED.getCode(), + ErrorStatus.METHOD_NOT_ALLOWED.getMessage(), + ex.getMessage() != null ? ex.getMessage() : "허용되지 않은 HTTP 메서드입니다.", + path + ); + + return new ResponseEntity<>(body, ErrorStatus.METHOD_NOT_ALLOWED.getHttpStatus()); + } + + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + public ResponseEntity> handleTypeMismatch( + WebRequest request, + MethodArgumentTypeMismatchException ex + ) { + String message = ex.getName() + "의 형식을 확인해주세요."; + String path = ((ServletWebRequest) request).getRequest().getRequestURI(); + + return ResponseEntity.badRequest().body( + ApiResponse.onFailure( + "COMMON_400", + "잘못된 요청입니다.", + message, + path + ) + ); + } + + @Override + protected ResponseEntity handleMethodArgumentNotValid( + MethodArgumentNotValidException ex, + HttpHeaders headers, + HttpStatusCode status, + WebRequest request + ) { + String errorMessage = ex.getBindingResult() + .getFieldErrors() + .stream() + .findFirst() + .map(error -> error.getDefaultMessage()) + .orElse("잘못된 요청입니다."); + + String path = ((ServletWebRequest) request).getRequest().getRequestURI(); + + return ResponseEntity.badRequest().body( + ApiResponse.onFailure( + "COMMON_400", + errorMessage, + null, + path + ) + ); + } + + /* ========================= + Internal Helper Methods + ========================= */ + + private ResponseEntity handleExceptionInternalFalse( + Exception e, + ErrorStatus errorStatus, + HttpHeaders headers, + HttpStatus status, + WebRequest request, + String errorPoint + ) { + String path = ((ServletWebRequest) request).getRequest().getRequestURI(); + + ApiResponse body = ApiResponse.onFailure( + errorStatus.getCode(), + errorStatus.getMessage(), + errorPoint, + path + ); + + log.error(errorPoint); + return super.handleExceptionInternal(e, body, headers, status, request); + } + + private ResponseEntity handleExceptionInternalConstraint( + Exception e, + HttpHeaders headers, + WebRequest request, + String message + ) { + String path = ((ServletWebRequest) request).getRequest().getRequestURI(); + + ApiResponse body = ApiResponse.onFailure( + ErrorStatus.BAD_REQUEST.getCode(), + message, + null, + path + ); + + log.error(message); + return super.handleExceptionInternal( + e, + body, + headers, + ErrorStatus.BAD_REQUEST.getHttpStatus(), + request + ); + } +} diff --git a/main-modules/common/src/main/java/com/sidework/common/response/exception/GlobalException.java b/main-modules/common/src/main/java/com/sidework/common/response/exception/GlobalException.java new file mode 100644 index 0000000..7ae9c00 --- /dev/null +++ b/main-modules/common/src/main/java/com/sidework/common/response/exception/GlobalException.java @@ -0,0 +1,20 @@ +package com.sidework.common.response.exception; + +import com.sidework.common.response.ErrorDetail; +import com.sidework.common.response.status.BaseStatusCode; +import lombok.Getter; + +@Getter +public class GlobalException extends RuntimeException { + + private final BaseStatusCode code; + + public GlobalException(BaseStatusCode code) { + super(code.getMessage()); + this.code = code; + } + + public ErrorDetail getErrorDetail() { + return ErrorDetail.from(this.code); + } +} diff --git a/main-modules/common/src/main/java/com/sidework/common/response/status/BaseStatusCode.java b/main-modules/common/src/main/java/com/sidework/common/response/status/BaseStatusCode.java new file mode 100644 index 0000000..bb505f5 --- /dev/null +++ b/main-modules/common/src/main/java/com/sidework/common/response/status/BaseStatusCode.java @@ -0,0 +1,9 @@ +package com.sidework.common.response.status; + +import org.springframework.http.HttpStatus; + +public interface BaseStatusCode { + HttpStatus getHttpStatus(); + String getCode(); + String getMessage(); +} diff --git a/main-modules/common/src/main/java/com/sidework/common/response/status/ErrorStatus.java b/main-modules/common/src/main/java/com/sidework/common/response/status/ErrorStatus.java new file mode 100644 index 0000000..507af99 --- /dev/null +++ b/main-modules/common/src/main/java/com/sidework/common/response/status/ErrorStatus.java @@ -0,0 +1,71 @@ +package com.sidework.common.response.status; + +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +public enum ErrorStatus implements BaseStatusCode { + + // 일반 응답 + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "COMMON_500", "서버 에러, 관리자에게 문의 바랍니다."), + BAD_REQUEST(HttpStatus.BAD_REQUEST, "COMMON_400", "잘못된 요청입니다."), + UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "COMMON_401", "인증이 필요합니다."), + FORBIDDEN(HttpStatus.FORBIDDEN, "COMMON_403", "금지된 요청입니다."), + NOT_FOUND(HttpStatus.NOT_FOUND, "COMMON_404", "요청한 리소스를 찾을 수 없습니다."), + METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "COMMON_405", "허용되지 않은 요청 메서드입니다."), + + // JWT + EMPTY_JWT(HttpStatus.UNAUTHORIZED, "TOKEN_001", "토큰이 비어있습니다."), + INVALID_JWT(HttpStatus.UNAUTHORIZED, "TOKEN_002", "필요한 정보를 포함하지 않은 토큰입니다."), + EXPIRED_JWT(HttpStatus.UNAUTHORIZED, "TOKEN_003", "유효기간이 만료된 토큰입니다."), + + // USER + USER_ALREADY_EXISTS(HttpStatus.BAD_REQUEST, "USER_001", "이미 존재하는 사용자입니다."), + USER_NOT_FOUND(HttpStatus.BAD_REQUEST, "USER_002", "해당 사용자를 찾을 수 없습니다."), + EMAIL_ALREADY_EXISTS(HttpStatus.BAD_REQUEST, "USER_003", "이미 사용 중인 이메일입니다."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + ErrorStatus(HttpStatus httpStatus, String code, String message) { + this.httpStatus = httpStatus; + this.code = code; + this.message = message; + } + + + @Override + public HttpStatus getHttpStatus() { + return httpStatus; + } + + @Override + public String getCode() { + return code; + } + + @Override + public String getMessage() { + return message; + } + + public BaseStatusCode withDetail(String detail) { + return new BaseStatusCode() { + @Override + public HttpStatus getHttpStatus() { + return ErrorStatus.this.httpStatus; + } + + @Override + public String getCode() { + return ErrorStatus.this.code; + } + + @Override + public String getMessage() { + return detail; + } + }; + } +} diff --git a/main-modules/common/src/main/java/com/sidework/common/response/status/SuccessStatus.java b/main-modules/common/src/main/java/com/sidework/common/response/status/SuccessStatus.java new file mode 100644 index 0000000..d690a79 --- /dev/null +++ b/main-modules/common/src/main/java/com/sidework/common/response/status/SuccessStatus.java @@ -0,0 +1,36 @@ +package com.sidework.common.response.status; + +import org.springframework.http.HttpStatus; + +public enum SuccessStatus implements BaseStatusCode { + + OK(HttpStatus.OK, "COMMON_200", "요청이 정상적으로 처리되었습니다."), + CREATED(HttpStatus.CREATED, "COMMON_201", "데이터가 정상적으로 생성되었습니다."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + + SuccessStatus(HttpStatus httpStatus, String code, String message) { + this.httpStatus = httpStatus; + this.code = code; + this.message = message; + } + + + @Override + public HttpStatus getHttpStatus() { + return httpStatus; + } + + @Override + public String getCode() { + return code; + } + + @Override + public String getMessage() { + return message; + } +} \ No newline at end of file diff --git a/main-modules/common/src/main/java/com/sidework/common/util/CursorUtil.kt b/main-modules/common/src/main/java/com/sidework/common/util/CursorUtil.kt new file mode 100644 index 0000000..cf721ad --- /dev/null +++ b/main-modules/common/src/main/java/com/sidework/common/util/CursorUtil.kt @@ -0,0 +1,33 @@ +//package com.moyeobus.global.util +// +//import java.time.Instant +//import java.util.Base64 +// +//data class CursorWrapper( +// val cursorCreatedAt: Instant?, +// val cursorId: Long? +//) +// +//object CursorUtil { +// fun encode(cursor: CursorWrapper): String? { +// val (createdAt, id) = cursor +// if (createdAt == null || id == null) return null +// val raw = "${createdAt.toEpochMilli()}:$id" +// return Base64.getUrlEncoder().withoutPadding().encodeToString(raw.toByteArray()) +// } +// +// fun decode(cursor: String?): CursorWrapper { +// if (cursor.isNullOrBlank()) return CursorWrapper(null, null) +// +// return try { +// val raw = String(Base64.getUrlDecoder().decode(cursor)) +// val (ts, id) = raw.split(":") +// CursorWrapper( +// Instant.ofEpochMilli(ts.toLong()), +// id.toLong() +// ) +// } catch (_: Exception) { +// CursorWrapper(null, null) +// } +// } +//} \ No newline at end of file diff --git a/main-modules/common/src/main/java/com/sidework/common/util/DateTimeUtil.kt b/main-modules/common/src/main/java/com/sidework/common/util/DateTimeUtil.kt new file mode 100644 index 0000000..9774404 --- /dev/null +++ b/main-modules/common/src/main/java/com/sidework/common/util/DateTimeUtil.kt @@ -0,0 +1,16 @@ +//package com.moyeobus.global.util +// +//import java.time.LocalDateTime +//import java.time.format.DateTimeFormatter +// +//object DateTimeUtil { +// +// private val timeFormatter = DateTimeFormatter.ofPattern("HH:mm") +// private val dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") +// +// fun formatTime(date: LocalDateTime): String = +// date.format(timeFormatter) +// +// fun formatDate(date: LocalDateTime): String = +// date.format(dateFormatter) +//} \ No newline at end of file diff --git a/main-modules/user/application/build.gradle b/main-modules/user/application/build.gradle index 06b2b8f..28805da 100644 --- a/main-modules/user/application/build.gradle +++ b/main-modules/user/application/build.gradle @@ -4,18 +4,30 @@ plugins { id 'io.spring.dependency-management' } tasks.named('jar') { - enabled = false + enabled = true } tasks.named('bootJar') { - enabled = true + enabled = false } dependencies { + implementation libs.spring.boot.starter.web + implementation libs.spring.boot.spring.doc + implementation libs.spring.boot.starter.security + implementation libs.spring.boot.starter.transactional + implementation libs.lombok + annotationProcessor libs.lombok + testImplementation libs.spring.boot.starter.test + testImplementation libs.database.h2 + implementation project(':main-modules:common') - // Only need Spring annotations (@Service) for this module + implementation project(':main-modules:user:domain') + implementation("org.springframework:spring-context") implementation("jakarta.validation:jakarta.validation-api:3.1.1") + + } repositories { diff --git a/main-modules/user/application/src/main/java/com/sidework/user/application/adapter/EmailExistResponse.java b/main-modules/user/application/src/main/java/com/sidework/user/application/adapter/EmailExistResponse.java new file mode 100644 index 0000000..55d6fc5 --- /dev/null +++ b/main-modules/user/application/src/main/java/com/sidework/user/application/adapter/EmailExistResponse.java @@ -0,0 +1,5 @@ +package com.sidework.user.application.adapter; + +public record EmailExistResponse( + Boolean isExist +) {} \ No newline at end of file diff --git a/main-modules/user/application/src/main/java/com/sidework/user/application/adapter/UserController.java b/main-modules/user/application/src/main/java/com/sidework/user/application/adapter/UserController.java new file mode 100644 index 0000000..daee916 --- /dev/null +++ b/main-modules/user/application/src/main/java/com/sidework/user/application/adapter/UserController.java @@ -0,0 +1,34 @@ +package com.sidework.user.application.adapter; + +import com.sidework.common.response.ApiResponse; +import com.sidework.user.application.port.in.SignUpCommand; +import com.sidework.user.application.port.in.UserCommandUseCase; +import com.sidework.user.application.port.in.UserQueryUseCase; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotNull; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/v1/users") +@RequiredArgsConstructor +public class UserController { + private final UserCommandUseCase commandService; + private final UserQueryUseCase queryService; + + @GetMapping("/email") + public ResponseEntity> getEmailAvailable(@RequestParam @Email @NotNull String email) { + boolean res = queryService.checkEmailExists(email); + return ResponseEntity.ok(ApiResponse.onSuccess(new EmailExistResponse(res))); + } + + @PostMapping + public ResponseEntity> postNewUser(@Validated @RequestBody SignUpCommand command) { + commandService.signUp(command); + return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.onSuccessVoid()); + } + +} diff --git a/main-modules/user/application/src/main/java/com/sidework/user/application/exception/InvalidCommandException.java b/main-modules/user/application/src/main/java/com/sidework/user/application/exception/InvalidCommandException.java new file mode 100644 index 0000000..6229636 --- /dev/null +++ b/main-modules/user/application/src/main/java/com/sidework/user/application/exception/InvalidCommandException.java @@ -0,0 +1,10 @@ +package com.sidework.user.application.exception; + +import com.sidework.common.response.exception.GlobalException; +import com.sidework.common.response.status.ErrorStatus; + +public class InvalidCommandException extends GlobalException { + public InvalidCommandException() { + super(ErrorStatus.BAD_REQUEST); + } +} diff --git a/main-modules/user/application/src/main/java/com/sidework/user/application/port/in/SignUpCommand.java b/main-modules/user/application/src/main/java/com/sidework/user/application/port/in/SignUpCommand.java new file mode 100644 index 0000000..64717e0 --- /dev/null +++ b/main-modules/user/application/src/main/java/com/sidework/user/application/port/in/SignUpCommand.java @@ -0,0 +1,15 @@ +package com.sidework.user.application.port.in; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +public record SignUpCommand( + @NotNull @Email String email, + @NotNull @Size(min = 8, message = "비밀번호는 최소 8자 이상이어야 합니다.") String password, + @NotNull String name, + @NotNull String nickname, + @NotNull Integer age, + @NotNull String tel +) { +} diff --git a/main-modules/user/application/src/main/java/com/sidework/user/application/port/in/UserCommandUseCase.java b/main-modules/user/application/src/main/java/com/sidework/user/application/port/in/UserCommandUseCase.java new file mode 100644 index 0000000..fb36d3e --- /dev/null +++ b/main-modules/user/application/src/main/java/com/sidework/user/application/port/in/UserCommandUseCase.java @@ -0,0 +1,5 @@ +package com.sidework.user.application.port.in; + +public interface UserCommandUseCase { + void signUp(SignUpCommand command); +} diff --git a/main-modules/user/application/src/main/java/com/sidework/user/application/port/in/UserQueryUseCase.java b/main-modules/user/application/src/main/java/com/sidework/user/application/port/in/UserQueryUseCase.java new file mode 100644 index 0000000..04556c1 --- /dev/null +++ b/main-modules/user/application/src/main/java/com/sidework/user/application/port/in/UserQueryUseCase.java @@ -0,0 +1,5 @@ +package com.sidework.user.application.port.in; + +public interface UserQueryUseCase { + boolean checkEmailExists(String email); +} diff --git a/main-modules/user/application/src/main/java/com/sidework/user/application/port/out/UserOutPort.java b/main-modules/user/application/src/main/java/com/sidework/user/application/port/out/UserOutPort.java new file mode 100644 index 0000000..bb898c1 --- /dev/null +++ b/main-modules/user/application/src/main/java/com/sidework/user/application/port/out/UserOutPort.java @@ -0,0 +1,9 @@ +package com.sidework.user.application.port.out; + +import com.sidework.user.domain.User; + +public interface UserOutPort { + void save(User user); + boolean existsByEmail(String email); + User findById(Long id); +} diff --git a/main-modules/user/application/src/main/java/com/sidework/user/application/service/UserCommandService.java b/main-modules/user/application/src/main/java/com/sidework/user/application/service/UserCommandService.java new file mode 100644 index 0000000..2acfef1 --- /dev/null +++ b/main-modules/user/application/src/main/java/com/sidework/user/application/service/UserCommandService.java @@ -0,0 +1,39 @@ +package com.sidework.user.application.service; + +import com.sidework.user.application.port.in.SignUpCommand; +import com.sidework.user.application.exception.InvalidCommandException; +import com.sidework.user.application.port.in.UserCommandUseCase; +import com.sidework.user.application.port.out.UserOutPort; +import com.sidework.user.domain.User; +import com.sidework.user.domain.UserType; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = false) +public class UserCommandService implements UserCommandUseCase { + private final UserOutPort userRepository; + private final PasswordEncoder encoder; + + @Override + public void signUp(SignUpCommand command) { + if(command == null) { + throw new InvalidCommandException(); + } + + if(command.email() == null || command.name() == null || command.nickname() == null + || command.password() == null || command.tel() == null || command.age() == null) { + throw new InvalidCommandException(); + } + User user = User.create(command.email(), command.name(), command.nickname(), encodePassword(command.password()) + , command.age(), command.tel(), UserType.LOCAL); + userRepository.save(user); + } + + private String encodePassword(String rawPassword) { + return encoder.encode(rawPassword); + } +} diff --git a/main-modules/user/application/src/main/java/com/sidework/user/application/service/UserQueryService.java b/main-modules/user/application/src/main/java/com/sidework/user/application/service/UserQueryService.java new file mode 100644 index 0000000..f2dd329 --- /dev/null +++ b/main-modules/user/application/src/main/java/com/sidework/user/application/service/UserQueryService.java @@ -0,0 +1,19 @@ +package com.sidework.user.application.service; + +import com.sidework.user.application.port.in.UserQueryUseCase; +import com.sidework.user.application.port.out.UserOutPort; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class UserQueryService implements UserQueryUseCase { + private final UserOutPort userRepository; + + @Override + public boolean checkEmailExists(String email) { + return userRepository.existsByEmail(email); + } +} diff --git a/main-modules/user/application/src/test/java/com/sidework/user/application/UserCommandServiceTest.java b/main-modules/user/application/src/test/java/com/sidework/user/application/UserCommandServiceTest.java new file mode 100644 index 0000000..99cf1f7 --- /dev/null +++ b/main-modules/user/application/src/test/java/com/sidework/user/application/UserCommandServiceTest.java @@ -0,0 +1,75 @@ +package com.sidework.user.application; + +import com.sidework.user.application.port.in.SignUpCommand; +import com.sidework.user.application.exception.InvalidCommandException; +import com.sidework.user.application.port.out.UserOutPort; +import com.sidework.user.application.service.UserCommandService; +import com.sidework.user.domain.User; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +public class UserCommandServiceTest { + @Mock + private UserOutPort repo; + + @InjectMocks + private UserCommandService service; + + @Spy + private BCryptPasswordEncoder encoder; + + + @Test + void 정상적인_회원가입_요청_DTO로_회원가입에_성공한다() { + SignUpCommand command = createCommand(); + service.signUp(command); + + // then + ArgumentCaptor captor = ArgumentCaptor.forClass(User.class); + verify(repo).save(captor.capture()); + + User savedUser = captor.getValue(); + assertTrue(encoder.matches(command.password(), savedUser.getPassword())); + assertNotEquals(command.password(), savedUser.getPassword()); + verify(encoder).encode(command.password()); + } + + @Test + void 일부_값이_누락된_회원가입_요청_DTO로_회원가입에_실패한다() { + SignUpCommand command = createInvalidCommand(); + assertThrows(InvalidCommandException.class, + () -> service.signUp(command)); + } + + private SignUpCommand createCommand(){ + return new SignUpCommand( + "test@test.com", + "password123!", + "홍길동", + "길동", + 20, + "010-1234-5678" + ); + } + + private SignUpCommand createInvalidCommand(){ + return new SignUpCommand( + null, + "password123!", + "홍길동", + "길동", + 20, + "010-1234-5678" + ); + } +} diff --git a/main-modules/user/application/src/test/java/com/sidework/user/application/UserControllerTest.java b/main-modules/user/application/src/test/java/com/sidework/user/application/UserControllerTest.java new file mode 100644 index 0000000..5820f65 --- /dev/null +++ b/main-modules/user/application/src/test/java/com/sidework/user/application/UserControllerTest.java @@ -0,0 +1,184 @@ +package com.sidework.user.application; + + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sidework.user.application.port.in.SignUpCommand; +import com.sidework.user.application.adapter.UserController; +import com.sidework.user.application.port.in.UserCommandUseCase; +import com.sidework.user.application.port.in.UserQueryUseCase; +import com.sidework.user.domain.User; +import com.sidework.user.domain.UserType; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@WebMvcTest(UserController.class) +@AutoConfigureMockMvc(addFilters = false) +@ContextConfiguration(classes = UserTestApplication.class) +public class UserControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockitoBean + private UserCommandUseCase userCommandUseCase; + + @MockitoBean + private UserQueryUseCase userQueryUseCase; + + @Test + void 회원가입_요청시_성공하면_201을_반환한다() throws Exception { + // given + SignUpCommand command = createCommand(); + doNothing().when(userCommandUseCase).signUp(command); + + // when & then + mockMvc.perform(post("/api/v1/users") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(command))) + .andDo(print()) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.isSuccess").value(true)); + + verify(userCommandUseCase).signUp(any()); + } + + @Test + void 회원가입_요청시_어느_하나의_값이라도_Null이면_400을_반환한다() throws Exception { + // given + SignUpCommand command = createNullExistCommand(); + + // when & then + mockMvc.perform(post("/api/v1/users") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(command))) + .andDo(print()) + .andExpect(status().isBadRequest()); + } + + @Test + void 회원가입_요청시_이메일_형식이_잘못되면_400을_반환한다() throws Exception { + // given + SignUpCommand command = createNotEmailCommand(); + + // when & then + mockMvc.perform(post("/api/v1/users") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(command))) + .andDo(print()) + .andExpect(status().isBadRequest()); + } + + @Test + void 회원가입_요청시_비밀번호_길이가_8보다_짧으면_400을_반환한다() throws Exception { + // given + SignUpCommand command = createShortPasswordCommand(); + + // when & then + mockMvc.perform(post("/api/v1/users") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(command))) + .andDo(print()) + .andExpect(status().isBadRequest()); + } + + @Test + void 이메일_중복_확인시_중복이면_true를_반환한다() throws Exception { + // given + String email = "test@test.com"; + when(userQueryUseCase.checkEmailExists(email)).thenReturn(true); + + // when & then + mockMvc.perform(get("/api/v1/users/email") + .param("email", email)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result.isExist").value(true)); + + verify(userQueryUseCase).checkEmailExists(email); + } + + @Test + void 이메일_중복_확인시_중복이_아니면_false를_반환한다() throws Exception { + // given + String email = "new@test.com"; + when(userQueryUseCase.checkEmailExists(email)).thenReturn(false); + + // when & then + mockMvc.perform(get("/api/v1/users/email") + .param("email", email)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result.isExist").value(false)); + + verify(userQueryUseCase).checkEmailExists(email); + } + + private SignUpCommand createCommand(){ + return new SignUpCommand( + "test@test.com", + "password123!", + "홍길동", + "길동", + 20, + "010-1234-5678" + ); + } + + private SignUpCommand createNullExistCommand(){ + return new SignUpCommand( + null, + "password123!", + "홍길동", + "길동", + 20, + "010-1234-5678" + ); + } + + private SignUpCommand createNotEmailCommand(){ + return new SignUpCommand( + "email", + "password123!", + "홍길동", + "길동", + 20, + "010-1234-5678" + ); + } + + private SignUpCommand createShortPasswordCommand(){ + return new SignUpCommand( + "test1@test.com", + "passwor", + "홍길동", + "길동", + 20, + "010-1234-5678" + ); + } + + + private User createUser(SignUpCommand command){ + return User.create(command.email(), + command.name(), + command.nickname(), + command.password(), + command.age(), + command.tel(), + UserType.LOCAL); + } +} diff --git a/main-modules/user/application/src/test/java/com/sidework/user/application/UserQueryServiceTest.java b/main-modules/user/application/src/test/java/com/sidework/user/application/UserQueryServiceTest.java new file mode 100644 index 0000000..56b236d --- /dev/null +++ b/main-modules/user/application/src/test/java/com/sidework/user/application/UserQueryServiceTest.java @@ -0,0 +1,33 @@ +package com.sidework.user.application; + +import com.sidework.user.application.port.out.UserOutPort; +import com.sidework.user.application.service.UserQueryService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class UserQueryServiceTest { + @Mock + private UserOutPort repo; + + @InjectMocks + private UserQueryService service; + + @Test + void email로_중복_여부를_조회한다() { + String email = "test1@naver.com"; + when(repo.existsByEmail(email)).thenReturn(true); + + boolean res = service.checkEmailExists(email); + + assertTrue(res); + verify(repo).existsByEmail(email); + } +} diff --git a/main-modules/user/application/src/test/java/com/sidework/user/application/UserTestApplication.java b/main-modules/user/application/src/test/java/com/sidework/user/application/UserTestApplication.java new file mode 100644 index 0000000..1462256 --- /dev/null +++ b/main-modules/user/application/src/test/java/com/sidework/user/application/UserTestApplication.java @@ -0,0 +1,7 @@ +package com.sidework.user.application; + +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +class UserTestApplication { +} diff --git a/main-modules/user/domain/build.gradle b/main-modules/user/domain/build.gradle index 4bf5631..24e2a2a 100644 --- a/main-modules/user/domain/build.gradle +++ b/main-modules/user/domain/build.gradle @@ -1 +1,27 @@ -// 도메인 모듈은 특정 프레임워크나 인터페이스나 의존하지 않으므로 의존성 불필요. \ No newline at end of file +plugins { + id 'java' + id 'org.springframework.boot' + id 'io.spring.dependency-management' +} +tasks.named('jar') { + enabled = true +} + +tasks.named('bootJar') { + enabled = false +} + +dependencies { + compileOnly libs.lombok + annotationProcessor libs.lombok +} + +repositories { + mavenCentral() +} + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} \ No newline at end of file diff --git a/main-modules/user/domain/src/main/java/com/sidework/user/domain/User.java b/main-modules/user/domain/src/main/java/com/sidework/user/domain/User.java new file mode 100644 index 0000000..7ee42c5 --- /dev/null +++ b/main-modules/user/domain/src/main/java/com/sidework/user/domain/User.java @@ -0,0 +1,46 @@ +package com.sidework.user.domain; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class User { + private Long id; + + private String email; + + private String name; + + private String nickname; + + private String password; + + private Integer age; + + private String tel; + + private UserType type; + + private Boolean isActive = true; + + public static User create( + String email, + String name, + String nickname, + String password, + Integer age, + String tel, + UserType type + ) { + return new User(null, email, name, nickname, password, age, tel, type, true); + } + // 탈퇴 처리 + public void deactivate() { + this.isActive = false; + } +} diff --git a/main-modules/user/domain/src/main/java/com/sidework/user/domain/UserType.java b/main-modules/user/domain/src/main/java/com/sidework/user/domain/UserType.java new file mode 100644 index 0000000..2ca66b4 --- /dev/null +++ b/main-modules/user/domain/src/main/java/com/sidework/user/domain/UserType.java @@ -0,0 +1,5 @@ +package com.sidework.user.domain; + +public enum UserType { + LOCAL, KAKAO, GOOGLE +} diff --git a/main-modules/user/infrastructure/build.gradle b/main-modules/user/infrastructure/build.gradle index b6a46ca..5659147 100644 --- a/main-modules/user/infrastructure/build.gradle +++ b/main-modules/user/infrastructure/build.gradle @@ -1,18 +1,19 @@ plugins { id 'java' - id 'org.springframework.boot' id 'io.spring.dependency-management' } tasks.named('jar') { - enabled = false + enabled = true } tasks.named('bootJar') { - enabled = true + enabled = false } dependencies { implementation project(':main-modules:common') + implementation project(':main-modules:user:domain') + implementation project(':main-modules:user:application') implementation libs.spring.boot.starter.jpa implementation libs.spring.boot.starter.security @@ -20,13 +21,17 @@ dependencies { implementation libs.spring.boot.starter.web.flux implementation libs.mysql.connector implementation libs.bundles.jwt + + compileOnly libs.lombok + annotationProcessor libs.lombok + implementation libs.mapstruct + annotationProcessor libs.mapstruct.processor + testAnnotationProcessor libs.mapstruct.processor - testImplementation(libs.spring.boot.starter.test) { - exclude group: 'org.mockito', module: 'mockito-core' - } - testImplementation libs.database.h2 + testImplementation libs.spring.boot.starter.test + testImplementation libs.database.h2 } repositories { diff --git a/main-modules/user/infrastructure/src/main/java/com/sidework/user/persistence/adapter/UserPersistenceAdapter.java b/main-modules/user/infrastructure/src/main/java/com/sidework/user/persistence/adapter/UserPersistenceAdapter.java new file mode 100644 index 0000000..220c44e --- /dev/null +++ b/main-modules/user/infrastructure/src/main/java/com/sidework/user/persistence/adapter/UserPersistenceAdapter.java @@ -0,0 +1,33 @@ +package com.sidework.user.persistence.adapter; + +import com.sidework.user.application.port.out.UserOutPort; +import com.sidework.user.domain.User; +import com.sidework.user.persistence.entity.UserEntity; +import com.sidework.user.persistence.exception.UserNotFoundException; +import com.sidework.user.persistence.mapper.UserMapper; +import com.sidework.user.persistence.repository.UserJpaRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class UserPersistenceAdapter implements UserOutPort { + private final UserJpaRepository repo; + private final UserMapper mapper; + + @Override + public void save(User user) { + repo.save(mapper.toEntity(user)); + } + + @Override + public boolean existsByEmail(String email) { + return repo.existsByEmail(email); + } + + @Override + public User findById(Long id) { + UserEntity user = repo.findById(id).orElseThrow(() -> new UserNotFoundException(id)); + return mapper.toDomain(user); + } +} diff --git a/main-modules/user/infrastructure/src/main/java/com/sidework/user/persistence/config/UserJpaConfig.java b/main-modules/user/infrastructure/src/main/java/com/sidework/user/persistence/config/UserJpaConfig.java new file mode 100644 index 0000000..b8b4c08 --- /dev/null +++ b/main-modules/user/infrastructure/src/main/java/com/sidework/user/persistence/config/UserJpaConfig.java @@ -0,0 +1,13 @@ +package com.sidework.user.persistence.config; + +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; + +@Configuration +@EnableJpaAuditing +@EnableJpaRepositories(basePackages = "com.sidework.user.persistence.repository") +@EntityScan(basePackages = "com.sidework.user.persistence.entity") +public class UserJpaConfig { +} diff --git a/main-modules/user/infrastructure/src/main/java/com/sidework/user/persistence/entity/UserEntity.java b/main-modules/user/infrastructure/src/main/java/com/sidework/user/persistence/entity/UserEntity.java new file mode 100644 index 0000000..3dfbb71 --- /dev/null +++ b/main-modules/user/infrastructure/src/main/java/com/sidework/user/persistence/entity/UserEntity.java @@ -0,0 +1,52 @@ +package com.sidework.user.persistence.entity; + +import com.sidework.common.entity.BaseEntity; +import com.sidework.user.domain.UserType; +import jakarta.persistence.*; +import lombok.*; + + +@Entity +@Table(name = "users") +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class UserEntity extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id", nullable = false) + private Long id; + + @Column(name = "email", nullable = false, length = 100) + private String email; + + // 실명 + @Column(name = "name", nullable = false, length = 15) + private String name; + + // 서비스 내 노출 이름 + @Column(name = "nickname", nullable = false, length = 30) + private String nickname; + + // 비밀번호 (암호화 저장 전제) + @Column(name = "password", nullable = false, columnDefinition = "TEXT") + private String password; + + // 만 나이 + @Column(name = "age", nullable = false) + private Integer age; + + // 전화번호 + @Column(name = "tel", nullable = false, length = 20) + private String tel; + + // 회원가입 종류 (LOCAL, KAKAO, GITHUB, GOOGLE) + @Enumerated(EnumType.STRING) + @Column(name = "type", nullable = false, length = 10) + private UserType type; + + // 활성 / 비활성 여부 (true = 활성, false = 탈퇴) + @Column(name = "is_active", nullable = false) + private Boolean isActive = true; +} \ No newline at end of file diff --git a/main-modules/user/infrastructure/src/main/java/com/sidework/user/persistence/exception/UserNotFoundException.java b/main-modules/user/infrastructure/src/main/java/com/sidework/user/persistence/exception/UserNotFoundException.java new file mode 100644 index 0000000..8a65314 --- /dev/null +++ b/main-modules/user/infrastructure/src/main/java/com/sidework/user/persistence/exception/UserNotFoundException.java @@ -0,0 +1,10 @@ +package com.sidework.user.persistence.exception; + +import com.sidework.common.response.exception.GlobalException; +import com.sidework.common.response.status.ErrorStatus; + +public class UserNotFoundException extends GlobalException { + public UserNotFoundException(Long id) { + super(ErrorStatus.USER_NOT_FOUND.withDetail(String.format("사용자(id=%d)를 찾을 수 없습니다.", id))); + } +} diff --git a/main-modules/user/infrastructure/src/main/java/com/sidework/user/persistence/mapper/UserMapper.java b/main-modules/user/infrastructure/src/main/java/com/sidework/user/persistence/mapper/UserMapper.java new file mode 100644 index 0000000..2369ce4 --- /dev/null +++ b/main-modules/user/infrastructure/src/main/java/com/sidework/user/persistence/mapper/UserMapper.java @@ -0,0 +1,11 @@ +package com.sidework.user.persistence.mapper; + +import com.sidework.user.domain.User; +import com.sidework.user.persistence.entity.UserEntity; +import org.mapstruct.Mapper; + +@Mapper(componentModel = "spring") +public interface UserMapper { + User toDomain(UserEntity entity); + UserEntity toEntity(User user); +} diff --git a/main-modules/user/infrastructure/src/main/java/com/sidework/user/persistence/repository/UserJpaRepository.java b/main-modules/user/infrastructure/src/main/java/com/sidework/user/persistence/repository/UserJpaRepository.java new file mode 100644 index 0000000..4f21c85 --- /dev/null +++ b/main-modules/user/infrastructure/src/main/java/com/sidework/user/persistence/repository/UserJpaRepository.java @@ -0,0 +1,10 @@ +package com.sidework.user.persistence.repository; + +import com.sidework.user.persistence.entity.UserEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface UserJpaRepository extends JpaRepository { + boolean existsByEmail(String email); +} diff --git a/main-modules/user/infrastructure/src/test/java/com/sidework/user/persistence/UserPersistenceAdapterTest.java b/main-modules/user/infrastructure/src/test/java/com/sidework/user/persistence/UserPersistenceAdapterTest.java new file mode 100644 index 0000000..d25f68f --- /dev/null +++ b/main-modules/user/infrastructure/src/test/java/com/sidework/user/persistence/UserPersistenceAdapterTest.java @@ -0,0 +1,117 @@ +package com.sidework.user.persistence; + +import com.sidework.user.application.port.in.SignUpCommand; +import com.sidework.user.domain.User; +import com.sidework.user.domain.UserType; +import com.sidework.user.persistence.adapter.UserPersistenceAdapter; +import com.sidework.user.persistence.entity.UserEntity; +import com.sidework.user.persistence.exception.UserNotFoundException; +import com.sidework.user.persistence.mapper.UserMapper; +import com.sidework.user.persistence.repository.UserJpaRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mapstruct.factory.Mappers; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +public class UserPersistenceAdapterTest { + @Mock + private UserJpaRepository repo; + + UserMapper mapper = Mappers.getMapper(UserMapper.class); + private UserPersistenceAdapter adapter; + + @BeforeEach + void setUp() { + adapter = new UserPersistenceAdapter(repo, mapper); + } + + @Test + void save는_도메인_객체를_영속성_객체로_변환해_저장한다() { + User domain = createUser(createCommand()); + adapter.save(domain); + verify(repo).save(any(UserEntity.class)); + } + + @Test + void existByEmail은_이메일_중복_여부를_확인한다() { + String emailExists = "test1@naver.com"; + String emailNotExists = "test2@naver.com"; + when(repo.existsByEmail(emailExists)).thenReturn(true); + when(repo.existsByEmail(emailNotExists)).thenReturn(false); + + boolean exists = adapter.existsByEmail(emailExists); + boolean notExists = adapter.existsByEmail(emailNotExists); + + assertTrue(exists); + assertFalse(notExists); + + verify(repo).existsByEmail(emailExists); + verify(repo).existsByEmail(emailNotExists); + } + + @Test + void findById는_Id로_사용자를_조회해_도메인_객체로_변환한다() { + UserEntity entity = createUserEntity(1L); + + when(repo.findById(1L)).thenReturn(Optional.of(entity)); + + User user = adapter.findById(1L); + + assertNotNull(user); + assertEquals(1L, user.getId()); + + verify(repo).findById(1L); + } + + @Test + void findById로_존재하지_않는_사용자_조회_시_UserNotFoundException을_던진다() { + when(repo.findById(2L)).thenReturn(Optional.empty()); + assertThrows(UserNotFoundException.class, + () -> adapter.findById(2L)); + verify(repo).findById(2L); + } + + private SignUpCommand createCommand(){ + return new SignUpCommand( + "test@test.com", + "password123!", + "홍길동", + "길동", + 20, + "010-1234-5678" + ); + } + + private User createUser(SignUpCommand command){ + return User.create(command.email(), + command.name(), + command.nickname(), + command.password(), + command.age(), + command.tel(), + UserType.LOCAL); + } + + private UserEntity createUserEntity(Long id){ + return new UserEntity( + id, + "test@test.com", + "테스트", + "테스트1", + "password123!", + 20, + "010-1234-5678", + UserType.LOCAL, + true + ); + } +} +