diff --git a/src/main/java/com/dduru/gildongmu/auth/service/OauthAuthService.java b/src/main/java/com/dduru/gildongmu/auth/service/OauthAuthService.java index fdeb3217..5aa82fd4 100644 --- a/src/main/java/com/dduru/gildongmu/auth/service/OauthAuthService.java +++ b/src/main/java/com/dduru/gildongmu/auth/service/OauthAuthService.java @@ -11,6 +11,8 @@ import com.dduru.gildongmu.common.exception.BusinessException; import com.dduru.gildongmu.common.exception.ErrorCode; import com.dduru.gildongmu.common.jwt.JwtTokenProvider; +import com.dduru.gildongmu.profile.domain.Profile; +import com.dduru.gildongmu.profile.repository.ProfileRepository; import com.dduru.gildongmu.user.domain.User; import com.dduru.gildongmu.user.enums.OauthType; import com.dduru.gildongmu.user.enums.Role; @@ -30,6 +32,7 @@ public class OauthAuthService { private final OauthFactory oauthFactory; private final UserRepository userRepository; + private final ProfileRepository profileRepository; private final JwtTokenProvider jwtTokenProvider; private final RefreshTokenService refreshTokenService; @@ -145,22 +148,27 @@ private record UserCreationResult(User user, boolean isNewUser) { } private User createNewUser(OauthUserInfo oauthUserInfo) { - // 추가 정보는 회원가입 이후 별도 입력으로 변경 - // 닉네임은 온보딩에서 설정하므로 null로 초기화 - User newUser = User.builder() .email(oauthUserInfo.email()) .name(oauthUserInfo.name()) - .nickname(null) - .profileImage(oauthUserInfo.profileImage()) .oauthId(oauthUserInfo.oauthId()) .oauthType(oauthUserInfo.loginType()) .role(Role.USER) + .build(); + + User savedUser = userRepository.save(newUser); + + Profile profile = Profile.builder() + .user(savedUser) + .profileImage(null) + .nickname(null) .gender(null) .phoneNumber(null) .birthday(null) .build(); - return userRepository.save(newUser); + profileRepository.save(profile); + + return savedUser; } } diff --git a/src/main/java/com/dduru/gildongmu/comment/dto/CommentResponse.java b/src/main/java/com/dduru/gildongmu/comment/dto/CommentResponse.java index 20486c6f..1ec2383c 100644 --- a/src/main/java/com/dduru/gildongmu/comment/dto/CommentResponse.java +++ b/src/main/java/com/dduru/gildongmu/comment/dto/CommentResponse.java @@ -34,8 +34,8 @@ public static CommentResponse from(Comment comment) { authorProfileImage = null; } else { content = comment.getContent(); - author = comment.getUser().getNickname(); - authorProfileImage = comment.getUser().getProfileImage(); + author = comment.getUser().getProfile().getNickname(); + authorProfileImage = comment.getUser().getProfile().getProfileImage(); } return new CommentResponse( diff --git a/src/main/java/com/dduru/gildongmu/common/annotation/ApiErrorResponses.java b/src/main/java/com/dduru/gildongmu/common/annotation/ApiErrorResponses.java new file mode 100644 index 00000000..99dcebf2 --- /dev/null +++ b/src/main/java/com/dduru/gildongmu/common/annotation/ApiErrorResponses.java @@ -0,0 +1,28 @@ +package com.dduru.gildongmu.common.annotation; + +import com.dduru.gildongmu.common.exception.ErrorCode; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * API 엔드포인트에 자동으로 에러 응답을 추가하기 위한 어노테이션 + * ErrorCode enum을 지정하면 해당 ErrorCode의 HTTP 상태 코드가 자동으로 사용됩니다. + * + * 사용 예시: + *
+ * {@code @ApiErrorResponses({ErrorCode.NICKNAME_ALREADY_TAKEN, ErrorCode.PROFILE_NOT_FOUND})}
+ * ResponseEntity> method();
+ * 
+ */ +@Target({ElementType.METHOD, ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +public @interface ApiErrorResponses { + /** + * 추가할 ErrorCode enum 목록 (예: ErrorCode.NICKNAME_ALREADY_TAKEN, ErrorCode.PROFILE_NOT_FOUND) + * 각 ErrorCode의 HTTP 상태 코드가 자동으로 사용됩니다. + */ + ErrorCode[] value(); +} diff --git a/src/main/java/com/dduru/gildongmu/common/annotation/CommonApiResponses.java b/src/main/java/com/dduru/gildongmu/common/annotation/CommonApiResponses.java deleted file mode 100644 index df4e5922..00000000 --- a/src/main/java/com/dduru/gildongmu/common/annotation/CommonApiResponses.java +++ /dev/null @@ -1,104 +0,0 @@ -package com.dduru.gildongmu.common.annotation; - -import com.dduru.gildongmu.common.exception.ErrorResponse; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.ExampleObject; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.responses.ApiResponses; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -@Target({ElementType.METHOD, ElementType.TYPE}) -@Retention(RetentionPolicy.RUNTIME) -@ApiResponses({ - @ApiResponse( - responseCode = "400", - description = "잘못된 요청", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ErrorResponse.class), - examples = @ExampleObject( - name = "InvalidInput", - value = """ - { - "status": 400, - "data": { - "errorCode": "INVALID_INPUT_VALUE", - "field": null, - "message": "잘못된 입력 값입니다." - } - } - """ - ) - ) - ), - @ApiResponse( - responseCode = "401", - description = "인증 실패", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ErrorResponse.class), - examples = @ExampleObject( - name = "Unauthorized", - value = """ - { - "status": 401, - "data": { - "errorCode": "UNAUTHORIZED", - "field": null, - "message": "인증되지 않은 사용자입니다." - } - } - """ - ) - ) - ), - @ApiResponse( - responseCode = "404", - description = "리소스를 찾을 수 없음", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ErrorResponse.class), - examples = @ExampleObject( - name = "NotFound", - value = """ - { - "status": 404, - "data": { - "errorCode": "NOT_FOUND", - "field": null, - "message": "요청한 리소스를 찾을 수 없습니다." - } - } - """ - ) - ) - ), - @ApiResponse( - responseCode = "500", - description = "서버 오류", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ErrorResponse.class), - examples = @ExampleObject( - name = "InternalServerError", - value = """ - { - "status": 500, - "data": { - "errorCode": "INTERNAL_SERVER_ERROR", - "field": null, - "message": "서버 오류가 발생했습니다." - } - } - """ - ) - ) - ) -}) -public @interface CommonApiResponses { -} diff --git a/src/main/java/com/dduru/gildongmu/common/exception/ApiErrorResponseDocsCustomizer.java b/src/main/java/com/dduru/gildongmu/common/exception/ApiErrorResponseDocsCustomizer.java new file mode 100644 index 00000000..5e7e8bfa --- /dev/null +++ b/src/main/java/com/dduru/gildongmu/common/exception/ApiErrorResponseDocsCustomizer.java @@ -0,0 +1,175 @@ +package com.dduru.gildongmu.common.exception; + +import com.dduru.gildongmu.common.annotation.ApiErrorResponses; +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.examples.Example; +import io.swagger.v3.oas.models.media.Content; +import io.swagger.v3.oas.models.media.MediaType; +import io.swagger.v3.oas.models.media.Schema; +import io.swagger.v3.oas.models.responses.ApiResponse; +import lombok.extern.slf4j.Slf4j; +import org.springdoc.core.customizers.OperationCustomizer; +import org.springdoc.core.customizers.OpenApiCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.lang.reflect.Method; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * OpenAPI 에러 응답 자동 추가 커스터마이저 + * + * @ApiErrorResponses 어노테이션에 ErrorCode enum을 지정하면, + * 해당 ErrorCode의 HTTP 상태 코드와 메시지가 자동으로 사용됩니다. + */ +@Slf4j +@Configuration +public class ApiErrorResponseDocsCustomizer { + + /** + * ErrorResponse 스키마를 Components에 등록 + */ + @Bean + public OpenApiCustomizer errorResponseSchemaCustomizer() { + return openApi -> { + Components components = openApi.getComponents(); + if (components == null) { + components = new Components(); + openApi.setComponents(components); + } + + // ErrorResponse 스키마 정의 + Schema errorResponseSchema = new Schema<>() + .type("object") + .addProperty("status", new Schema<>().type("integer").format("int32").example(400)) + .addProperty("data", new Schema<>() + .type("object") + .addProperty("errorCode", new Schema<>().type("string").example("INVALID_INPUT_VALUE")) + .addProperty("field", new Schema<>().type("string").nullable(true)) + .addProperty("message", new Schema<>().type("string").example("잘못된 입력 값입니다."))); + + components.addSchemas("ErrorResponse", errorResponseSchema); + }; + } + + /** + * @ApiErrorResponses 어노테이션에 지정된 ErrorCode enum을 기반으로 에러 응답 자동 추가 + */ + @Bean + public OperationCustomizer operationCustomizer() { + return (operation, handlerMethod) -> { + ErrorCode[] errorCodes = new ErrorCode[0]; + + Method method = handlerMethod.getMethod(); + Class declaringClass = method.getDeclaringClass(); + String methodName = method.getName(); + + // 모든 인터페이스에서 메서드를 찾아 어노테이션 확인 + for (Class iface : declaringClass.getInterfaces()) { + // 인터페이스의 모든 메서드 검색 (파라미터 타입이 다를 수 있으므로) + for (Method interfaceMethod : iface.getMethods()) { + // 메서드 이름이 같고 어노테이션이 있으면 사용 + if (interfaceMethod.getName().equals(methodName) + && interfaceMethod.isAnnotationPresent(ApiErrorResponses.class)) { + errorCodes = interfaceMethod.getAnnotation(ApiErrorResponses.class).value(); + break; + } + } + if (errorCodes.length > 0) { + break; + } + + // 인터페이스 레벨 어노테이션 확인 + if (iface.isAnnotationPresent(ApiErrorResponses.class)) { + errorCodes = iface.getAnnotation(ApiErrorResponses.class).value(); + break; + } + } + + // 클래스 레벨 어노테이션 확인 + if (errorCodes.length == 0 && declaringClass.isAnnotationPresent(ApiErrorResponses.class)) { + errorCodes = declaringClass.getAnnotation(ApiErrorResponses.class).value(); + } + + // 메서드 레벨 어노테이션 확인 (메서드 레벨이 우선) + if (method.isAnnotationPresent(ApiErrorResponses.class)) { + errorCodes = method.getAnnotation(ApiErrorResponses.class).value(); + } + + if (errorCodes.length > 0) { + addErrorResponses(operation, errorCodes); + } + + return operation; + }; + } + + /** + * ErrorCode enum 배열을 기반으로 에러 응답 추가 + * 각 ErrorCode에 대해 해당하는 HTTP 상태 코드의 응답을 추가하고, 예시를 포함합니다. + * 같은 HTTP 상태 코드를 가진 여러 ErrorCode가 있으면 모두 examples에 추가합니다. + */ + private void addErrorResponses(io.swagger.v3.oas.models.Operation operation, ErrorCode[] errorCodes) { + // ErrorResponse 스키마 참조 + Schema errorResponseSchema = new Schema<>() + .$ref("#/components/schemas/ErrorResponse"); + + // HTTP 상태 코드별로 ErrorCode들을 그룹화 + Map> statusCodeToErrorCodes = new HashMap<>(); + + for (ErrorCode errorCode : errorCodes) { + String statusCode = String.valueOf(errorCode.getStatus()); + statusCodeToErrorCodes.computeIfAbsent(statusCode, k -> new java.util.ArrayList<>()).add(errorCode); + } + + // 각 상태 코드에 대해 에러 응답 추가 + for (Map.Entry> entry : statusCodeToErrorCodes.entrySet()) { + String statusCode = entry.getKey(); + java.util.List errorCodeList = entry.getValue(); + + // 이미 해당 상태 코드의 응답이 있으면 스킵 + if (operation.getResponses().containsKey(statusCode)) { + continue; + } + + // 첫 번째 ErrorCode를 기본으로 사용 (description) + ErrorCode primaryErrorCode = errorCodeList.get(0); + + // 각 ErrorCode에 대한 예시 생성 + Map examples = new HashMap<>(); + for (ErrorCode errorCode : errorCodeList) { + Map errorResponseMap = new LinkedHashMap<>(); + errorResponseMap.put("status", errorCode.getStatus()); + + Map dataMap = new LinkedHashMap<>(); + dataMap.put("errorCode", errorCode.name()); + dataMap.put("field", null); + dataMap.put("message", errorCode.getMessage()); + errorResponseMap.put("data", dataMap); + + Example example = new Example(); + example.setValue(errorResponseMap); + example.setSummary(errorCode.getMessage()); + examples.put(errorCode.name(), example); + } + + // MediaType 생성 (여러 예시가 있으면 examples 사용, 하나만 있으면 example 사용) + MediaType mediaType = new MediaType().schema(errorResponseSchema); + if (examples.size() > 1) { + mediaType.setExamples(examples); + } else if (examples.size() == 1) { + Example singleExample = examples.values().iterator().next(); + mediaType.setExample(singleExample.getValue()); + } + + // 에러 응답 생성 + ApiResponse apiResponse = new ApiResponse() + .description(primaryErrorCode.getMessage()) + .content(new Content().addMediaType("application/json", mediaType)); + + operation.getResponses().addApiResponse(statusCode, apiResponse); + } + } +} diff --git a/src/main/java/com/dduru/gildongmu/common/exception/ErrorCode.java b/src/main/java/com/dduru/gildongmu/common/exception/ErrorCode.java index b267e5bb..96a8b1ff 100644 --- a/src/main/java/com/dduru/gildongmu/common/exception/ErrorCode.java +++ b/src/main/java/com/dduru/gildongmu/common/exception/ErrorCode.java @@ -14,7 +14,7 @@ public enum ErrorCode { UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "인증되지 않은 사용자입니다."), DUPLICATE_EMAIL(HttpStatus.CONFLICT, "이미 다른 소셜 계정으로 가입된 이메일입니다."), - // 닉네임 (NICKNAME) - 이전 '온보딩 (ONBD)' + // 닉네임 (NICKNAME) NICKNAME_NOT_BLANK(HttpStatus.BAD_REQUEST, "닉네임은 공백일 수 없습니다."), NICKNAME_INVALID_LENGTH(HttpStatus.BAD_REQUEST, "닉네임은 2자 이상 14자 이하로 입력해주세요."), NICKNAME_INVALID_CHARACTERS(HttpStatus.BAD_REQUEST, "닉네임은 한글, 영어, 숫자만 사용 가능합니다."), @@ -25,6 +25,7 @@ public enum ErrorCode { // 사용자 (USER) USER_NOT_FOUND(HttpStatus.NOT_FOUND, "사용자를 찾을 수 없습니다."), + PROFILE_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 유저의 프로필을 찾을 수 없습니다."), // 게시글 (POST) POST_NOT_FOUND(HttpStatus.NOT_FOUND, "게시글을 찾을 수 없습니다."), @@ -61,7 +62,7 @@ public enum ErrorCode { INVALID_FILE_EXTENSION(HttpStatus.BAD_REQUEST, "허용되지 않는 파일 확장자입니다."), // 설문 (SURVEY) - UNKNOWN_SURVEY_ANSWER_CODE(HttpStatus.BAD_REQUEST, "알 수 없는 설문조사 답변 코드입니다."), + SURVEY_RESULT_NOT_FOUND(HttpStatus.NOT_FOUND, "설문 결과를 찾을 수 없습니다."), // 휴대폰 인증 (VERIFICATION) SMS_SEND_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "SMS 발송에 실패했습니다."), diff --git a/src/main/java/com/dduru/gildongmu/common/jwt/JwtTokenProvider.java b/src/main/java/com/dduru/gildongmu/common/jwt/JwtTokenProvider.java index 9263e0cd..9971b6cd 100644 --- a/src/main/java/com/dduru/gildongmu/common/jwt/JwtTokenProvider.java +++ b/src/main/java/com/dduru/gildongmu/common/jwt/JwtTokenProvider.java @@ -122,6 +122,50 @@ public String createVerificationToken(Long userId, String phoneNumber) { .compact(); } + /** + * Verification Token 검증 및 전화번호 추출 + * + * @param token verification token + * @param expectedPhoneNumber 검증할 전화번호 + * @return 검증 성공 여부 + */ + public boolean validateVerificationToken(String token, String expectedPhoneNumber) { + try { + Claims claims = getClaims(token); + String tokenType = claims.get("type", String.class); + if (!"verification".equals(tokenType)) { + log.warn("Verification Token이 아닙니다 - {}", tokenType); + return false; + } + + String phoneNumber = claims.get("phone_number", String.class); + if (phoneNumber == null || !phoneNumber.equals(expectedPhoneNumber)) { + log.warn("전화번호가 일치하지 않습니다. 토큰: {}, 요청: {}", phoneNumber, expectedPhoneNumber); + return false; + } + + return true; + } catch (ExpiredJwtException e) { + log.warn("Verification Token이 만료되었습니다"); + return false; + } catch (UnsupportedJwtException e) { + log.warn("JWT 형식이 틀렸습니다"); + return false; + } catch (MalformedJwtException e) { + log.warn("JWT 구조가 잘못되었습니다"); + return false; + } catch (SignatureException e) { + log.warn("JWT 서명 검증 실패하였습니다"); + return false; + } catch (IllegalArgumentException e) { + log.warn("부적절한 값이 들어왔습니다"); + return false; + } catch (JwtException e) { + log.warn("Verification Token이 유효하지 않습니다: {}", e.getMessage()); + return false; + } + } + private Claims getClaims(String token) { return Jwts.parser() .setSigningKey(jwtSecret) diff --git a/src/main/java/com/dduru/gildongmu/post/domain/Post.java b/src/main/java/com/dduru/gildongmu/post/domain/Post.java index d1285131..8db2c7e1 100644 --- a/src/main/java/com/dduru/gildongmu/post/domain/Post.java +++ b/src/main/java/com/dduru/gildongmu/post/domain/Post.java @@ -4,10 +4,15 @@ import com.dduru.gildongmu.destination.domain.Destination; import com.dduru.gildongmu.participation.domain.Participation; import com.dduru.gildongmu.post.enums.PostStatus; -import com.dduru.gildongmu.post.exception.*; +import com.dduru.gildongmu.post.exception.InvalidPostStatusException; +import com.dduru.gildongmu.post.exception.InvalidRecruitCapacityException; +import com.dduru.gildongmu.post.exception.RecruitCountBelowZeroException; +import com.dduru.gildongmu.post.exception.RecruitCountExceedCapacityException; +import com.dduru.gildongmu.post.exception.TravelAlreadyEndedException; +import com.dduru.gildongmu.post.exception.TravelAlreadyStartedException; +import com.dduru.gildongmu.profile.domain.enums.AgeRange; +import com.dduru.gildongmu.profile.domain.enums.Gender; import com.dduru.gildongmu.user.domain.User; -import com.dduru.gildongmu.user.enums.AgeRange; -import com.dduru.gildongmu.user.enums.Gender; import jakarta.persistence.*; import lombok.AccessLevel; import lombok.Builder; diff --git a/src/main/java/com/dduru/gildongmu/post/repository/PostRepositoryImpl.java b/src/main/java/com/dduru/gildongmu/post/repository/PostRepositoryImpl.java index a36d6cc8..744b57c1 100644 --- a/src/main/java/com/dduru/gildongmu/post/repository/PostRepositoryImpl.java +++ b/src/main/java/com/dduru/gildongmu/post/repository/PostRepositoryImpl.java @@ -1,11 +1,11 @@ package com.dduru.gildongmu.post.repository; -import com.dduru.gildongmu.user.enums.AgeRange; -import com.dduru.gildongmu.user.enums.Gender; import com.dduru.gildongmu.post.domain.Post; import com.dduru.gildongmu.post.dto.PostListRequest; import com.dduru.gildongmu.post.enums.PostStatus; -import com.dduru.gildongmu.user.exception.InvalidGenderException; +import com.dduru.gildongmu.profile.domain.enums.AgeRange; +import com.dduru.gildongmu.profile.domain.enums.Gender; +import com.dduru.gildongmu.profile.exception.InvalidGenderException; import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.jpa.impl.JPAQueryFactory; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/com/dduru/gildongmu/post/service/PostService.java b/src/main/java/com/dduru/gildongmu/post/service/PostService.java index d20bcdbb..9cd2bc81 100644 --- a/src/main/java/com/dduru/gildongmu/post/service/PostService.java +++ b/src/main/java/com/dduru/gildongmu/post/service/PostService.java @@ -14,9 +14,9 @@ import com.dduru.gildongmu.post.exception.InvalidPostDateException; import com.dduru.gildongmu.post.exception.PostAccessDeniedException; import com.dduru.gildongmu.post.repository.PostRepository; +import com.dduru.gildongmu.profile.domain.enums.AgeRange; +import com.dduru.gildongmu.profile.domain.enums.Gender; import com.dduru.gildongmu.user.domain.User; -import com.dduru.gildongmu.user.enums.AgeRange; -import com.dduru.gildongmu.user.enums.Gender; import com.dduru.gildongmu.user.repository.UserRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/src/main/java/com/dduru/gildongmu/profile/controller/ProfileApiDocs.java b/src/main/java/com/dduru/gildongmu/profile/controller/ProfileApiDocs.java new file mode 100644 index 00000000..95efa4cc --- /dev/null +++ b/src/main/java/com/dduru/gildongmu/profile/controller/ProfileApiDocs.java @@ -0,0 +1,111 @@ +package com.dduru.gildongmu.profile.controller; + +import com.dduru.gildongmu.common.annotation.ApiErrorResponses; +import com.dduru.gildongmu.common.dto.ApiResult; +import com.dduru.gildongmu.common.exception.ErrorCode; +import com.dduru.gildongmu.profile.dto.NicknameRandomResponse; +import com.dduru.gildongmu.profile.dto.NicknameUpdateRequest; +import com.dduru.gildongmu.profile.dto.NicknameValidateResponse; +import com.dduru.gildongmu.profile.dto.ProfileSetupRequest; +import com.dduru.gildongmu.profile.validator.ValidNickname; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; + +@Tag(name = "Profiles", description = "사용자 프로필 관리 API") +public interface ProfileApiDocs { + + @Operation(summary = "닉네임 수정", description = "사용자의 닉네임을 수정합니다.") + @ApiResponses({ + @ApiResponse( + responseCode = "204", + description = "닉네임 수정 성공", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ApiResult.class)) + ) + }) + @ApiErrorResponses({ + ErrorCode.NICKNAME_INVALID_LENGTH, + ErrorCode.NICKNAME_INVALID_CHARACTERS, + ErrorCode.NICKNAME_CONTAINS_BAD_WORD, + ErrorCode.UNAUTHORIZED, + ErrorCode.PROFILE_NOT_FOUND, + ErrorCode.NICKNAME_ALREADY_TAKEN + }) + ResponseEntity> updateNickname( + @Parameter(hidden = true) Long id, + @Valid NicknameUpdateRequest request + ); + + @Operation(summary = "닉네임 유효성 확인", description = "닉네임의 사용 가능 여부를 확인합니다. 닉네임 형식 검증 및 중복 여부를 체크합니다.") + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "닉네임 유효성 확인 성공 - 사용 가능한 닉네임임", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = NicknameValidateResponse.class)) + ) + }) + @ApiErrorResponses({ + ErrorCode.NICKNAME_INVALID_LENGTH, + ErrorCode.NICKNAME_INVALID_CHARACTERS, + ErrorCode.NICKNAME_CONTAINS_BAD_WORD, + ErrorCode.NICKNAME_ALREADY_TAKEN + }) + ResponseEntity> checkNickname( + @Parameter( + description = "유효성 체크할 닉네임", + example = "길동무" + ) + @ValidNickname String nickname + ); + + @Operation(summary = "랜덤 닉네임 생성", description = "랜덤으로 사용 가능한 닉네임을 생성합니다. 형용사와 명사 조합에 랜덤 숫자를 추가하여 고유성을 보장합니다.") + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "랜덤 닉네임 생성 성공", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = NicknameRandomResponse.class)) + ) + }) + @ApiErrorResponses({ErrorCode.UNAUTHORIZED}) + ResponseEntity> generateRandomNickname(); + + @Operation( + summary = "프로필 초기 설정", + description = "온보딩 과정에서 사용자의 프로필 정보를 초기 설정합니다. 닉네임, 성별, 전화번호, 생년월일을 저장하며, 비관적 잠금을 사용하여 닉네임 중복을 방지합니다." + ) + @ApiResponses({ + @ApiResponse( + responseCode = "204", + description = "프로필 초기 설정 성공", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ApiResult.class)) + ) + }) + @ApiErrorResponses({ + ErrorCode.INVALID_INPUT_VALUE, + ErrorCode.NICKNAME_INVALID_LENGTH, + ErrorCode.NICKNAME_INVALID_CHARACTERS, + ErrorCode.UNAUTHORIZED, + ErrorCode.INVALID_TOKEN, + ErrorCode.PROFILE_NOT_FOUND, + ErrorCode.NICKNAME_ALREADY_TAKEN, + ErrorCode.DUPLICATE_PHONE_NUMBER + }) + ResponseEntity> setupInitialProfile( + @Parameter(hidden = true) Long userId, + @Parameter( + description = "프로필 초기 설정 요청 정보", + required = true + ) + @Valid ProfileSetupRequest request + ); +} diff --git a/src/main/java/com/dduru/gildongmu/user/controller/UserController.java b/src/main/java/com/dduru/gildongmu/profile/controller/ProfileController.java similarity index 53% rename from src/main/java/com/dduru/gildongmu/user/controller/UserController.java rename to src/main/java/com/dduru/gildongmu/profile/controller/ProfileController.java index 1ffe97a5..2bada98e 100644 --- a/src/main/java/com/dduru/gildongmu/user/controller/UserController.java +++ b/src/main/java/com/dduru/gildongmu/profile/controller/ProfileController.java @@ -1,12 +1,13 @@ -package com.dduru.gildongmu.user.controller; +package com.dduru.gildongmu.profile.controller; import com.dduru.gildongmu.common.annotation.CurrentUser; import com.dduru.gildongmu.common.dto.ApiResult; -import com.dduru.gildongmu.user.dto.NicknameRandomResponse; -import com.dduru.gildongmu.user.dto.UserCheckNicknameResponse; -import com.dduru.gildongmu.user.dto.UserUpdateNicknameRequest; -import com.dduru.gildongmu.user.service.UserService; -import com.dduru.gildongmu.user.validator.ValidNickname; +import com.dduru.gildongmu.profile.dto.NicknameRandomResponse; +import com.dduru.gildongmu.profile.dto.NicknameValidateResponse; +import com.dduru.gildongmu.profile.dto.NicknameUpdateRequest; +import com.dduru.gildongmu.profile.dto.ProfileSetupRequest; +import com.dduru.gildongmu.profile.service.ProfileService; +import com.dduru.gildongmu.profile.validator.ValidNickname; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; @@ -23,33 +24,43 @@ @RequestMapping("/api/v1") @RequiredArgsConstructor @RestController -public class UserController implements UserApiDocs { +public class ProfileController implements ProfileApiDocs { - private final UserService userService; + private final ProfileService profileService; @Override @PutMapping("/users/nickname") public ResponseEntity> updateNickname( @CurrentUser Long id, - @Valid @RequestBody UserUpdateNicknameRequest request + @Valid @RequestBody NicknameUpdateRequest request ) { - userService.updateNickname(id, request); + profileService.updateNickname(id, request); return ResponseEntity.status(HttpStatus.NO_CONTENT).body(ApiResult.noContent()); } @Override @GetMapping("/nicknames/{nickname}/availability") - public ResponseEntity> checkNickname( + public ResponseEntity> checkNickname( @PathVariable @ValidNickname String nickname ) { - UserCheckNicknameResponse response = userService.checkNickname(nickname); + NicknameValidateResponse response = profileService.checkNickname(nickname); return ResponseEntity.ok(ApiResult.ok(response)); } @Override @GetMapping("/nicknames/random") public ResponseEntity> generateRandomNickname() { - NicknameRandomResponse response = userService.generateRandomNickname(); + NicknameRandomResponse response = profileService.generateRandomNickname(); return ResponseEntity.ok(ApiResult.ok(response)); } + + @Override + @PutMapping("/me/profile") + public ResponseEntity> setupInitialProfile( + @CurrentUser Long userId, + @RequestBody @Valid ProfileSetupRequest request + ) { + profileService.setupInitialProfile(userId, request); + return ResponseEntity.status(HttpStatus.NO_CONTENT).body(ApiResult.noContent()); + } } diff --git a/src/main/java/com/dduru/gildongmu/profile/domain/Profile.java b/src/main/java/com/dduru/gildongmu/profile/domain/Profile.java new file mode 100644 index 00000000..99c571d6 --- /dev/null +++ b/src/main/java/com/dduru/gildongmu/profile/domain/Profile.java @@ -0,0 +1,75 @@ +package com.dduru.gildongmu.profile.domain; + +import com.dduru.gildongmu.common.entity.BaseTimeEntity; +import com.dduru.gildongmu.profile.domain.enums.Gender; +import com.dduru.gildongmu.user.domain.User; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +@Entity +@Table(name = "profiles") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Profile extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false, unique = true) + private User user; + + @Column(length = 14, unique = true) + private String nickname; + + @Enumerated(EnumType.STRING) + private Gender gender; + + @Column(name = "phone_number", length = 20) + private String phoneNumber; + + @Column(name = "birthday") + private LocalDate birthday; + + @Column(name = "profile_image", length = 500) + private String profileImage; + + @Column(name = "self_introduction", length = 60) + private String selfIntroduction; + + @Builder + public Profile(User user, String nickname, Gender gender, String phoneNumber, LocalDate birthday, String profileImage, String selfIntroduction) { + this.user = user; + this.nickname = nickname; + this.gender = gender; + this.phoneNumber = phoneNumber; + this.birthday = birthday; + this.profileImage = profileImage; + this.selfIntroduction = selfIntroduction; + } + + public void updateNickname(String nickname) { + this.nickname = nickname; + } + + public void setupInitialProfile(String nickname, Gender gender, String phoneNumber, LocalDate birthday) { + if (nickname != null) { + this.nickname = nickname; + } + if (gender != null) { + this.gender = gender; + } + if (phoneNumber != null) { + this.phoneNumber = phoneNumber; + } + if (birthday != null) { + this.birthday = birthday; + } + } +} diff --git a/src/main/java/com/dduru/gildongmu/user/enums/AgeRange.java b/src/main/java/com/dduru/gildongmu/profile/domain/enums/AgeRange.java similarity index 93% rename from src/main/java/com/dduru/gildongmu/user/enums/AgeRange.java rename to src/main/java/com/dduru/gildongmu/profile/domain/enums/AgeRange.java index 761267e8..950497e0 100644 --- a/src/main/java/com/dduru/gildongmu/user/enums/AgeRange.java +++ b/src/main/java/com/dduru/gildongmu/profile/domain/enums/AgeRange.java @@ -1,4 +1,4 @@ -package com.dduru.gildongmu.user.enums; +package com.dduru.gildongmu.profile.domain.enums; import lombok.Getter; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/com/dduru/gildongmu/user/enums/Gender.java b/src/main/java/com/dduru/gildongmu/profile/domain/enums/Gender.java similarity index 83% rename from src/main/java/com/dduru/gildongmu/user/enums/Gender.java rename to src/main/java/com/dduru/gildongmu/profile/domain/enums/Gender.java index 7872ec88..9cb536bc 100644 --- a/src/main/java/com/dduru/gildongmu/user/enums/Gender.java +++ b/src/main/java/com/dduru/gildongmu/profile/domain/enums/Gender.java @@ -1,6 +1,6 @@ -package com.dduru.gildongmu.user.enums; +package com.dduru.gildongmu.profile.domain.enums; -import com.dduru.gildongmu.user.exception.InvalidGenderException; +import com.dduru.gildongmu.profile.exception.InvalidGenderException; import lombok.Getter; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/com/dduru/gildongmu/user/dto/NicknameRandomResponse.java b/src/main/java/com/dduru/gildongmu/profile/dto/NicknameRandomResponse.java similarity index 87% rename from src/main/java/com/dduru/gildongmu/user/dto/NicknameRandomResponse.java rename to src/main/java/com/dduru/gildongmu/profile/dto/NicknameRandomResponse.java index 7701e88f..4891b8fa 100644 --- a/src/main/java/com/dduru/gildongmu/user/dto/NicknameRandomResponse.java +++ b/src/main/java/com/dduru/gildongmu/profile/dto/NicknameRandomResponse.java @@ -1,4 +1,4 @@ -package com.dduru.gildongmu.user.dto; +package com.dduru.gildongmu.profile.dto; import lombok.Builder; diff --git a/src/main/java/com/dduru/gildongmu/user/dto/UserUpdateNicknameRequest.java b/src/main/java/com/dduru/gildongmu/profile/dto/NicknameUpdateRequest.java similarity index 66% rename from src/main/java/com/dduru/gildongmu/user/dto/UserUpdateNicknameRequest.java rename to src/main/java/com/dduru/gildongmu/profile/dto/NicknameUpdateRequest.java index da212368..21025f08 100644 --- a/src/main/java/com/dduru/gildongmu/user/dto/UserUpdateNicknameRequest.java +++ b/src/main/java/com/dduru/gildongmu/profile/dto/NicknameUpdateRequest.java @@ -1,9 +1,9 @@ -package com.dduru.gildongmu.user.dto; +package com.dduru.gildongmu.profile.dto; -import com.dduru.gildongmu.user.validator.ValidNickname; +import com.dduru.gildongmu.profile.validator.ValidNickname; import io.swagger.v3.oas.annotations.media.Schema; -public record UserUpdateNicknameRequest( +public record NicknameUpdateRequest( @Schema( description = "닉네임", example = "길동무", diff --git a/src/main/java/com/dduru/gildongmu/profile/dto/NicknameValidateResponse.java b/src/main/java/com/dduru/gildongmu/profile/dto/NicknameValidateResponse.java new file mode 100644 index 00000000..a9e63b11 --- /dev/null +++ b/src/main/java/com/dduru/gildongmu/profile/dto/NicknameValidateResponse.java @@ -0,0 +1,9 @@ +package com.dduru.gildongmu.profile.dto; + +import lombok.Builder; + +@Builder +public record NicknameValidateResponse( + String sanitizedNickname +) { +} diff --git a/src/main/java/com/dduru/gildongmu/profile/dto/ProfileSetupRequest.java b/src/main/java/com/dduru/gildongmu/profile/dto/ProfileSetupRequest.java new file mode 100644 index 00000000..b3c5a26f --- /dev/null +++ b/src/main/java/com/dduru/gildongmu/profile/dto/ProfileSetupRequest.java @@ -0,0 +1,34 @@ +package com.dduru.gildongmu.profile.dto; + +import com.dduru.gildongmu.profile.validator.ValidNickname; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + + +public record ProfileSetupRequest( + @Schema(description = "닉네임", example = "용감한여행자1234") + @ValidNickname + @NotBlank + String nickname, + + @Schema(description = "성별", example = "M", allowableValues = {"M", "F"}) + @NotBlank + @Pattern(regexp = "^(M|F)$", message = "성별 형식이 올바르지 않습니다.") + String gender, + + @Schema(description = "전화번호", example = "01012345678") + @NotBlank + @Pattern(regexp = "^010\\d{8}$", message = "전화번호는 010으로 시작하는 11자리 숫자여야 합니다.") + String phoneNumber, + + @Schema(description = "생년월일", example = "2000-01-01") + @NotBlank + @Pattern(regexp = "^\\d{4}-\\d{2}-\\d{2}$", message = "생년월일 형식이 올바르지 않습니다. (예: 2000-01-01)") + String birthday, + + @Schema(description = "전화번호 인증 토큰", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...") + @NotBlank + String verificationToken +) { +} diff --git a/src/main/java/com/dduru/gildongmu/user/exception/InvalidGenderException.java b/src/main/java/com/dduru/gildongmu/profile/exception/InvalidGenderException.java similarity index 92% rename from src/main/java/com/dduru/gildongmu/user/exception/InvalidGenderException.java rename to src/main/java/com/dduru/gildongmu/profile/exception/InvalidGenderException.java index 0258cc0c..1e73b86a 100644 --- a/src/main/java/com/dduru/gildongmu/user/exception/InvalidGenderException.java +++ b/src/main/java/com/dduru/gildongmu/profile/exception/InvalidGenderException.java @@ -1,4 +1,4 @@ -package com.dduru.gildongmu.user.exception; +package com.dduru.gildongmu.profile.exception; import com.dduru.gildongmu.common.exception.BusinessException; import com.dduru.gildongmu.common.exception.ErrorCode; diff --git a/src/main/java/com/dduru/gildongmu/profile/repository/ProfileRepository.java b/src/main/java/com/dduru/gildongmu/profile/repository/ProfileRepository.java new file mode 100644 index 00000000..3938ca10 --- /dev/null +++ b/src/main/java/com/dduru/gildongmu/profile/repository/ProfileRepository.java @@ -0,0 +1,28 @@ +package com.dduru.gildongmu.profile.repository; + +import com.dduru.gildongmu.profile.domain.Profile; +import com.dduru.gildongmu.user.domain.User; +import jakarta.persistence.LockModeType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface ProfileRepository extends JpaRepository { + Optional findByUser(User user); + + Optional findByUser_Id(Long userId); + + boolean existsByNickname(String nickname); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT COUNT(p) > 0 FROM Profile p WHERE p.nickname = :nickname") + boolean existsByNicknameWithLock(@Param("nickname") String nickname); + + boolean existsByPhoneNumber(String phoneNumber); +} + diff --git a/src/main/java/com/dduru/gildongmu/profile/service/ProfileService.java b/src/main/java/com/dduru/gildongmu/profile/service/ProfileService.java new file mode 100644 index 00000000..d71f83fe --- /dev/null +++ b/src/main/java/com/dduru/gildongmu/profile/service/ProfileService.java @@ -0,0 +1,152 @@ +package com.dduru.gildongmu.profile.service; + +import com.dduru.gildongmu.common.exception.BusinessException; +import com.dduru.gildongmu.common.exception.ErrorCode; +import com.dduru.gildongmu.common.jwt.JwtTokenProvider; +import com.dduru.gildongmu.profile.domain.Profile; +import com.dduru.gildongmu.profile.domain.enums.Gender; +import com.dduru.gildongmu.profile.dto.NicknameRandomResponse; +import com.dduru.gildongmu.profile.dto.NicknameUpdateRequest; +import com.dduru.gildongmu.profile.dto.NicknameValidateResponse; +import com.dduru.gildongmu.profile.dto.ProfileSetupRequest; +import com.dduru.gildongmu.profile.repository.ProfileRepository; +import com.dduru.gildongmu.profile.utils.NicknameGenerator; +import com.dduru.gildongmu.user.domain.User; +import com.dduru.gildongmu.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; + +@Slf4j +@RequiredArgsConstructor +@Service +public class ProfileService { + + private static final int NICKNAME_MAX_RETRY_ATTEMPTS = 10; + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + + private final NicknameGenerator nicknameGenerator; + private final JwtTokenProvider jwtTokenProvider; + + private final ProfileRepository profileRepository; + private final UserRepository userRepository; + + @Transactional + public void updateNickname(Long userId, NicknameUpdateRequest request) { + User user = userRepository.getByIdOrThrow(userId); + Profile profile = getProfileByUserId(user); + checkDuplicateNicknameWithLock(request.nickname()); + + profile.updateNickname(request.nickname()); + profileRepository.save(profile); + } + + @Transactional(readOnly = true) + public NicknameValidateResponse checkNickname(String nickname) { + checkDuplicateNickname(nickname); + + return NicknameValidateResponse.builder() + .sanitizedNickname(nickname) + .build(); + } + + /** + * 중복되지 않는 유니크한 닉네임을 생성합니다. + * 형용사 + 명사 조합에 랜덤 숫자를 추가하여 고유성을 보장합니다. + * DB에 중복이 있으면 새로운 랜덤 숫자로 재시도합니다. + * + * @return 유니크한 닉네임 (예: "용감한여행자1234") + */ + @Transactional(readOnly = true) + public NicknameRandomResponse generateRandomNickname() { + + for (int attempt = 0; attempt < NICKNAME_MAX_RETRY_ATTEMPTS; attempt++) { + String baseNickname = nicknameGenerator.generateBaseNickname(); + int randomNumber = nicknameGenerator.generateRandomNumber(); + String nickname = baseNickname + randomNumber; + + if (!profileRepository.existsByNickname(nickname)) { + log.info("랜덤 닉네임 생성: {}", nickname); + return NicknameRandomResponse.of(nickname); + } + + log.debug("해당 닉네임이 이미 존재합니다: {}, 새로운 숫자를 부여하겠습니다.", nickname); + } + + String fallbackNickname = "뚜비" + (System.currentTimeMillis() % 10000); + log.warn("유니크한 닉네임 생성에 {}회 실패하여 대체 닉네임 사용: {}", NICKNAME_MAX_RETRY_ATTEMPTS, fallbackNickname); + return NicknameRandomResponse.of(fallbackNickname); + } + + @Transactional + public void setupInitialProfile(Long userId, ProfileSetupRequest request) { + User user = userRepository.getByIdOrThrow(userId); + Profile profile = getProfileByUserId(user); + + // 비관적 잠금을 사용하여 닉네임 중복 체크 (race condition 방지) + checkDuplicateNicknameWithLock(request.nickname()); + + validateVerificationToken(request.verificationToken(), request.phoneNumber()); + + if (profileRepository.existsByPhoneNumber(request.phoneNumber())) { + throw new BusinessException(ErrorCode.DUPLICATE_PHONE_NUMBER); + } + + LocalDate birthday = parseBirthDate(request.birthday()); + + profile.setupInitialProfile( + request.nickname(), + Gender.valueOf(request.gender()), + request.phoneNumber(), + birthday + ); + + profileRepository.save(profile); + log.info("프로필 초기 설정 완료: userId={}, nickname={}", userId, request.nickname()); + } + + private LocalDate parseBirthDate(String birthDateString) { + if (birthDateString == null || birthDateString.trim().isEmpty()) { + return null; + } + + try { + return LocalDate.parse(birthDateString.trim(), DATE_FORMATTER); + } catch (DateTimeParseException e) { + log.warn("생년월일 파싱 실패: {}", birthDateString, e); + throw new BusinessException(ErrorCode.INVALID_INPUT_VALUE, "생년월일 형식이 올바르지 않습니다. (yyyy-MM-dd 형식)"); + } + } + + private void validateVerificationToken(String verificationToken, String phoneNumber) { + if (verificationToken == null || verificationToken.trim().isEmpty()) { + throw new BusinessException(ErrorCode.INVALID_TOKEN, "인증 토큰이 필요합니다."); + } + + if (!jwtTokenProvider.validateVerificationToken(verificationToken, phoneNumber)) { + throw new BusinessException(ErrorCode.INVALID_TOKEN, "유효하지 않거나 만료된 인증 토큰입니다."); + } + } + + private Profile getProfileByUserId(User user) { + return profileRepository.findByUser(user) + .orElseThrow(() -> new BusinessException(ErrorCode.PROFILE_NOT_FOUND)); + } + + private void checkDuplicateNickname(String nickname) { + if (profileRepository.existsByNickname(nickname)) { + throw new BusinessException(ErrorCode.NICKNAME_ALREADY_TAKEN); + } + } + + private void checkDuplicateNicknameWithLock(String nickname) { + if (profileRepository.existsByNicknameWithLock(nickname)) { + throw new BusinessException(ErrorCode.NICKNAME_ALREADY_TAKEN); + } + } +} diff --git a/src/main/java/com/dduru/gildongmu/user/utils/NicknameAdjectiveProvider.java b/src/main/java/com/dduru/gildongmu/profile/utils/NicknameAdjectiveProvider.java similarity index 97% rename from src/main/java/com/dduru/gildongmu/user/utils/NicknameAdjectiveProvider.java rename to src/main/java/com/dduru/gildongmu/profile/utils/NicknameAdjectiveProvider.java index 151cf559..5862af7d 100644 --- a/src/main/java/com/dduru/gildongmu/user/utils/NicknameAdjectiveProvider.java +++ b/src/main/java/com/dduru/gildongmu/profile/utils/NicknameAdjectiveProvider.java @@ -1,4 +1,4 @@ -package com.dduru.gildongmu.user.utils; +package com.dduru.gildongmu.profile.utils; import org.springframework.stereotype.Component; diff --git a/src/main/java/com/dduru/gildongmu/user/utils/NicknameGenerator.java b/src/main/java/com/dduru/gildongmu/profile/utils/NicknameGenerator.java similarity index 93% rename from src/main/java/com/dduru/gildongmu/user/utils/NicknameGenerator.java rename to src/main/java/com/dduru/gildongmu/profile/utils/NicknameGenerator.java index f2efdb6c..2b957388 100644 --- a/src/main/java/com/dduru/gildongmu/user/utils/NicknameGenerator.java +++ b/src/main/java/com/dduru/gildongmu/profile/utils/NicknameGenerator.java @@ -1,4 +1,4 @@ -package com.dduru.gildongmu.user.utils; +package com.dduru.gildongmu.profile.utils; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -36,6 +36,6 @@ public int generateRandomNumber() { public String generateBaseNickname() { String adjective = nicknameAdjectiveProvider.getRandomAdjective(); String noun = nicknameNounProvider.getRandomNoun(); - return adjective + " " + noun; + return adjective + noun; } } diff --git a/src/main/java/com/dduru/gildongmu/user/utils/NicknameNounProvider.java b/src/main/java/com/dduru/gildongmu/profile/utils/NicknameNounProvider.java similarity index 96% rename from src/main/java/com/dduru/gildongmu/user/utils/NicknameNounProvider.java rename to src/main/java/com/dduru/gildongmu/profile/utils/NicknameNounProvider.java index 6b4ae10d..0146b815 100644 --- a/src/main/java/com/dduru/gildongmu/user/utils/NicknameNounProvider.java +++ b/src/main/java/com/dduru/gildongmu/profile/utils/NicknameNounProvider.java @@ -1,4 +1,4 @@ -package com.dduru.gildongmu.user.utils; +package com.dduru.gildongmu.profile.utils; import org.springframework.stereotype.Component; diff --git a/src/main/java/com/dduru/gildongmu/user/validator/NicknameConstraintValidator.java b/src/main/java/com/dduru/gildongmu/profile/validator/NicknameConstraintValidator.java similarity index 96% rename from src/main/java/com/dduru/gildongmu/user/validator/NicknameConstraintValidator.java rename to src/main/java/com/dduru/gildongmu/profile/validator/NicknameConstraintValidator.java index 2cfe88dc..63a1d14d 100644 --- a/src/main/java/com/dduru/gildongmu/user/validator/NicknameConstraintValidator.java +++ b/src/main/java/com/dduru/gildongmu/profile/validator/NicknameConstraintValidator.java @@ -1,4 +1,4 @@ -package com.dduru.gildongmu.user.validator; +package com.dduru.gildongmu.profile.validator; import jakarta.validation.ConstraintValidator; import jakarta.validation.ConstraintValidatorContext; diff --git a/src/main/java/com/dduru/gildongmu/user/validator/ValidNickname.java b/src/main/java/com/dduru/gildongmu/profile/validator/ValidNickname.java similarity index 96% rename from src/main/java/com/dduru/gildongmu/user/validator/ValidNickname.java rename to src/main/java/com/dduru/gildongmu/profile/validator/ValidNickname.java index 4931233c..b0e2ee50 100644 --- a/src/main/java/com/dduru/gildongmu/user/validator/ValidNickname.java +++ b/src/main/java/com/dduru/gildongmu/profile/validator/ValidNickname.java @@ -1,4 +1,4 @@ -package com.dduru.gildongmu.user.validator; +package com.dduru.gildongmu.profile.validator; import jakarta.validation.Constraint; import jakarta.validation.Payload; diff --git a/src/main/java/com/dduru/gildongmu/survey/controller/SurveyApiDocs.java b/src/main/java/com/dduru/gildongmu/survey/controller/SurveyApiDocs.java new file mode 100644 index 00000000..ac362fc5 --- /dev/null +++ b/src/main/java/com/dduru/gildongmu/survey/controller/SurveyApiDocs.java @@ -0,0 +1,123 @@ +package com.dduru.gildongmu.survey.controller; + +import com.dduru.gildongmu.common.dto.ApiResult; +import com.dduru.gildongmu.common.exception.ErrorResponse; +import com.dduru.gildongmu.survey.dto.SurveyRequest; +import com.dduru.gildongmu.survey.dto.SurveyResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; + +@Tag(name = "Survey", description = "설문조사 API") +@SecurityRequirement(name = "JWT") +public interface SurveyApiDocs { + + @Operation(summary = "설문조사 제출", description = "11개 질문의 선택지를 제출하고 성향 점수 및 아바타를 매칭합니다.") + @ApiResponses({ + @ApiResponse( + responseCode = "201", + description = "설문 제출 성공", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = SurveyResponse.class) + ) + ), + @ApiResponse( + responseCode = "400", + description = "잘못된 요청 (유효하지 않은 답변 코드, 필수 질문 미응답, Q7 범위 초과 등)", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = { + @ExampleObject( + name = "InvalidAnswerCode", + value = """ + { + "status": 400, + "data": { + "errorCode": "INVALID_INPUT_VALUE", + "field": null, + "message": "Q1 이동수단에 대한 유효하지 않은 답변 코드입니다: 99" + } + } + """ + ), + @ExampleObject( + name = "ValidationError", + value = """ + { + "status": 400, + "data": { + "errorCode": "INVALID_INPUT_VALUE", + "field": "q1", + "message": "Q1 이동수단은 필수입니다." + } + } + """ + ), + @ExampleObject( + name = "InvalidQ7Size", + value = """ + { + "status": 400, + "data": { + "errorCode": "INVALID_INPUT_VALUE", + "field": "q7", + "message": "Q7 선호활동은 최대 3개까지 선택할 수 있습니다." + } + } + """ + ) + } + ) + ) + }) + ResponseEntity> submitSurvey( + @Parameter(hidden = true) Long userId, + @Valid SurveyRequest request + ); + + @Operation(summary = "내 설문 결과 조회", description = "현재 사용자의 설문 결과 및 매칭된 아바타를 조회합니다.") + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "조회 성공", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = SurveyResponse.class) + ) + ), + @ApiResponse( + responseCode = "404", + description = "설문 결과를 찾을 수 없음", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject( + name = "SurveyResultNotFound", + value = """ + { + "status": 404, + "data": { + "errorCode": "SURVEY_RESULT_NOT_FOUND", + "field": null, + "message": "설문 결과를 찾을 수 없습니다." + } + } + """ + ) + ) + ) + }) + ResponseEntity> getMySurveyResult( + @Parameter(hidden = true) Long userId + ); +} diff --git a/src/main/java/com/dduru/gildongmu/survey/controller/SurveyController.java b/src/main/java/com/dduru/gildongmu/survey/controller/SurveyController.java new file mode 100644 index 00000000..d6e93c54 --- /dev/null +++ b/src/main/java/com/dduru/gildongmu/survey/controller/SurveyController.java @@ -0,0 +1,39 @@ +package com.dduru.gildongmu.survey.controller; + +import com.dduru.gildongmu.common.annotation.CurrentUser; +import com.dduru.gildongmu.common.dto.ApiResult; +import com.dduru.gildongmu.survey.dto.SurveyRequest; +import com.dduru.gildongmu.survey.dto.SurveyResponse; +import com.dduru.gildongmu.survey.service.SurveyService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/surveys") +public class SurveyController implements SurveyApiDocs { + + private final SurveyService surveyService; + + @Override + @PostMapping + public ResponseEntity> submitSurvey( + @CurrentUser Long userId, + @RequestBody @Valid SurveyRequest request + ) { + SurveyResponse response = surveyService.submitSurvey(userId, request); + return ResponseEntity.status(HttpStatus.CREATED).body(ApiResult.ok(response)); + } + + @Override + @GetMapping("/me") + public ResponseEntity> getMySurveyResult( + @CurrentUser Long userId + ) { + SurveyResponse response = surveyService.getMySurveyResult(userId); + return ResponseEntity.status(HttpStatus.OK).body(ApiResult.ok(response)); + } +} diff --git a/src/main/java/com/dduru/gildongmu/survey/controller/TravelSurveyApiDocs.java b/src/main/java/com/dduru/gildongmu/survey/controller/TravelSurveyApiDocs.java deleted file mode 100644 index 5d156adf..00000000 --- a/src/main/java/com/dduru/gildongmu/survey/controller/TravelSurveyApiDocs.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.dduru.gildongmu.survey.controller; - -import com.dduru.gildongmu.common.dto.ApiResult; -import com.dduru.gildongmu.survey.dto.TravelSurveyRequest; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.responses.ApiResponses; -import io.swagger.v3.oas.annotations.security.SecurityRequirement; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import org.springframework.http.ResponseEntity; - -@Tag(name = "TravelSurveyApi", description = "여행 취향 테스트 API") -@SecurityRequirement(name = "JWT") -public interface TravelSurveyApiDocs { - - @Operation(summary = "여행 취향 설문 저장", description = "사용자의 여행 취향 설문을 저장합니다.") - @ApiResponses(value = { - @ApiResponse(responseCode = "201", description = "테스트 저장 성공"), - }) - ResponseEntity> submitTravelSurvey( - @Parameter(hidden = true) Long userId, - @Valid TravelSurveyRequest request - ); -} diff --git a/src/main/java/com/dduru/gildongmu/survey/controller/TravelSurveyController.java b/src/main/java/com/dduru/gildongmu/survey/controller/TravelSurveyController.java deleted file mode 100644 index 42457776..00000000 --- a/src/main/java/com/dduru/gildongmu/survey/controller/TravelSurveyController.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.dduru.gildongmu.survey.controller; - -import com.dduru.gildongmu.common.annotation.CurrentUser; -import com.dduru.gildongmu.common.dto.ApiResult; -import com.dduru.gildongmu.survey.dto.TravelSurveyRequest; -import com.dduru.gildongmu.survey.service.TravelSurveyService; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/v1/travel-preferences") -public class TravelSurveyController implements TravelSurveyApiDocs { - - private final TravelSurveyService travelSurveyService; - - @Override - @PostMapping - public ResponseEntity> submitTravelSurvey( - @CurrentUser Long userId, - @RequestBody @Valid TravelSurveyRequest request) { - travelSurveyService.create(userId, request); - return ResponseEntity.status(HttpStatus.CREATED).body(ApiResult.created(null)); - } -} diff --git a/src/main/java/com/dduru/gildongmu/survey/converter/SurveyConverter.java b/src/main/java/com/dduru/gildongmu/survey/converter/SurveyConverter.java new file mode 100644 index 00000000..03ab879e --- /dev/null +++ b/src/main/java/com/dduru/gildongmu/survey/converter/SurveyConverter.java @@ -0,0 +1,67 @@ +package com.dduru.gildongmu.survey.converter; + +import com.dduru.gildongmu.common.enums.CodedEnum; +import com.dduru.gildongmu.common.enums.EnumUtils; +import com.dduru.gildongmu.survey.domain.Survey; +import com.dduru.gildongmu.survey.domain.enums.*; +import com.dduru.gildongmu.survey.dto.SurveyRequest; +import com.dduru.gildongmu.survey.exception.InvalidSurveyAnswerCodeException; +import com.dduru.gildongmu.user.domain.User; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.stream.Collectors; + +@Component +public class SurveyConverter { + + public Survey toEntity(User user, SurveyRequest request) { + ParsedSurveyData parsed = parseRequest(request); + return Survey.createSurvey(user, parsed.q1(), parsed.q2(), parsed.q3(), parsed.q4(), parsed.q5(), + parsed.q6(), parsed.q7(), parsed.q8(), parsed.q9(), parsed.q10(), parsed.q11()); + } + + public ParsedSurveyData parseRequest(SurveyRequest request) { + Question1Transport q1 = toEnum(Question1Transport.class, request.q1(), "Q1 이동수단"); + Question2Waiting q2 = toEnum(Question2Waiting.class, request.q2(), "Q2 웨이팅"); + Question3Stay q3 = toEnum(Question3Stay.class, request.q3(), "Q3 숙소"); + Question4Wakeup q4 = toEnum(Question4Wakeup.class, request.q4(), "Q4 기상시간"); + Question5Expense q5 = toEnum(Question5Expense.class, request.q5(), "Q5 경비관리"); + Question6Spend q6 = toEnum(Question6Spend.class, request.q6(), "Q6 소비태도"); + List q7 = toEnumList(Question7Interest.class, request.q7(), "Q7 선호활동"); + Question8Planning q8 = toEnum(Question8Planning.class, request.q8(), "Q8 계획성"); + Question9Menu q9 = toEnum(Question9Menu.class, request.q9(), "Q9 낯선메뉴"); + Question10Companion q10 = toEnum(Question10Companion.class, request.q10(), "Q10 동행제안"); + Question11Photo q11 = toEnum(Question11Photo.class, request.q11(), "Q11 사진"); + + return new ParsedSurveyData(q1, q2, q3, q4, q5, q6, q7, q8, q9, q10, q11); + } + + private & CodedEnum> E toEnum( + Class enumClass, Integer code, String questionName) { + return EnumUtils.fromCode(enumClass, code) + .orElseThrow(() -> InvalidSurveyAnswerCodeException.of(questionName, code)); + } + + private & CodedEnum> List toEnumList( + Class enumClass, List codes, String questionName) { + return codes.stream() + .map(code -> toEnum(enumClass, code, questionName)) + .collect(Collectors.toList()); + } + + public record ParsedSurveyData( + Question1Transport q1, + Question2Waiting q2, + Question3Stay q3, + Question4Wakeup q4, + Question5Expense q5, + Question6Spend q6, + List q7, + Question8Planning q8, + Question9Menu q9, + Question10Companion q10, + Question11Photo q11 + ) { + } +} diff --git a/src/main/java/com/dduru/gildongmu/survey/converter/TravelSurveyConverter.java b/src/main/java/com/dduru/gildongmu/survey/converter/TravelSurveyConverter.java deleted file mode 100644 index 8613a182..00000000 --- a/src/main/java/com/dduru/gildongmu/survey/converter/TravelSurveyConverter.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.dduru.gildongmu.survey.converter; - -import com.dduru.gildongmu.survey.domain.TravelSurvey; -import com.dduru.gildongmu.survey.domain.enums.CaptureStyle; -import com.dduru.gildongmu.survey.domain.enums.ExpenseStyle; -import com.dduru.gildongmu.survey.domain.enums.MoveStyle; -import com.dduru.gildongmu.survey.domain.enums.PaceStyle; -import com.dduru.gildongmu.survey.domain.enums.PlanStyle; -import com.dduru.gildongmu.survey.domain.enums.SpendStyle; -import com.dduru.gildongmu.survey.domain.enums.StayStyle; -import com.dduru.gildongmu.survey.domain.enums.TastingStyle; -import com.dduru.gildongmu.survey.dto.TravelSurveyRequest; -import org.springframework.stereotype.Component; - -@Component -public class TravelSurveyConverter { - - public TravelSurvey toEntity(Long userId, TravelSurveyRequest request) { - return TravelSurvey.builder() - .userId(userId) - .planStyle(PlanStyle.valueOf(request.planStyle())) - .tastingStyle(TastingStyle.valueOf(request.tastingStyle())) - .stayStyle(StayStyle.valueOf(request.stayStyle())) - .expenseStyle(ExpenseStyle.valueOf(request.expenseStyle())) - .moveStyle(MoveStyle.valueOf(request.moveStyle())) - .spendStyle(SpendStyle.valueOf(request.spendStyle())) - .captureStyle(CaptureStyle.valueOf(request.captureStyle())) - .paceStyle(PaceStyle.valueOf(request.paceStyle())) - .build(); - } -} diff --git a/src/main/java/com/dduru/gildongmu/survey/converter/TravelSurveyInterestConverter.java b/src/main/java/com/dduru/gildongmu/survey/converter/TravelSurveyInterestConverter.java deleted file mode 100644 index 80eb3ebd..00000000 --- a/src/main/java/com/dduru/gildongmu/survey/converter/TravelSurveyInterestConverter.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.dduru.gildongmu.survey.converter; - -import com.dduru.gildongmu.survey.domain.TravelSurvey; -import com.dduru.gildongmu.survey.domain.TravelSurveyInterest; -import com.dduru.gildongmu.survey.domain.enums.Interest; -import org.springframework.stereotype.Component; - -import java.util.List; - -@Component -public class TravelSurveyInterestConverter { - - public List toEntity(TravelSurvey travelSurvey, List interests) { - return interests.stream() - .map(interest -> TravelSurveyInterest.create( - travelSurvey, - Interest.valueOf(interest) - )) - .toList(); - } -} diff --git a/src/main/java/com/dduru/gildongmu/survey/domain/Survey.java b/src/main/java/com/dduru/gildongmu/survey/domain/Survey.java new file mode 100644 index 00000000..6cc14f37 --- /dev/null +++ b/src/main/java/com/dduru/gildongmu/survey/domain/Survey.java @@ -0,0 +1,139 @@ +package com.dduru.gildongmu.survey.domain; + +import com.dduru.gildongmu.common.entity.BaseTimeEntity; +import com.dduru.gildongmu.user.domain.User; +import com.dduru.gildongmu.survey.domain.enums.*; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.List; + +@Entity +@Table(name = "surveys") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Survey extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false, unique = true) + private User user; + + @Enumerated(EnumType.STRING) + @Column(name = "q1_transport", nullable = false) + private Question1Transport q1Transport; + + @Enumerated(EnumType.STRING) + @Column(name = "q2_waiting", nullable = false) + private Question2Waiting q2Waiting; + + @Enumerated(EnumType.STRING) + @Column(name = "q3_stay", nullable = false) + private Question3Stay q3Stay; + + @Enumerated(EnumType.STRING) + @Column(name = "q4_wakeup", nullable = false) + private Question4Wakeup q4Wakeup; + + @Enumerated(EnumType.STRING) + @Column(name = "q5_expense", nullable = false) + private Question5Expense q5Expense; + + @Enumerated(EnumType.STRING) + @Column(name = "q6_spend", nullable = false) + private Question6Spend q6Spend; + + @ElementCollection(fetch = FetchType.LAZY) + @CollectionTable(name = "survey_interests", joinColumns = @JoinColumn(name = "survey_id")) + @Enumerated(EnumType.STRING) + @Column(name = "interest", nullable = false) + private List q7Interests; + + @Enumerated(EnumType.STRING) + @Column(name = "q8_planning", nullable = false) + private Question8Planning q8Planning; + + @Enumerated(EnumType.STRING) + @Column(name = "q9_menu", nullable = false) + private Question9Menu q9Menu; + + @Enumerated(EnumType.STRING) + @Column(name = "q10_companion", nullable = false) + private Question10Companion q10Companion; + + @Enumerated(EnumType.STRING) + @Column(name = "q11_photo", nullable = false) + private Question11Photo q11Photo; + + @Builder + public Survey(User user, Question1Transport q1Transport, Question2Waiting q2Waiting, + Question3Stay q3Stay, Question4Wakeup q4Wakeup, Question5Expense q5Expense, + Question6Spend q6Spend, List q7Interests, + Question8Planning q8Planning, Question9Menu q9Menu, + Question10Companion q10Companion, Question11Photo q11Photo) { + this.user = user; + this.q1Transport = q1Transport; + this.q2Waiting = q2Waiting; + this.q3Stay = q3Stay; + this.q4Wakeup = q4Wakeup; + this.q5Expense = q5Expense; + this.q6Spend = q6Spend; + this.q7Interests = q7Interests != null ? new ArrayList<>(q7Interests) : null; + this.q8Planning = q8Planning; + this.q9Menu = q9Menu; + this.q10Companion = q10Companion; + this.q11Photo = q11Photo; + } + + public static Survey createSurvey(User user, Question1Transport q1Transport, Question2Waiting q2Waiting, + Question3Stay q3Stay, Question4Wakeup q4Wakeup, Question5Expense q5Expense, + Question6Spend q6Spend, List q7Interests, + Question8Planning q8Planning, Question9Menu q9Menu, + Question10Companion q10Companion, Question11Photo q11Photo) { + return Survey.builder() + .user(user) + .q1Transport(q1Transport) + .q2Waiting(q2Waiting) + .q3Stay(q3Stay) + .q4Wakeup(q4Wakeup) + .q5Expense(q5Expense) + .q6Spend(q6Spend) + .q7Interests(q7Interests) + .q8Planning(q8Planning) + .q9Menu(q9Menu) + .q10Companion(q10Companion) + .q11Photo(q11Photo) + .build(); + } + + public void updateSurvey(Question1Transport q1Transport, Question2Waiting q2Waiting, Question3Stay q3Stay, + Question4Wakeup q4Wakeup, Question5Expense q5Expense, Question6Spend q6Spend, + List q7Interests, Question8Planning q8Planning, + Question9Menu q9Menu, Question10Companion q10Companion, Question11Photo q11Photo) { + this.q1Transport = q1Transport; + this.q2Waiting = q2Waiting; + this.q3Stay = q3Stay; + this.q4Wakeup = q4Wakeup; + this.q5Expense = q5Expense; + this.q6Spend = q6Spend; + if (this.q7Interests == null) { + this.q7Interests = q7Interests != null ? new ArrayList<>(q7Interests) : new ArrayList<>(); + } else { + this.q7Interests.clear(); + if (q7Interests != null) { + this.q7Interests.addAll(q7Interests); + } + } + this.q8Planning = q8Planning; + this.q9Menu = q9Menu; + this.q10Companion = q10Companion; + this.q11Photo = q11Photo; + } +} diff --git a/src/main/java/com/dduru/gildongmu/survey/domain/TravelSurvey.java b/src/main/java/com/dduru/gildongmu/survey/domain/TravelSurvey.java deleted file mode 100644 index 8a70fd88..00000000 --- a/src/main/java/com/dduru/gildongmu/survey/domain/TravelSurvey.java +++ /dev/null @@ -1,77 +0,0 @@ -package com.dduru.gildongmu.survey.domain; - -import com.dduru.gildongmu.common.entity.BaseTimeEntity; -import com.dduru.gildongmu.survey.domain.enums.CaptureStyle; -import com.dduru.gildongmu.survey.domain.enums.ExpenseStyle; -import com.dduru.gildongmu.survey.domain.enums.MoveStyle; -import com.dduru.gildongmu.survey.domain.enums.PaceStyle; -import com.dduru.gildongmu.survey.domain.enums.PlanStyle; -import com.dduru.gildongmu.survey.domain.enums.SpendStyle; -import com.dduru.gildongmu.survey.domain.enums.StayStyle; -import com.dduru.gildongmu.survey.domain.enums.TastingStyle; -import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.Table; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Entity -@Table(name = "travel_surveys") -@Getter -@Builder -@AllArgsConstructor -@NoArgsConstructor(access = AccessLevel.PROTECTED) -public class TravelSurvey extends BaseTimeEntity { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - private Long userId; - - @Enumerated(EnumType.STRING) - private PlanStyle planStyle; - - @Enumerated(EnumType.STRING) - private TastingStyle tastingStyle; - - @Enumerated(EnumType.STRING) - private StayStyle stayStyle; - - @Enumerated(EnumType.STRING) - private ExpenseStyle expenseStyle; - - @Enumerated(EnumType.STRING) - private MoveStyle moveStyle; - - @Enumerated(EnumType.STRING) - private SpendStyle spendStyle; - - @Enumerated(EnumType.STRING) - private CaptureStyle captureStyle; - - @Enumerated(EnumType.STRING) - private PaceStyle paceStyle; - - public void updateStyles( - PlanStyle planStyle, TastingStyle tastingStyle, - StayStyle stayStyle, ExpenseStyle expenseStyle, MoveStyle moveStyle, - SpendStyle spendStyle, CaptureStyle captureStyle, PaceStyle paceStyle - ) { - this.planStyle = planStyle; - this.tastingStyle = tastingStyle; - this.stayStyle = stayStyle; - this.expenseStyle = expenseStyle; - this.moveStyle = moveStyle; - this.spendStyle = spendStyle; - this.captureStyle = captureStyle; - this.paceStyle = paceStyle; - } -} diff --git a/src/main/java/com/dduru/gildongmu/survey/domain/TravelSurveyInterest.java b/src/main/java/com/dduru/gildongmu/survey/domain/TravelSurveyInterest.java deleted file mode 100644 index 0e1b3f51..00000000 --- a/src/main/java/com/dduru/gildongmu/survey/domain/TravelSurveyInterest.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.dduru.gildongmu.survey.domain; - -import com.dduru.gildongmu.common.entity.BaseTimeEntity; -import com.dduru.gildongmu.survey.domain.enums.Interest; -import jakarta.persistence.*; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Entity -@Table(name = "travel_survey_interests") -@Getter -@Builder -@AllArgsConstructor -@NoArgsConstructor(access = AccessLevel.PROTECTED) -public class TravelSurveyInterest extends BaseTimeEntity { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "travel_survey_id", nullable = false) - private TravelSurvey travelSurvey; - - @Enumerated(EnumType.STRING) - private Interest interest; - - public static TravelSurveyInterest create(TravelSurvey travelSurvey, Interest interest) { - return TravelSurveyInterest.builder() - .travelSurvey(travelSurvey) - .interest(interest) - .build(); - } -} diff --git a/src/main/java/com/dduru/gildongmu/survey/domain/TravelTendency.java b/src/main/java/com/dduru/gildongmu/survey/domain/TravelTendency.java new file mode 100644 index 00000000..b058d2c0 --- /dev/null +++ b/src/main/java/com/dduru/gildongmu/survey/domain/TravelTendency.java @@ -0,0 +1,72 @@ +package com.dduru.gildongmu.survey.domain; + +import com.dduru.gildongmu.common.entity.BaseTimeEntity; +import com.dduru.gildongmu.user.domain.User; +import com.dduru.gildongmu.survey.domain.enums.AvatarType; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; + +@Entity +@Table(name = "travel_tendencies") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class TravelTendency extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false, unique = true) + private User user; + + @Column(nullable = false, precision = 3, scale = 1) + private BigDecimal r; + + @Column(nullable = false, precision = 3, scale = 1) + private BigDecimal w; + + @Column(nullable = false, precision = 3, scale = 1) + private BigDecimal s; + + @Column(nullable = false, precision = 3, scale = 1) + private BigDecimal p; + + @Enumerated(EnumType.STRING) + @Column(name = "avatar_type", nullable = false) + private AvatarType avatarType; + + @Builder + public TravelTendency(User user, BigDecimal r, BigDecimal w, BigDecimal s, BigDecimal p, AvatarType avatarType) { + this.user = user; + this.r = r; + this.w = w; + this.s = s; + this.p = p; + this.avatarType = avatarType; + } + + public static TravelTendency create(User user, BigDecimal r, BigDecimal w, BigDecimal s, BigDecimal p, AvatarType avatarType) { + return TravelTendency.builder() + .user(user) + .r(r) + .w(w) + .s(s) + .p(p) + .avatarType(avatarType) + .build(); + } + + public void update(BigDecimal r, BigDecimal w, BigDecimal s, BigDecimal p, AvatarType avatarType) { + this.r = r; + this.w = w; + this.s = s; + this.p = p; + this.avatarType = avatarType; + } +} diff --git a/src/main/java/com/dduru/gildongmu/survey/domain/enums/AvatarType.java b/src/main/java/com/dduru/gildongmu/survey/domain/enums/AvatarType.java new file mode 100644 index 00000000..9c90fe24 --- /dev/null +++ b/src/main/java/com/dduru/gildongmu/survey/domain/enums/AvatarType.java @@ -0,0 +1,42 @@ +package com.dduru.gildongmu.survey.domain.enums; + +import com.dduru.gildongmu.common.enums.CodedEnum; +import lombok.AllArgsConstructor; + +@AllArgsConstructor +public enum AvatarType implements CodedEnum { + TTUR_POGUNI(1, "뚜르 포근이"), + TTUR_MOOD(2, "뚜르 무드"), + TTUR_MALLANGI(3, "뚜르 말랑이"), + TTUR_SWEET(4, "뚜르 스윗"), + TTUR_POPO(5, "뚜르 포포"), + TTUR_SPARKLE(6, "뚜르 스파클"), + TTUR_GLIMMING(7, "뚜르 글리밍"), + TTUR_PADO(8, "뚜르 파도"); + + private final int code; + private final String text; + + @Override + public int getCode() { + return code; + } + + @Override + public String getText() { + return text; + } + + public String getDescription() { + return switch (this) { + case TTUR_POGUNI -> "안정형 가성비 조용러 (내 페이스대로 차분히)"; + case TTUR_MOOD -> "안정형 플랙스 감성러 (분위기와 여유로운 힐링)"; + case TTUR_MALLANGI -> "안정형 가성비 배려러 (함께 가며 챙겨주는 다정함)"; + case TTUR_SWEET -> "안정형 플랙스 미식 힐링러 (다같이 맛있는 미식 힐링)"; + case TTUR_POPO -> "모험형 가성비 실속러 (알뜰하고 영리한 실속 탐험)"; + case TTUR_SPARKLE -> "모험형 플랙스 경험러 (즉흥적이고 화려한 경험)"; + case TTUR_GLIMMING -> "모험형 가성비 미식 모험러 (북적이는 로컬 맛집 탐방)"; + case TTUR_PADO -> "모험형 플랙스 인싸러 (에너지 넘치는 즉흥 끝판왕)"; + }; + } +} diff --git a/src/main/java/com/dduru/gildongmu/survey/domain/enums/CaptureStyle.java b/src/main/java/com/dduru/gildongmu/survey/domain/enums/CaptureStyle.java deleted file mode 100644 index a88d1019..00000000 --- a/src/main/java/com/dduru/gildongmu/survey/domain/enums/CaptureStyle.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.dduru.gildongmu.survey.domain.enums; - -import com.dduru.gildongmu.common.enums.CodedEnum; -import lombok.AllArgsConstructor; -import lombok.Getter; - -@Getter -@AllArgsConstructor -public enum CaptureStyle implements CodedEnum { - PHOTO(1, "사진으로 기록"), - EYES(2, "눈으로 담기"); - - private final int code; - private final String text; -} diff --git a/src/main/java/com/dduru/gildongmu/survey/domain/enums/ExpenseStyle.java b/src/main/java/com/dduru/gildongmu/survey/domain/enums/ExpenseStyle.java deleted file mode 100644 index 09c1b26e..00000000 --- a/src/main/java/com/dduru/gildongmu/survey/domain/enums/ExpenseStyle.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.dduru.gildongmu.survey.domain.enums; - -import com.dduru.gildongmu.common.enums.CodedEnum; -import lombok.AllArgsConstructor; -import lombok.Getter; - -@Getter -@AllArgsConstructor -public enum ExpenseStyle implements CodedEnum { - EACH_PAYS(1, "각자 계산"), - POOLED(2, "통장 관리"); - - private final int code; - private final String text; -} diff --git a/src/main/java/com/dduru/gildongmu/survey/domain/enums/MoveStyle.java b/src/main/java/com/dduru/gildongmu/survey/domain/enums/MoveStyle.java deleted file mode 100644 index 0cb4b8d3..00000000 --- a/src/main/java/com/dduru/gildongmu/survey/domain/enums/MoveStyle.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.dduru.gildongmu.survey.domain.enums; - -import com.dduru.gildongmu.common.enums.CodedEnum; -import lombok.AllArgsConstructor; -import lombok.Getter; - -@Getter -@AllArgsConstructor -public enum MoveStyle implements CodedEnum { - WALK_BUS(1, "걷기/대중교통"), - TAXI(2, "택시/렌터카"); - - private final int code; - private final String text; -} diff --git a/src/main/java/com/dduru/gildongmu/survey/domain/enums/PaceStyle.java b/src/main/java/com/dduru/gildongmu/survey/domain/enums/PaceStyle.java deleted file mode 100644 index 18bbbf5a..00000000 --- a/src/main/java/com/dduru/gildongmu/survey/domain/enums/PaceStyle.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.dduru.gildongmu.survey.domain.enums; - -import com.dduru.gildongmu.common.enums.CodedEnum; -import lombok.AllArgsConstructor; -import lombok.Getter; - -@Getter -@AllArgsConstructor -public enum PaceStyle implements CodedEnum { - EARLY_FULL(1, "아침 일찍 꽉 채우기"), - RELAXED(2, "여유롭게"); - - private final int code; - private final String text; -} diff --git a/src/main/java/com/dduru/gildongmu/survey/domain/enums/PlanStyle.java b/src/main/java/com/dduru/gildongmu/survey/domain/enums/PlanStyle.java deleted file mode 100644 index 499187d8..00000000 --- a/src/main/java/com/dduru/gildongmu/survey/domain/enums/PlanStyle.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.dduru.gildongmu.survey.domain.enums; - -import com.dduru.gildongmu.common.enums.CodedEnum; -import lombok.AllArgsConstructor; -import lombok.Getter; - -@Getter -@AllArgsConstructor -public enum PlanStyle implements CodedEnum { - PLANNER(1, "경로/맛집/시간까지"), - FREE(2, "무계획의 즐거움"); - - private final int code; - private final String text; -} diff --git a/src/main/java/com/dduru/gildongmu/survey/domain/enums/Question10Companion.java b/src/main/java/com/dduru/gildongmu/survey/domain/enums/Question10Companion.java new file mode 100644 index 00000000..5d178ed2 --- /dev/null +++ b/src/main/java/com/dduru/gildongmu/survey/domain/enums/Question10Companion.java @@ -0,0 +1,24 @@ +package com.dduru.gildongmu.survey.domain.enums; + +import com.dduru.gildongmu.common.enums.CodedEnum; +import lombok.AllArgsConstructor; + +@AllArgsConstructor +public enum Question10Companion implements CodedEnum { + WELCOME(1, "완전 환영"), + SITUATIONAL(2, "상황 봐서"), + US_ONLY(3, "우리끼리만"); + + private final int code; + private final String text; + + @Override + public int getCode() { + return code; + } + + @Override + public String getText() { + return text; + } +} diff --git a/src/main/java/com/dduru/gildongmu/survey/domain/enums/Question11Photo.java b/src/main/java/com/dduru/gildongmu/survey/domain/enums/Question11Photo.java new file mode 100644 index 00000000..5c9e2fd2 --- /dev/null +++ b/src/main/java/com/dduru/gildongmu/survey/domain/enums/Question11Photo.java @@ -0,0 +1,24 @@ +package com.dduru.gildongmu.survey.domain.enums; + +import com.dduru.gildongmu.common.enums.CodedEnum; +import lombok.AllArgsConstructor; + +@AllArgsConstructor +public enum Question11Photo implements CodedEnum { + LIFETIME_SHOT(1, "인생샷"), + MATCH_COMPANION(2, "동행자에 맞춤"), + EYES_ONLY(3, "눈으로 담기"); + + private final int code; + private final String text; + + @Override + public int getCode() { + return code; + } + + @Override + public String getText() { + return text; + } +} diff --git a/src/main/java/com/dduru/gildongmu/survey/domain/enums/Question1Transport.java b/src/main/java/com/dduru/gildongmu/survey/domain/enums/Question1Transport.java new file mode 100644 index 00000000..47b638c4 --- /dev/null +++ b/src/main/java/com/dduru/gildongmu/survey/domain/enums/Question1Transport.java @@ -0,0 +1,23 @@ +package com.dduru.gildongmu.survey.domain.enums; + +import com.dduru.gildongmu.common.enums.CodedEnum; +import lombok.AllArgsConstructor; + +@AllArgsConstructor +public enum Question1Transport implements CodedEnum { + WALK_BUS(1, "걷기/버스"), + TAXI(2, "택시"); + + private final int code; + private final String text; + + @Override + public int getCode() { + return code; + } + + @Override + public String getText() { + return text; + } +} diff --git a/src/main/java/com/dduru/gildongmu/survey/domain/enums/Question2Waiting.java b/src/main/java/com/dduru/gildongmu/survey/domain/enums/Question2Waiting.java new file mode 100644 index 00000000..1eddbfa7 --- /dev/null +++ b/src/main/java/com/dduru/gildongmu/survey/domain/enums/Question2Waiting.java @@ -0,0 +1,23 @@ +package com.dduru.gildongmu.survey.domain.enums; + +import com.dduru.gildongmu.common.enums.CodedEnum; +import lombok.AllArgsConstructor; + +@AllArgsConstructor +public enum Question2Waiting implements CodedEnum { + WAIT(1, "기다림"), + MOVE_ELSEWHERE(2, "다른 곳 이동"); + + private final int code; + private final String text; + + @Override + public int getCode() { + return code; + } + + @Override + public String getText() { + return text; + } +} diff --git a/src/main/java/com/dduru/gildongmu/survey/domain/enums/Question3Stay.java b/src/main/java/com/dduru/gildongmu/survey/domain/enums/Question3Stay.java new file mode 100644 index 00000000..f4e202c7 --- /dev/null +++ b/src/main/java/com/dduru/gildongmu/survey/domain/enums/Question3Stay.java @@ -0,0 +1,23 @@ +package com.dduru.gildongmu.survey.domain.enums; + +import com.dduru.gildongmu.common.enums.CodedEnum; +import lombok.AllArgsConstructor; + +@AllArgsConstructor +public enum Question3Stay implements CodedEnum { + HOTEL(1, "호텔/갖춰진 곳"), + JUST_SLEEP(2, "잠만 자면 OK"); + + private final int code; + private final String text; + + @Override + public int getCode() { + return code; + } + + @Override + public String getText() { + return text; + } +} diff --git a/src/main/java/com/dduru/gildongmu/survey/domain/enums/Question4Wakeup.java b/src/main/java/com/dduru/gildongmu/survey/domain/enums/Question4Wakeup.java new file mode 100644 index 00000000..c60c01a3 --- /dev/null +++ b/src/main/java/com/dduru/gildongmu/survey/domain/enums/Question4Wakeup.java @@ -0,0 +1,23 @@ +package com.dduru.gildongmu.survey.domain.enums; + +import com.dduru.gildongmu.common.enums.CodedEnum; +import lombok.AllArgsConstructor; + +@AllArgsConstructor +public enum Question4Wakeup implements CodedEnum { + EARLY(1, "일찍 기상"), + RELAXED(2, "느긋하게 기상"); + + private final int code; + private final String text; + + @Override + public int getCode() { + return code; + } + + @Override + public String getText() { + return text; + } +} diff --git a/src/main/java/com/dduru/gildongmu/survey/domain/enums/Question5Expense.java b/src/main/java/com/dduru/gildongmu/survey/domain/enums/Question5Expense.java new file mode 100644 index 00000000..44341501 --- /dev/null +++ b/src/main/java/com/dduru/gildongmu/survey/domain/enums/Question5Expense.java @@ -0,0 +1,23 @@ +package com.dduru.gildongmu.survey.domain.enums; + +import com.dduru.gildongmu.common.enums.CodedEnum; +import lombok.AllArgsConstructor; + +@AllArgsConstructor +public enum Question5Expense implements CodedEnum { + EACH_PAYS(1, "각자 결제"), + POOLED(2, "모아 쓰기"); + + private final int code; + private final String text; + + @Override + public int getCode() { + return code; + } + + @Override + public String getText() { + return text; + } +} diff --git a/src/main/java/com/dduru/gildongmu/survey/domain/enums/Question6Spend.java b/src/main/java/com/dduru/gildongmu/survey/domain/enums/Question6Spend.java new file mode 100644 index 00000000..366d4e58 --- /dev/null +++ b/src/main/java/com/dduru/gildongmu/survey/domain/enums/Question6Spend.java @@ -0,0 +1,23 @@ +package com.dduru.gildongmu.survey.domain.enums; + +import com.dduru.gildongmu.common.enums.CodedEnum; +import lombok.AllArgsConstructor; + +@AllArgsConstructor +public enum Question6Spend implements CodedEnum { + SPLURGE(1, "쓴다 (플랙스)"), + SAVE(2, "아낀다 (가성비)"); + + private final int code; + private final String text; + + @Override + public int getCode() { + return code; + } + + @Override + public String getText() { + return text; + } +} diff --git a/src/main/java/com/dduru/gildongmu/survey/domain/enums/Interest.java b/src/main/java/com/dduru/gildongmu/survey/domain/enums/Question7Interest.java similarity index 67% rename from src/main/java/com/dduru/gildongmu/survey/domain/enums/Interest.java rename to src/main/java/com/dduru/gildongmu/survey/domain/enums/Question7Interest.java index 744bf86a..2208d994 100644 --- a/src/main/java/com/dduru/gildongmu/survey/domain/enums/Interest.java +++ b/src/main/java/com/dduru/gildongmu/survey/domain/enums/Question7Interest.java @@ -2,15 +2,13 @@ import com.dduru.gildongmu.common.enums.CodedEnum; import lombok.AllArgsConstructor; -import lombok.Getter; -@Getter @AllArgsConstructor -public enum Interest implements CodedEnum { +public enum Question7Interest implements CodedEnum { SIGHTSEEING(1, "관광"), EXHIBITION(2, "관람"), NATURE(3, "자연 탐방"), - FOOD(4, "먹방"), + FOOD(4, "맛집 탐방"), SHOPPING(5, "쇼핑"), RESORT(6, "휴양"), ACTIVITY(7, "액티비티"), @@ -19,4 +17,14 @@ public enum Interest implements CodedEnum { private final int code; private final String text; + + @Override + public int getCode() { + return code; + } + + @Override + public String getText() { + return text; + } } diff --git a/src/main/java/com/dduru/gildongmu/survey/domain/enums/Question8Planning.java b/src/main/java/com/dduru/gildongmu/survey/domain/enums/Question8Planning.java new file mode 100644 index 00000000..6a569065 --- /dev/null +++ b/src/main/java/com/dduru/gildongmu/survey/domain/enums/Question8Planning.java @@ -0,0 +1,24 @@ +package com.dduru.gildongmu.survey.domain.enums; + +import com.dduru.gildongmu.common.enums.CodedEnum; +import lombok.AllArgsConstructor; + +@AllArgsConstructor +public enum Question8Planning implements CodedEnum { + DETAILED(1, "디테일한 계획"), + FLEXIBLE(2, "융통성 있는 계획"), + ON_SITE(3, "현장 결정"); + + private final int code; + private final String text; + + @Override + public int getCode() { + return code; + } + + @Override + public String getText() { + return text; + } +} diff --git a/src/main/java/com/dduru/gildongmu/survey/domain/enums/Question9Menu.java b/src/main/java/com/dduru/gildongmu/survey/domain/enums/Question9Menu.java new file mode 100644 index 00000000..17f61c1b --- /dev/null +++ b/src/main/java/com/dduru/gildongmu/survey/domain/enums/Question9Menu.java @@ -0,0 +1,24 @@ +package com.dduru.gildongmu.survey.domain.enums; + +import com.dduru.gildongmu.common.enums.CodedEnum; +import lombok.AllArgsConstructor; + +@AllArgsConstructor +public enum Question9Menu implements CodedEnum { + SAFE(1, "안전한 메뉴"), + CHECK_REVIEW(2, "후기 확인 후 결정"), + CHALLENGE(3, "바로 도전"); + + private final int code; + private final String text; + + @Override + public int getCode() { + return code; + } + + @Override + public String getText() { + return text; + } +} diff --git a/src/main/java/com/dduru/gildongmu/survey/domain/enums/SpendStyle.java b/src/main/java/com/dduru/gildongmu/survey/domain/enums/SpendStyle.java deleted file mode 100644 index a78933ec..00000000 --- a/src/main/java/com/dduru/gildongmu/survey/domain/enums/SpendStyle.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.dduru.gildongmu.survey.domain.enums; - -import com.dduru.gildongmu.common.enums.CodedEnum; -import lombok.AllArgsConstructor; -import lombok.Getter; - -@Getter -@AllArgsConstructor -public enum SpendStyle implements CodedEnum { - SPLURGE(1, "과감한 지출"), - SAVER(2, "알뜰한 소비"); - - private final int code; - private final String text; -} diff --git a/src/main/java/com/dduru/gildongmu/survey/domain/enums/StayStyle.java b/src/main/java/com/dduru/gildongmu/survey/domain/enums/StayStyle.java deleted file mode 100644 index abebeb79..00000000 --- a/src/main/java/com/dduru/gildongmu/survey/domain/enums/StayStyle.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.dduru.gildongmu.survey.domain.enums; - -import com.dduru.gildongmu.common.enums.CodedEnum; -import lombok.AllArgsConstructor; -import lombok.Getter; - -@Getter -@AllArgsConstructor -public enum StayStyle implements CodedEnum { - HOTEL(1, "호텔"), - JUST_SLEEP(2, "잠만 자기"); - - private final int code; - private final String text; -} diff --git a/src/main/java/com/dduru/gildongmu/survey/domain/enums/TastingStyle.java b/src/main/java/com/dduru/gildongmu/survey/domain/enums/TastingStyle.java deleted file mode 100644 index 781bce41..00000000 --- a/src/main/java/com/dduru/gildongmu/survey/domain/enums/TastingStyle.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.dduru.gildongmu.survey.domain.enums; - -import com.dduru.gildongmu.common.enums.CodedEnum; -import lombok.AllArgsConstructor; -import lombok.Getter; - -@Getter -@AllArgsConstructor -public enum TastingStyle implements CodedEnum { - WAIT(1, "기다려서라도"), - NEARBY(2, "주변에서 편하게"); - - private final int code; - private final String text; -} diff --git a/src/main/java/com/dduru/gildongmu/survey/dto/SurveyRequest.java b/src/main/java/com/dduru/gildongmu/survey/dto/SurveyRequest.java new file mode 100644 index 00000000..a34c2b3f --- /dev/null +++ b/src/main/java/com/dduru/gildongmu/survey/dto/SurveyRequest.java @@ -0,0 +1,43 @@ +package com.dduru.gildongmu.survey.dto; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.util.List; + +public record SurveyRequest( + @NotNull(message = "Q1 이동수단은 필수입니다.") + Integer q1, + + @NotNull(message = "Q2 웨이팅은 필수입니다.") + Integer q2, + + @NotNull(message = "Q3 숙소는 필수입니다.") + Integer q3, + + @NotNull(message = "Q4 기상시간은 필수입니다.") + Integer q4, + + @NotNull(message = "Q5 경비관리는 필수입니다.") + Integer q5, + + @NotNull(message = "Q6 소비태도는 필수입니다.") + Integer q6, + + @NotNull(message = "Q7 선호활동은 필수입니다.") + @Size(min = 1, max = 3, message = "Q7 선호활동은 최대 3개까지 선택할 수 있습니다.") + List q7, + + @NotNull(message = "Q8 계획성은 필수입니다.") + Integer q8, + + @NotNull(message = "Q9 낯선메뉴는 필수입니다.") + Integer q9, + + @NotNull(message = "Q10 동행제안은 필수입니다.") + Integer q10, + + @NotNull(message = "Q11 사진은 필수입니다.") + Integer q11 +) { +} diff --git a/src/main/java/com/dduru/gildongmu/survey/dto/SurveyResponse.java b/src/main/java/com/dduru/gildongmu/survey/dto/SurveyResponse.java new file mode 100644 index 00000000..0e7e807d --- /dev/null +++ b/src/main/java/com/dduru/gildongmu/survey/dto/SurveyResponse.java @@ -0,0 +1,60 @@ +package com.dduru.gildongmu.survey.dto; + +import com.dduru.gildongmu.survey.domain.TravelTendency; +import com.dduru.gildongmu.survey.domain.enums.AvatarType; +import com.dduru.gildongmu.survey.service.AvatarProfileProvider; + +import java.util.List; + +public record SurveyResponse( + Double r, + Double w, + Double s, + Double p, + Integer avatarCode, + String avatarType, + String avatarName, + String avatarDescription, + String personality, + String strength, + String tip, + List tags +) { + public static SurveyResponse from(TravelTendency travelTendency, AvatarProfileProvider avatarProfileProvider) { + AvatarType avatarType = travelTendency.getAvatarType(); + AvatarProfileProvider.AvatarProfile profile = avatarProfileProvider.getProfile(avatarType); + + return new SurveyResponse( + travelTendency.getR().doubleValue(), + travelTendency.getW().doubleValue(), + travelTendency.getS().doubleValue(), + travelTendency.getP().doubleValue(), + avatarType.getCode(), + avatarType.name(), + avatarType.getText(), + avatarType.getDescription(), + profile.personality(), + profile.strength(), + profile.tip(), + profile.tags() + ); + } + + public static SurveyResponse of( + double r, double w, double s, double p, + AvatarType avatarType, + AvatarProfileProvider.AvatarProfile profile + ) { + return new SurveyResponse( + r, w, s, p, + avatarType.getCode(), + avatarType.name(), + avatarType.getText(), + avatarType.getDescription(), + profile.personality(), + profile.strength(), + profile.tip(), + profile.tags() + ); + } +} diff --git a/src/main/java/com/dduru/gildongmu/survey/dto/TravelSurveyRequest.java b/src/main/java/com/dduru/gildongmu/survey/dto/TravelSurveyRequest.java deleted file mode 100644 index 353385c6..00000000 --- a/src/main/java/com/dduru/gildongmu/survey/dto/TravelSurveyRequest.java +++ /dev/null @@ -1,47 +0,0 @@ -package com.dduru.gildongmu.survey.dto; - -import com.dduru.gildongmu.survey.validation.ValidInterestList; -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.Pattern; - -import java.util.List; - -public record TravelSurveyRequest( - @NotBlank - @Pattern(regexp = "PLANNER|FREE", message = "유효하지 않은 계획 스타일입니다") - String planStyle, - - @NotBlank - @Pattern(regexp = "WAIT|NEARBY", message = "유효하지 않은 맛집 대기 스타일입니다") - String tastingStyle, - - @NotBlank - @Pattern(regexp = "HOTEL|JUST_SLEEP", message = "유효하지 않은 숙소 스타일입니다") - String stayStyle, - - @NotBlank - @Pattern(regexp = "EACH_PAYS|POOLED", message = "유효하지 않은 정산 스타일입니다") - String expenseStyle, - - @NotBlank - @Pattern(regexp = "WALK_BUS|TAXI", message = "유효하지 않은 이동 수단 스타일입니다") - String moveStyle, - - @NotBlank - @Pattern(regexp = "SPLURGE|SAVER", message = "유효하지 않은 소비 스타일입니다") - String spendStyle, - - @NotBlank - @Pattern(regexp = "PHOTO|EYES", message = "유효하지 않은 기록 스타일입니다") - String captureStyle, - - @NotBlank - @Pattern(regexp = "EARLY_FULL|RELAXED", message = "유효하지 않은 일정 템포 스타일입니다") - String paceStyle, - - @Schema(description = "관심사 리스트", example = "[\"SIGHTSEEING\", \"EXHIBITION\", \"NATURE\"]") - @ValidInterestList() - List interests -) { -} diff --git a/src/main/java/com/dduru/gildongmu/survey/exception/InvalidSurveyAnswerCodeException.java b/src/main/java/com/dduru/gildongmu/survey/exception/InvalidSurveyAnswerCodeException.java new file mode 100644 index 00000000..62dee5f9 --- /dev/null +++ b/src/main/java/com/dduru/gildongmu/survey/exception/InvalidSurveyAnswerCodeException.java @@ -0,0 +1,18 @@ +package com.dduru.gildongmu.survey.exception; + +import com.dduru.gildongmu.common.exception.BusinessException; +import com.dduru.gildongmu.common.exception.ErrorCode; + +public class InvalidSurveyAnswerCodeException extends BusinessException { + public InvalidSurveyAnswerCodeException() { + super(ErrorCode.INVALID_INPUT_VALUE, "유효하지 않은 설문 답변 코드입니다"); + } + + public InvalidSurveyAnswerCodeException(String message) { + super(ErrorCode.INVALID_INPUT_VALUE, message); + } + + public static InvalidSurveyAnswerCodeException of(String question, Integer code) { + return new InvalidSurveyAnswerCodeException(question + "에 대한 유효하지 않은 답변 코드입니다: " + code); + } +} diff --git a/src/main/java/com/dduru/gildongmu/survey/exception/SurveyResultNotFoundException.java b/src/main/java/com/dduru/gildongmu/survey/exception/SurveyResultNotFoundException.java new file mode 100644 index 00000000..c9dd645c --- /dev/null +++ b/src/main/java/com/dduru/gildongmu/survey/exception/SurveyResultNotFoundException.java @@ -0,0 +1,18 @@ +package com.dduru.gildongmu.survey.exception; + +import com.dduru.gildongmu.common.exception.BusinessException; +import com.dduru.gildongmu.common.exception.ErrorCode; + +public class SurveyResultNotFoundException extends BusinessException { + public SurveyResultNotFoundException() { + super(ErrorCode.SURVEY_RESULT_NOT_FOUND, "설문 결과를 찾을 수 없습니다"); + } + + public SurveyResultNotFoundException(String message) { + super(ErrorCode.SURVEY_RESULT_NOT_FOUND, message); + } + + public static SurveyResultNotFoundException of() { + return new SurveyResultNotFoundException(); + } +} diff --git a/src/main/java/com/dduru/gildongmu/survey/exception/UnknownSurveyAnswerException.java b/src/main/java/com/dduru/gildongmu/survey/exception/UnknownSurveyAnswerException.java deleted file mode 100644 index 732aaa5d..00000000 --- a/src/main/java/com/dduru/gildongmu/survey/exception/UnknownSurveyAnswerException.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.dduru.gildongmu.survey.exception; - -import com.dduru.gildongmu.common.exception.BusinessException; -import com.dduru.gildongmu.common.exception.ErrorCode; - -public class UnknownSurveyAnswerException extends BusinessException { - public UnknownSurveyAnswerException(Integer code) { - super(ErrorCode.UNKNOWN_SURVEY_ANSWER_CODE, "알 수 없는 설문조사 답변 코드입니다: " + code); - } -} diff --git a/src/main/java/com/dduru/gildongmu/survey/repository/SurveyRepository.java b/src/main/java/com/dduru/gildongmu/survey/repository/SurveyRepository.java new file mode 100644 index 00000000..21b4155b --- /dev/null +++ b/src/main/java/com/dduru/gildongmu/survey/repository/SurveyRepository.java @@ -0,0 +1,13 @@ +package com.dduru.gildongmu.survey.repository; + +import com.dduru.gildongmu.survey.domain.Survey; +import com.dduru.gildongmu.user.domain.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface SurveyRepository extends JpaRepository { + Optional findByUser(User user); +} diff --git a/src/main/java/com/dduru/gildongmu/survey/repository/TravelSurveyInterestRepository.java b/src/main/java/com/dduru/gildongmu/survey/repository/TravelSurveyInterestRepository.java deleted file mode 100644 index a9736718..00000000 --- a/src/main/java/com/dduru/gildongmu/survey/repository/TravelSurveyInterestRepository.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.dduru.gildongmu.survey.repository; - -import com.dduru.gildongmu.survey.domain.TravelSurvey; -import com.dduru.gildongmu.survey.domain.TravelSurveyInterest; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Modifying; -import org.springframework.data.jpa.repository.Query; - -public interface TravelSurveyInterestRepository extends JpaRepository { - @Modifying(clearAutomatically = true) - @Query("DELETE FROM TravelSurveyInterest i WHERE i.travelSurvey = :travelSurvey") - void deleteAllByTravelSurvey(TravelSurvey travelSurvey); -} diff --git a/src/main/java/com/dduru/gildongmu/survey/repository/TravelSurveyRepository.java b/src/main/java/com/dduru/gildongmu/survey/repository/TravelSurveyRepository.java deleted file mode 100644 index 65fa8dfb..00000000 --- a/src/main/java/com/dduru/gildongmu/survey/repository/TravelSurveyRepository.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.dduru.gildongmu.survey.repository; - -import com.dduru.gildongmu.survey.domain.TravelSurvey; -import org.springframework.data.jpa.repository.JpaRepository; - -import java.util.Optional; - -public interface TravelSurveyRepository extends JpaRepository { - Optional findByUserId(Long userId); -} diff --git a/src/main/java/com/dduru/gildongmu/survey/repository/TravelTendencyRepository.java b/src/main/java/com/dduru/gildongmu/survey/repository/TravelTendencyRepository.java new file mode 100644 index 00000000..e5dea930 --- /dev/null +++ b/src/main/java/com/dduru/gildongmu/survey/repository/TravelTendencyRepository.java @@ -0,0 +1,13 @@ +package com.dduru.gildongmu.survey.repository; + +import com.dduru.gildongmu.survey.domain.TravelTendency; +import com.dduru.gildongmu.user.domain.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface TravelTendencyRepository extends JpaRepository { + Optional findByUser(User user); +} diff --git a/src/main/java/com/dduru/gildongmu/survey/service/AvatarMatcher.java b/src/main/java/com/dduru/gildongmu/survey/service/AvatarMatcher.java new file mode 100644 index 00000000..c1f38184 --- /dev/null +++ b/src/main/java/com/dduru/gildongmu/survey/service/AvatarMatcher.java @@ -0,0 +1,36 @@ +package com.dduru.gildongmu.survey.service; + +import com.dduru.gildongmu.survey.domain.enums.AvatarType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class AvatarMatcher { + + private static final double THRESHOLD = 5.0; + + public AvatarType match(double r, double w, double s) { + boolean isStable = r < THRESHOLD; + boolean isCostEffective = w < THRESHOLD; + boolean isSocial = s >= THRESHOLD; + + if (isStable && !isSocial && isCostEffective) { + return AvatarType.TTUR_POGUNI; + } else if (isStable && !isSocial) { + return AvatarType.TTUR_MOOD; + } else if (isStable && isCostEffective) { + return AvatarType.TTUR_MALLANGI; + } else if (isStable) { + return AvatarType.TTUR_SWEET; + } else if (!isSocial && isCostEffective) { + return AvatarType.TTUR_POPO; + } else if (!isSocial) { + return AvatarType.TTUR_SPARKLE; + } else if (isCostEffective) { + return AvatarType.TTUR_GLIMMING; + } else { + return AvatarType.TTUR_PADO; + } + } +} diff --git a/src/main/java/com/dduru/gildongmu/survey/service/AvatarProfileProvider.java b/src/main/java/com/dduru/gildongmu/survey/service/AvatarProfileProvider.java new file mode 100644 index 00000000..09d0d755 --- /dev/null +++ b/src/main/java/com/dduru/gildongmu/survey/service/AvatarProfileProvider.java @@ -0,0 +1,69 @@ +package com.dduru.gildongmu.survey.service; + +import com.dduru.gildongmu.survey.domain.enums.AvatarType; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; + +@Component +public class AvatarProfileProvider { + + private static final Map PROFILES = Map.of( + AvatarType.TTUR_POGUNI, new AvatarProfile( + "느긋하고 신중한 스타일. 계획은 최소한으로 세우되 자신의 리듬은 확실히 지킴.", + "조용한 명소 찾기, 가성비 좋은 맛집 발굴, 일정 중 여유 시간 확보.", + "일정에 자유시간을 조금만 넣어도 만족도가 올라가요.", + List.of("안정", "독립", "가성비") + ), + AvatarType.TTUR_MOOD, new AvatarProfile( + "카페, 전경, 감성 스팟을 선호하며 여행의 '분위기'를 가장 중요하게 생각함.", + "예쁜 코스 및 뷰 포인트 찾기, 사진 찍기 좋은 장소 안내.", + "기록에 집중하느라 현재를 놓치지 않도록 '5분 기록, 5분 즐기기' 규칙을 세워보세요.", + List.of("안정", "독립", "플랙스") + ), + AvatarType.TTUR_MALLANGI, new AvatarProfile( + "여행 중 동행자의 분위기를 살피고 잘 맞춰주는 따뜻한 스타일. 검소한 여행 선호.", + "인원 조율 및 감정 관리, 합리적인 식당 및 코스 추천.", + "너무 배려만 하지 말고 '하루 한 번은 내 선택!' 규칙을 실천해 보세요.", + List.of("안정", "사교", "가성비") + ), + AvatarType.TTUR_SWEET, new AvatarProfile( + "사람을 편안하게 해주며, 맛있는 것과 좋은 분위기만 있다면 행복한 미식 힐링러.", + "단체 일정 코디, 실패 없는 맛집 픽, 여행 중 쉼표 만들어주기.", + "활동적인 친구와 여행할 때는 '식사 후 자유시간'으로 완급을 조절하세요.", + List.of("안정", "사교", "플랙스") + ), + AvatarType.TTUR_POPO, new AvatarProfile( + "새로움을 추구하지만 위험은 피하는 실속파. 효율과 재미의 밸런스를 잘 잡음.", + "정보 탐색 및 길 찾기 능력 우수, 혼자서도 뛰어난 문제 해결 능력.", + "하루 한 번 정도는 예산을 신경 쓰지 않는 '플랙스 타임'을 가져보세요.", + List.of("모험", "독립", "가성비") + ), + AvatarType.TTUR_SPARKLE, new AvatarProfile( + "즉흥적으로 떠나는 것을 즐기며 비용보다 긍정적인 경험의 가치를 높게 평가함.", + "숨겨진 스팟 개척, 급격한 일정 변경에도 빠른 적응.", + "예산 관리가 어려울 수 있으니 알뜰한 친구와 일정을 미리 의논해 보세요.", + List.of("모험", "독립", "플랙스") + ), + AvatarType.TTUR_GLIMMING, new AvatarProfile( + "새로운 자극과 인생샷에 진심인 미식 모험가. 즉흥적인 계획도 긍정적으로 수용.", + "현지인 추천 코스 실행력, 인생샷 스팟 캐치, 북적이는 에너지 유지.", + "일정이 사람 중심으로 흐를 수 있으니 하루 30분은 온전한 개인 시간을 가져보세요.", + List.of("모험", "사교", "가성비") + ), + AvatarType.TTUR_PADO, new AvatarProfile( + "어디서든 사람들과 잘 어울리며 분위기를 살리는 흥부자. 지출에 쿨한 인싸 유형.", + "현지 액티비티 빠른 예약, SNS 기록 담당, 높은 여행 에너지 유지.", + "체력과 예산 소모가 클 수 있으니 하루 1~2개의 핵심 활동에 집중해 보세요.", + List.of("모험", "사교", "플랙스") + ) + ); + + public AvatarProfile getProfile(AvatarType avatarType) { + return PROFILES.get(avatarType); + } + + public record AvatarProfile(String personality, String strength, String tip, List tags) { + } +} diff --git a/src/main/java/com/dduru/gildongmu/survey/service/SurveyService.java b/src/main/java/com/dduru/gildongmu/survey/service/SurveyService.java new file mode 100644 index 00000000..d89ad23a --- /dev/null +++ b/src/main/java/com/dduru/gildongmu/survey/service/SurveyService.java @@ -0,0 +1,96 @@ +package com.dduru.gildongmu.survey.service; + +import com.dduru.gildongmu.survey.converter.SurveyConverter; +import com.dduru.gildongmu.survey.domain.Survey; +import com.dduru.gildongmu.survey.domain.TravelTendency; +import com.dduru.gildongmu.survey.domain.enums.AvatarType; +import com.dduru.gildongmu.survey.dto.SurveyRequest; +import com.dduru.gildongmu.survey.dto.SurveyResponse; +import com.dduru.gildongmu.survey.exception.SurveyResultNotFoundException; +import com.dduru.gildongmu.survey.repository.SurveyRepository; +import com.dduru.gildongmu.survey.repository.TravelTendencyRepository; +import com.dduru.gildongmu.user.domain.User; +import com.dduru.gildongmu.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.math.RoundingMode; + +@Slf4j +@Service +@Transactional +@RequiredArgsConstructor +public class SurveyService { + + private final SurveyRepository surveyRepository; + private final TravelTendencyRepository travelTendencyRepository; + private final SurveyConverter surveyConverter; + private final TravelTendencyCalculator tendencyCalculator; + private final AvatarMatcher avatarMatcher; + private final AvatarProfileProvider avatarProfileProvider; + private final UserRepository userRepository; + + public SurveyResponse submitSurvey(Long userId, SurveyRequest request) { + log.debug("설문조사 제출 시작 - userId: {}", userId); + + User user = userRepository.getByIdOrThrow(userId); + Survey survey = saveOrUpdateSurvey(user, request); + + TravelTendencyCalculator.TendencyScores scores = tendencyCalculator.calculate(survey); + AvatarType avatarType = avatarMatcher.match(scores.r(), scores.w(), scores.s()); + + saveOrUpdateTravelTendency(user, scores, avatarType); + + AvatarProfileProvider.AvatarProfile profile = avatarProfileProvider.getProfile(avatarType); + + log.info("설문조사 제출 완료 - userId: {}, avatarType: {}, 점수: R={}, W={}, S={}, P={}", + userId, avatarType, scores.r(), scores.w(), scores.s(), scores.p()); + return SurveyResponse.of(scores.r(), scores.w(), scores.s(), scores.p(), avatarType, profile); + } + + @Transactional(readOnly = true) + public SurveyResponse getMySurveyResult(Long userId) { + log.debug("설문 결과 조회 시작 - userId: {}", userId); + + User user = userRepository.getByIdOrThrow(userId); + TravelTendency travelTendency = travelTendencyRepository.findByUser(user) + .orElseThrow(SurveyResultNotFoundException::of); + + log.info("설문 결과 조회 완료 - userId: {}, avatarType: {}", userId, travelTendency.getAvatarType()); + return SurveyResponse.from(travelTendency, avatarProfileProvider); + } + + private Survey saveOrUpdateSurvey(User user, SurveyRequest request) { + SurveyConverter.ParsedSurveyData parsed = surveyConverter.parseRequest(request); + + return surveyRepository.findByUser(user) + .map(existing -> { + existing.updateSurvey(parsed.q1(), parsed.q2(), parsed.q3(), parsed.q4(), parsed.q5(), + parsed.q6(), parsed.q7(), parsed.q8(), parsed.q9(), parsed.q10(), parsed.q11()); + return existing; + }) + .orElseGet(() -> surveyRepository.save(surveyConverter.toEntity(user, request))); + } + + private void saveOrUpdateTravelTendency(User user, TravelTendencyCalculator.TendencyScores scores, AvatarType avatarType) { + BigDecimal rDecimal = toBigDecimal(scores.r()); + BigDecimal wDecimal = toBigDecimal(scores.w()); + BigDecimal sDecimal = toBigDecimal(scores.s()); + BigDecimal pDecimal = toBigDecimal(scores.p()); + + travelTendencyRepository.findByUser(user) + .ifPresentOrElse( + existing -> existing.update(rDecimal, wDecimal, sDecimal, pDecimal, avatarType), + () -> travelTendencyRepository.save( + TravelTendency.create(user, rDecimal, wDecimal, sDecimal, pDecimal, avatarType) + ) + ); + } + + private BigDecimal toBigDecimal(double value) { + return BigDecimal.valueOf(value).setScale(1, RoundingMode.HALF_UP); + } +} diff --git a/src/main/java/com/dduru/gildongmu/survey/service/TravelSurveyService.java b/src/main/java/com/dduru/gildongmu/survey/service/TravelSurveyService.java deleted file mode 100644 index 93293ad2..00000000 --- a/src/main/java/com/dduru/gildongmu/survey/service/TravelSurveyService.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.dduru.gildongmu.survey.service; - -import com.dduru.gildongmu.survey.converter.TravelSurveyConverter; -import com.dduru.gildongmu.survey.converter.TravelSurveyInterestConverter; -import com.dduru.gildongmu.survey.domain.TravelSurvey; -import com.dduru.gildongmu.survey.domain.TravelSurveyInterest; -import com.dduru.gildongmu.survey.dto.TravelSurveyRequest; -import com.dduru.gildongmu.survey.repository.TravelSurveyInterestRepository; -import com.dduru.gildongmu.survey.repository.TravelSurveyRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.List; - -@Service -@Transactional -@RequiredArgsConstructor -public class TravelSurveyService { - - private final TravelSurveyRepository travelSurveyRepository; - private final TravelSurveyInterestRepository travelSurveyInterestRepository; - - private final TravelSurveyConverter travelSurveyConverter; - private final TravelSurveyInterestConverter travelSurveyInterestConverter; - - public void create(Long userId, TravelSurveyRequest request) { - TravelSurvey surveyRequest = travelSurveyConverter.toEntity(userId, request); - TravelSurvey travelSurvey = travelSurveyRepository.findByUserId(userId) - .map(existingSurvey -> { - existingSurvey.updateStyles( - surveyRequest.getPlanStyle(), - surveyRequest.getTastingStyle(), - surveyRequest.getStayStyle(), - surveyRequest.getExpenseStyle(), - surveyRequest.getMoveStyle(), - surveyRequest.getSpendStyle(), - surveyRequest.getCaptureStyle(), - surveyRequest.getPaceStyle() - ); - travelSurveyInterestRepository.deleteAllByTravelSurvey(existingSurvey); - travelSurveyRepository.save(existingSurvey); - return existingSurvey; - }) - .orElseGet(() -> travelSurveyRepository.save(surveyRequest)); - - List interests = travelSurveyInterestConverter.toEntity(travelSurvey, request.interests()); - travelSurveyInterestRepository.saveAll(interests); - } -} diff --git a/src/main/java/com/dduru/gildongmu/survey/service/TravelTendencyCalculator.java b/src/main/java/com/dduru/gildongmu/survey/service/TravelTendencyCalculator.java new file mode 100644 index 00000000..9ede699f --- /dev/null +++ b/src/main/java/com/dduru/gildongmu/survey/service/TravelTendencyCalculator.java @@ -0,0 +1,164 @@ +package com.dduru.gildongmu.survey.service; + +import com.dduru.gildongmu.survey.domain.Survey; +import com.dduru.gildongmu.survey.domain.enums.*; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +@RequiredArgsConstructor +public class TravelTendencyCalculator { + + private static final double INITIAL_SCORE = 5.0; + private static final double MIN_SCORE = 0.0; + private static final double MAX_SCORE = 10.0; + + public TendencyScores calculate(Survey survey) { + double r = INITIAL_SCORE; // 여행 리듬 + double w = INITIAL_SCORE; // 지갑 성향 + double s = INITIAL_SCORE; // 동행 스타일 + double p = INITIAL_SCORE; // 활동 에너지 + + // Q1. 이동수단 + if (survey.getQ1Transport() == Question1Transport.WALK_BUS) { + w -= 1.0; + p += 1.0; + } else if (survey.getQ1Transport() == Question1Transport.TAXI) { + w += 1.0; + p -= 1.0; + } + + // Q2. 웨이팅 + if (survey.getQ2Waiting() == Question2Waiting.WAIT) { + r += 1.0; + p += 1.0; + } else if (survey.getQ2Waiting() == Question2Waiting.MOVE_ELSEWHERE) { + r -= 1.0; + p -= 1.0; + } + + // Q3. 숙소 + if (survey.getQ3Stay() == Question3Stay.HOTEL) { + r -= 1.0; + w += 1.5; + } else if (survey.getQ3Stay() == Question3Stay.JUST_SLEEP) { + r += 1.0; + w -= 1.5; + } + + // Q4. 기상시간 + if (survey.getQ4Wakeup() == Question4Wakeup.EARLY) { + p += 2.0; + } else if (survey.getQ4Wakeup() == Question4Wakeup.RELAXED) { + p -= 2.0; + } + + // Q5. 경비관리 + if (survey.getQ5Expense() == Question5Expense.EACH_PAYS) { + w -= 1.0; + s -= 0.5; + } else if (survey.getQ5Expense() == Question5Expense.POOLED) { + w += 1.0; + s += 0.5; + } + + // Q6. 소비태도 + if (survey.getQ6Spend() == Question6Spend.SPLURGE) { + w += 2.0; + } else if (survey.getQ6Spend() == Question6Spend.SAVE) { + w -= 2.0; + } + + // Q7. 선호활동 (택3 누적합산) + List interests = survey.getQ7Interests(); + if (interests != null) { + for (Question7Interest interest : interests) { + switch (interest) { + case SIGHTSEEING: + p += 0.5; + break; + case EXHIBITION: + p -= 0.5; + break; + case NATURE: + p -= 0.5; + break; + case FOOD: + w += 0.5; + break; + case SHOPPING: + w += 1.0; + break; + case RESORT: + p -= 1.0; + break; + case ACTIVITY: + r += 1.0; + p += 0.5; + break; + case THEME_PARK: + r += 0.5; + p += 0.5; + break; + case FESTIVAL: + r += 1.0; + p += 1.0; + break; + } + } + } + + // Q8. 계획성 + if (survey.getQ8Planning() == Question8Planning.DETAILED) { + r -= 2.0; + } else if (survey.getQ8Planning() == Question8Planning.FLEXIBLE) { + } else if (survey.getQ8Planning() == Question8Planning.ON_SITE) { + r += 2.0; + } + + // Q9. 낯선메뉴 + if (survey.getQ9Menu() == Question9Menu.SAFE) { + r -= 1.5; + } else if (survey.getQ9Menu() == Question9Menu.CHECK_REVIEW) { + r -= 0.5; + } else if (survey.getQ9Menu() == Question9Menu.CHALLENGE) { + r += 1.5; + } + + // Q10. 동행제안 + if (survey.getQ10Companion() == Question10Companion.WELCOME) { + s += 3.0; + } else if (survey.getQ10Companion() == Question10Companion.SITUATIONAL) { + s += 1.0; + } else if (survey.getQ10Companion() == Question10Companion.US_ONLY) { + s -= 2.0; + } + + // Q11. 사진 + if (survey.getQ11Photo() == Question11Photo.LIFETIME_SHOT) { + s += 1.0; + p += 1.0; + } else if (survey.getQ11Photo() == Question11Photo.MATCH_COMPANION) { + s += 0.5; + } else if (survey.getQ11Photo() == Question11Photo.EYES_ONLY) { + s -= 1.0; + p -= 1.0; + } + + r = clamp(r); + w = clamp(w); + s = clamp(s); + p = clamp(p); + + return new TendencyScores(r, w, s, p); + } + + private double clamp(double value) { + return Math.max(MIN_SCORE, Math.min(MAX_SCORE, value)); + } + + public record TendencyScores(double r, double w, double s, double p) { + } +} diff --git a/src/main/java/com/dduru/gildongmu/survey/validation/InterestListValidator.java b/src/main/java/com/dduru/gildongmu/survey/validation/InterestListValidator.java deleted file mode 100644 index e5d0f2e4..00000000 --- a/src/main/java/com/dduru/gildongmu/survey/validation/InterestListValidator.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.dduru.gildongmu.survey.validation; - -import com.dduru.gildongmu.survey.domain.enums.Interest; -import jakarta.validation.ConstraintValidator; -import jakarta.validation.ConstraintValidatorContext; - -import java.util.Arrays; -import java.util.List; -import java.util.Set; -import java.util.stream.Collectors; - -public class InterestListValidator implements ConstraintValidator> { - - private Set validInterests; - - @Override - public void initialize(ValidInterestList constraintAnnotation) { - validInterests = Arrays.stream(Interest.values()) - .map(Enum::name) - .collect(Collectors.toSet()); - } - - @Override - public boolean isValid(List value, ConstraintValidatorContext context) { - if (value == null || value.isEmpty()) { - return false; - } - return value.stream().allMatch(validInterests::contains); - } -} diff --git a/src/main/java/com/dduru/gildongmu/survey/validation/ValidInterestList.java b/src/main/java/com/dduru/gildongmu/survey/validation/ValidInterestList.java deleted file mode 100644 index 3c34c8c3..00000000 --- a/src/main/java/com/dduru/gildongmu/survey/validation/ValidInterestList.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.dduru.gildongmu.survey.validation; - -import jakarta.validation.Constraint; -import jakarta.validation.Payload; - -import java.lang.annotation.*; - -@Documented -@Constraint(validatedBy = InterestListValidator.class) -@Target({ ElementType.FIELD, ElementType.PARAMETER }) -@Retention(RetentionPolicy.RUNTIME) -public @interface ValidInterestList { - String message() default "유효하지 않은 관심사입니다"; - Class[] groups() default {}; - Class[] payload() default {}; -} diff --git a/src/main/java/com/dduru/gildongmu/user/controller/UserApiDocs.java b/src/main/java/com/dduru/gildongmu/user/controller/UserApiDocs.java deleted file mode 100644 index 556aaa11..00000000 --- a/src/main/java/com/dduru/gildongmu/user/controller/UserApiDocs.java +++ /dev/null @@ -1,45 +0,0 @@ -package com.dduru.gildongmu.user.controller; - -import com.dduru.gildongmu.common.dto.ApiResult; -import com.dduru.gildongmu.user.dto.NicknameRandomResponse; -import com.dduru.gildongmu.user.dto.UserCheckNicknameResponse; -import com.dduru.gildongmu.user.dto.UserUpdateNicknameRequest; -import com.dduru.gildongmu.user.validator.ValidNickname; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.responses.ApiResponses; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import org.springframework.http.ResponseEntity; - -@Tag(name = "Users", description = "사용자 관리 API") -public interface UserApiDocs { - - @Operation(summary = "닉네임 수정", description = "사용자의 닉네임을 수정합니다.") - @ApiResponses({ - @ApiResponse(responseCode = "204", description = "수정 성공"), - @ApiResponse(responseCode = "400", description = "잘못된 요청") - }) - ResponseEntity> updateNickname( - @Parameter(hidden = true) Long id, - @Valid UserUpdateNicknameRequest request - ); - - @Operation(summary = "닉네임 유효성 확인", description = "닉네임의 사용 가능 여부를 확인합니다.") - @ApiResponse(responseCode = "200", description = "유효성 확인 성공") - ResponseEntity> checkNickname( - @Parameter( - description = "유효성 체크할 닉네임", - example = "길동무" - ) - @ValidNickname String nickname - ); - - @Operation(summary = "랜덤 닉네임 생성", description = "랜덤으로 닉네임을 생성합니다.") - @ApiResponses({ - @ApiResponse(responseCode = "200", description = "랜덤 닉네임 생성 성공"), - @ApiResponse(responseCode = "401", description = "인증 필요") - }) - ResponseEntity> generateRandomNickname(); -} diff --git a/src/main/java/com/dduru/gildongmu/user/domain/User.java b/src/main/java/com/dduru/gildongmu/user/domain/User.java index 23fae491..04c5c643 100644 --- a/src/main/java/com/dduru/gildongmu/user/domain/User.java +++ b/src/main/java/com/dduru/gildongmu/user/domain/User.java @@ -1,17 +1,15 @@ package com.dduru.gildongmu.user.domain; -import com.dduru.gildongmu.user.enums.Gender; +import com.dduru.gildongmu.common.entity.BaseTimeEntity; +import com.dduru.gildongmu.profile.domain.Profile; import com.dduru.gildongmu.user.enums.OauthType; import com.dduru.gildongmu.user.enums.Role; -import com.dduru.gildongmu.common.entity.BaseTimeEntity; import jakarta.persistence.*; import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; -import java.time.LocalDate; - @Entity @Table(name = "users") @Getter @@ -27,12 +25,6 @@ public class User extends BaseTimeEntity { @Column(nullable = false, length = 50) private String name; - @Column(length = 14, unique = true) - private String nickname; - - @Column(name = "profile_image", nullable = false, length = 500) - private String profileImage; - @Column(name = "oauth_id", nullable = false, length = 100) private String oauthId; @@ -44,49 +36,15 @@ public class User extends BaseTimeEntity { @Column(nullable = false) private Role role = Role.USER; - @Enumerated(EnumType.STRING) - private Gender gender; - - // AgeRange는 사용하지 않으므로 주석 처리 - // @Enumerated(EnumType.STRING) - // @Column(name = "age_range", nullable = true) - // private AgeRange ageRange; - - @Column(name = "phone_number", length = 20) - private String phoneNumber; - - @Column(name = "birthday") - private LocalDate birthday; + @OneToOne(mappedBy = "user", cascade = CascadeType.ALL, fetch = FetchType.LAZY) + private Profile profile; @Builder - public User(String email, String name, String nickname, String profileImage, String oauthId, - OauthType oauthType, Role role, Gender gender, /* AgeRange ageRange, */ String phoneNumber, LocalDate birthday) { + public User(String email, String name, String oauthId, OauthType oauthType, Role role) { this.email = email; this.name = name; - this.nickname = nickname; - this.profileImage = profileImage; this.oauthId = oauthId; this.oauthType = oauthType; this.role = role != null ? role : Role.USER; - this.gender = gender; - // this.ageRange = ageRange; - this.phoneNumber = phoneNumber; - this.birthday = birthday; } - - public void updateNickname(String nickname) { - this.nickname = nickname; - } - -// public void updateAdditionalInfo(Gender gender, LocalDate birthday, String phoneNumber) { -// if (gender != null) { -// this.gender = gender; -// } -// if (birthday != null) { -// this.birthday = birthday; -// } -// if (phoneNumber != null) { -// this.phoneNumber = phoneNumber; -// } -// } } diff --git a/src/main/java/com/dduru/gildongmu/user/dto/UserCheckNicknameResponse.java b/src/main/java/com/dduru/gildongmu/user/dto/UserCheckNicknameResponse.java deleted file mode 100644 index 19595c29..00000000 --- a/src/main/java/com/dduru/gildongmu/user/dto/UserCheckNicknameResponse.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.dduru.gildongmu.user.dto; - -import lombok.Builder; - -@Builder -public record UserCheckNicknameResponse( - String sanitizedNickname -) { -} diff --git a/src/main/java/com/dduru/gildongmu/user/dto/UserInfo.java b/src/main/java/com/dduru/gildongmu/user/dto/UserInfo.java index d8c4af02..ca255e36 100644 --- a/src/main/java/com/dduru/gildongmu/user/dto/UserInfo.java +++ b/src/main/java/com/dduru/gildongmu/user/dto/UserInfo.java @@ -1,5 +1,6 @@ package com.dduru.gildongmu.user.dto; +import com.dduru.gildongmu.profile.domain.Profile; import com.dduru.gildongmu.user.domain.User; public record UserInfo( @@ -11,13 +12,14 @@ public record UserInfo( String nickname ) { public static UserInfo from(User user) { + Profile profile = user.getProfile(); return new UserInfo( user.getId(), user.getName(), - user.getProfileImage(), - user.getGender() != null ? user.getGender().name() : null, - user.getBirthday() != null ? user.getBirthday().toString() : null, - user.getNickname() + profile.getProfileImage(), + profile.getGender().name(), + profile.getBirthday().toString(), + profile.getNickname() ); } } diff --git a/src/main/java/com/dduru/gildongmu/user/repository/UserRepository.java b/src/main/java/com/dduru/gildongmu/user/repository/UserRepository.java index e37f5c4c..131c54fc 100644 --- a/src/main/java/com/dduru/gildongmu/user/repository/UserRepository.java +++ b/src/main/java/com/dduru/gildongmu/user/repository/UserRepository.java @@ -18,7 +18,4 @@ default User getByIdOrThrow(Long id) { return findById(id) .orElseThrow(() -> UserNotFoundException.of(id)); } - - boolean existsByNickname(String nickname); - boolean existsByPhoneNumber(String phoneNumber); } diff --git a/src/main/java/com/dduru/gildongmu/user/service/UserService.java b/src/main/java/com/dduru/gildongmu/user/service/UserService.java deleted file mode 100644 index 9442b39b..00000000 --- a/src/main/java/com/dduru/gildongmu/user/service/UserService.java +++ /dev/null @@ -1,76 +0,0 @@ -package com.dduru.gildongmu.user.service; - -import com.dduru.gildongmu.common.exception.BusinessException; -import com.dduru.gildongmu.common.exception.ErrorCode; -import com.dduru.gildongmu.user.domain.User; -import com.dduru.gildongmu.user.dto.NicknameRandomResponse; -import com.dduru.gildongmu.user.dto.UserCheckNicknameResponse; -import com.dduru.gildongmu.user.dto.UserUpdateNicknameRequest; -import com.dduru.gildongmu.user.repository.UserRepository; -import com.dduru.gildongmu.user.utils.NicknameGenerator; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Slf4j -@RequiredArgsConstructor -@Service -public class UserService { - - private static final int NICKNAME_MAX_RETRY_ATTEMPTS = 10; - - private final NicknameGenerator nicknameGenerator; - private final UserRepository userRepository; - - @Transactional - public void updateNickname(Long userId, UserUpdateNicknameRequest request) { - User user = userRepository.getByIdOrThrow(userId); - checkDuplicateNickname(request.nickname()); - - user.updateNickname(request.nickname()); - } - - @Transactional(readOnly = true) - public UserCheckNicknameResponse checkNickname(String nickname) { - checkDuplicateNickname(nickname); - - return UserCheckNicknameResponse.builder() - .sanitizedNickname(nickname) - .build(); - } - - private void checkDuplicateNickname(String nickname) { - if (userRepository.existsByNickname(nickname)) { - throw new BusinessException(ErrorCode.NICKNAME_ALREADY_TAKEN); - } - } - - /** - * 중복되지 않는 유니크한 닉네임을 생성합니다. - * 형용사 + 명사 조합에 랜덤 숫자를 추가하여 고유성을 보장합니다. - * DB에 중복이 있으면 새로운 랜덤 숫자로 재시도합니다. - * - * @return 유니크한 닉네임 (예: "용감한 여행자1234") - */ - @Transactional(readOnly = true) - public NicknameRandomResponse generateRandomNickname() { - - for (int attempt = 0; attempt < NICKNAME_MAX_RETRY_ATTEMPTS; attempt++) { - String baseNickname = nicknameGenerator.generateBaseNickname(); - int randomNumber = nicknameGenerator.generateRandomNumber(); - String nickname = baseNickname + randomNumber; - - if (!userRepository.existsByNickname(nickname)) { - log.info("랜덤 닉네임 생성: {}", nickname); - return NicknameRandomResponse.of(nickname); - } - - log.debug("해당 닉네임이 이미 존재합니다: {}, 새로운 숫자를 부여하겠습니다.", nickname); - } - - String fallbackNickname = "뚜비" + (System.currentTimeMillis() % 10000); - log.warn("유니크한 닉네임 생성에 {}회 실패하여 대체 닉네임 사용: {}", NICKNAME_MAX_RETRY_ATTEMPTS, fallbackNickname); - return NicknameRandomResponse.of(fallbackNickname); - } -} diff --git a/src/main/java/com/dduru/gildongmu/verification/controller/PhoneVerificationApiDocs.java b/src/main/java/com/dduru/gildongmu/verification/controller/PhoneVerificationApiDocs.java index b672354b..690d3471 100644 --- a/src/main/java/com/dduru/gildongmu/verification/controller/PhoneVerificationApiDocs.java +++ b/src/main/java/com/dduru/gildongmu/verification/controller/PhoneVerificationApiDocs.java @@ -1,7 +1,5 @@ package com.dduru.gildongmu.verification.controller; -import com.dduru.gildongmu.common.annotation.CommonApiResponses; -import com.dduru.gildongmu.common.annotation.CurrentUser; import com.dduru.gildongmu.common.dto.ApiResult; import com.dduru.gildongmu.common.exception.ErrorResponse; import com.dduru.gildongmu.verification.dto.VerificationSendRequest; @@ -18,13 +16,11 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.RequestBody; @Tag(name = "Phone Verification", description = "휴대폰 인증 API") public interface PhoneVerificationApiDocs { - @Operation(summary = "인증번호 발송", description = "소셜 로그인한 사용자만 전화번호로 인증번호를 발송할 수 있습니다.") - @CommonApiResponses + @Operation(summary = "인증번호 발송", description = "전화번호로 인증번호를 발송합니다.") @ApiResponses({ @ApiResponse( responseCode = "200", @@ -93,12 +89,11 @@ public interface PhoneVerificationApiDocs { ) }) ResponseEntity> sendVerificationCode( - @Parameter(hidden = true) @CurrentUser Long userId, - @Valid @RequestBody VerificationSendRequest request + @Parameter(hidden = true) Long userId, + @Valid VerificationSendRequest request ); - @Operation(summary = "인증번호 검증", description = "소셜 로그인한 사용자만 발송된 인증번호를 검증하고 인증 토큰을 발급할 수 있습니다.") - @CommonApiResponses + @Operation(summary = "인증번호 검증", description = "발송된 인증번호를 검증하고 인증 토큰을 발급합니다.") @ApiResponses({ @ApiResponse( responseCode = "200", @@ -188,7 +183,7 @@ ResponseEntity> sendVerificationCode( ) }) ResponseEntity> verifyCode( - @Parameter(hidden = true) @CurrentUser Long userId, - @Valid @RequestBody VerificationVerifyRequest request + @Parameter(hidden = true) Long userId, + @Valid VerificationVerifyRequest request ); } diff --git a/src/main/java/com/dduru/gildongmu/verification/service/PhoneVerificationService.java b/src/main/java/com/dduru/gildongmu/verification/service/PhoneVerificationService.java index aea28cf6..22dd740b 100644 --- a/src/main/java/com/dduru/gildongmu/verification/service/PhoneVerificationService.java +++ b/src/main/java/com/dduru/gildongmu/verification/service/PhoneVerificationService.java @@ -1,7 +1,7 @@ package com.dduru.gildongmu.verification.service; import com.dduru.gildongmu.common.jwt.JwtTokenProvider; -import com.dduru.gildongmu.user.repository.UserRepository; +import com.dduru.gildongmu.profile.repository.ProfileRepository; import com.dduru.gildongmu.verification.dto.VerificationSendResponse; import com.dduru.gildongmu.verification.dto.VerificationVerifyResponse; import com.dduru.gildongmu.verification.exception.DuplicatePhoneNumberException; @@ -19,7 +19,7 @@ public class PhoneVerificationService { private final SmsService smsService; private final VerificationCodeService verificationCodeService; private final JwtTokenProvider jwtTokenProvider; - private final UserRepository userRepository; + private final ProfileRepository profileRepository; public VerificationSendResponse sendVerificationCode(String phoneNumber) { validatePhoneNumber(phoneNumber); @@ -48,7 +48,7 @@ public VerificationVerifyResponse verifyCode(String phoneNumber, String code) { } private void validatePhoneNumber(String phoneNumber) { - if (userRepository.existsByPhoneNumber(phoneNumber)) { + if (profileRepository.existsByPhoneNumber(phoneNumber)) { throw new DuplicatePhoneNumberException("이미 가입된 전화번호입니다. 로그인해주세요."); } } diff --git a/src/test/java/com/dduru/gildongmu/auth/repository/UserRepositoryTest.java b/src/test/java/com/dduru/gildongmu/auth/repository/UserRepositoryTest.java index 47389568..186190e7 100644 --- a/src/test/java/com/dduru/gildongmu/auth/repository/UserRepositoryTest.java +++ b/src/test/java/com/dduru/gildongmu/auth/repository/UserRepositoryTest.java @@ -1,8 +1,10 @@ package com.dduru.gildongmu.auth.repository; import com.dduru.gildongmu.config.QueryDslConfig; +import com.dduru.gildongmu.profile.domain.Profile; +import com.dduru.gildongmu.profile.domain.enums.Gender; +import com.dduru.gildongmu.profile.repository.ProfileRepository; import com.dduru.gildongmu.user.domain.User; -import com.dduru.gildongmu.user.enums.Gender; import com.dduru.gildongmu.user.enums.OauthType; import com.dduru.gildongmu.user.repository.UserRepository; import org.junit.jupiter.api.Test; @@ -21,21 +23,29 @@ class UserRepositoryTest { @Autowired private UserRepository userRepository; + @Autowired + private ProfileRepository profileRepository; + @Test void 사용자_저장_및_조회_테스트() { // given User user = User.builder() .email("test@example.com") .name("테스트사용자") - .profileImage("http://example.com/profile.jpg") .oauthId("12345") .oauthType(OauthType.KAKAO) - .gender(Gender.M) - .phoneNumber("010-1234-5678") .build(); // when User savedUser = userRepository.save(user); + + Profile profile = Profile.builder() + .user(savedUser) + .profileImage("http://example.com/profile.jpg") + .gender(Gender.M) + .phoneNumber("010-1234-5678") + .build(); + profileRepository.save(profile); // then assertThat(savedUser.getId()).isNotNull(); @@ -49,13 +59,18 @@ class UserRepositoryTest { User user = User.builder() .email("find@example.com") .name("찾을사용자") - .profileImage("http://example.com/profile.jpg") .oauthId("67890") .oauthType(OauthType.GOOGLE) - .gender(Gender.F) .build(); - userRepository.save(user); + User savedUser = userRepository.save(user); + + Profile profile = Profile.builder() + .user(savedUser) + .profileImage("http://example.com/profile.jpg") + .gender(Gender.F) + .build(); + profileRepository.save(profile); // when boolean exists = userRepository.existsByEmail("find@example.com"); @@ -70,13 +85,18 @@ class UserRepositoryTest { User user = User.builder() .email("oauth@example.com") .name("OAuth사용자") - .profileImage("http://example.com/profile.jpg") .oauthId("oauth123") .oauthType(OauthType.KAKAO) - .gender(Gender.M) .build(); - userRepository.save(user); + User savedUser = userRepository.save(user); + + Profile profile = Profile.builder() + .user(savedUser) + .profileImage("http://example.com/profile.jpg") + .gender(Gender.M) + .build(); + profileRepository.save(profile); // when boolean exists = userRepository.existsByOauthIdAndOauthType("oauth123", OauthType.KAKAO); diff --git a/src/test/java/com/dduru/gildongmu/profile/service/ProfileServiceTest.java b/src/test/java/com/dduru/gildongmu/profile/service/ProfileServiceTest.java new file mode 100644 index 00000000..d38eedcc --- /dev/null +++ b/src/test/java/com/dduru/gildongmu/profile/service/ProfileServiceTest.java @@ -0,0 +1,300 @@ +package com.dduru.gildongmu.profile.service; + +import com.dduru.gildongmu.common.exception.BusinessException; +import com.dduru.gildongmu.common.exception.ErrorCode; +import com.dduru.gildongmu.common.jwt.JwtTokenProvider; +import com.dduru.gildongmu.profile.domain.Profile; +import com.dduru.gildongmu.profile.dto.ProfileSetupRequest; +import com.dduru.gildongmu.profile.repository.ProfileRepository; +import com.dduru.gildongmu.profile.utils.NicknameGenerator; +import com.dduru.gildongmu.user.domain.User; +import com.dduru.gildongmu.user.enums.OauthType; +import com.dduru.gildongmu.user.repository.UserRepository; +import org.junit.jupiter.api.DisplayName; +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 java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ProfileService 프로필 생성 테스트") +class ProfileServiceTest { + + @Mock + private ProfileRepository profileRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private JwtTokenProvider jwtTokenProvider; + + @Mock + private NicknameGenerator nicknameGenerator; + + @InjectMocks + private ProfileService profileService; + + @Test + @DisplayName("정상적인 프로필 생성 성공") + void 성공() { + // given + Long userId = 1L; + String nickname = "테스트닉네임"; + String phoneNumber = "01012345678"; + String verificationToken = "valid-token"; + ProfileSetupRequest request = new ProfileSetupRequest( + nickname, + "M", + phoneNumber, + "2000-01-01", + verificationToken + ); + + User user = User.builder() + .email("test@example.com") + .name("테스트사용자") + .oauthId("12345") + .oauthType(OauthType.KAKAO) + .build(); + + Profile profile = Profile.builder() + .user(user) + .build(); + + when(userRepository.getByIdOrThrow(userId)).thenReturn(user); + when(profileRepository.findByUser(user)).thenReturn(Optional.of(profile)); + when(profileRepository.existsByNicknameWithLock(nickname)).thenReturn(false); + when(jwtTokenProvider.validateVerificationToken(verificationToken, phoneNumber)).thenReturn(true); + when(profileRepository.existsByPhoneNumber(phoneNumber)).thenReturn(false); + when(profileRepository.save(any(Profile.class))).thenReturn(profile); + + // when + profileService.setupInitialProfile(userId, request); + + // then + verify(profileRepository).existsByNicknameWithLock(nickname); + verify(jwtTokenProvider).validateVerificationToken(verificationToken, phoneNumber); + verify(profileRepository).existsByPhoneNumber(phoneNumber); + verify(profileRepository).save(any(Profile.class)); + } + + @Test + @DisplayName("닉네임 중복 시 NICKNAME_ALREADY_TAKEN 예외 발생 (비관적 잠금 사용)") + void 닉네임중복_예외발생() { + // given + Long userId = 1L; + String duplicateNickname = "중복닉네임"; + ProfileSetupRequest request = new ProfileSetupRequest( + duplicateNickname, + "M", + "01012345678", + "2000-01-01", + "valid-token" + ); + + User user = User.builder() + .email("test@example.com") + .name("테스트사용자") + .oauthId("12345") + .oauthType(OauthType.KAKAO) + .build(); + + Profile profile = Profile.builder() + .user(user) + .build(); + + when(userRepository.getByIdOrThrow(userId)).thenReturn(user); + when(profileRepository.findByUser(user)).thenReturn(Optional.of(profile)); + when(profileRepository.existsByNicknameWithLock(duplicateNickname)).thenReturn(true); + + // when & then + assertThatThrownBy(() -> profileService.setupInitialProfile(userId, request)) + .isInstanceOf(BusinessException.class) + .satisfies(exception -> { + BusinessException businessException = (BusinessException) exception; + assertThat(businessException.getErrorCode()).isEqualTo(ErrorCode.NICKNAME_ALREADY_TAKEN); + }); + + verify(profileRepository).existsByNicknameWithLock(duplicateNickname); + verify(profileRepository, never()).save(any(Profile.class)); + verify(jwtTokenProvider, never()).validateVerificationToken(anyString(), anyString()); + } + + @Test + @DisplayName("전화번호 중복 시 DUPLICATE_PHONE_NUMBER 예외 발생") + void 전화번호중복_예외발생() { + // given + Long userId = 1L; + String duplicatePhoneNumber = "01012345678"; + ProfileSetupRequest request = new ProfileSetupRequest( + "테스트닉네임", + "M", + duplicatePhoneNumber, + "2000-01-01", + "valid-token" + ); + + User user = User.builder() + .email("test@example.com") + .name("테스트사용자") + .oauthId("12345") + .oauthType(OauthType.KAKAO) + .build(); + + Profile profile = Profile.builder() + .user(user) + .build(); + + when(userRepository.getByIdOrThrow(userId)).thenReturn(user); + when(profileRepository.findByUser(user)).thenReturn(Optional.of(profile)); + when(profileRepository.existsByNicknameWithLock("테스트닉네임")).thenReturn(false); + when(jwtTokenProvider.validateVerificationToken("valid-token", duplicatePhoneNumber)).thenReturn(true); + when(profileRepository.existsByPhoneNumber(duplicatePhoneNumber)).thenReturn(true); + + // when & then + assertThatThrownBy(() -> profileService.setupInitialProfile(userId, request)) + .isInstanceOf(BusinessException.class) + .satisfies(exception -> { + BusinessException businessException = (BusinessException) exception; + assertThat(businessException.getErrorCode()).isEqualTo(ErrorCode.DUPLICATE_PHONE_NUMBER); + }); + + verify(profileRepository).existsByNicknameWithLock("테스트닉네임"); + verify(profileRepository).existsByPhoneNumber(duplicatePhoneNumber); + verify(profileRepository, never()).save(any(Profile.class)); + } + + @Test + @DisplayName("인증 토큰 검증 실패 시 INVALID_TOKEN 예외 발생") + void 인증토큰검증실패_예외발생() { + // given + Long userId = 1L; + String invalidToken = "invalid-token"; + String phoneNumber = "01012345678"; + ProfileSetupRequest request = new ProfileSetupRequest( + "테스트닉네임", + "M", + phoneNumber, + "2000-01-01", + invalidToken + ); + + User user = User.builder() + .email("test@example.com") + .name("테스트사용자") + .oauthId("12345") + .oauthType(OauthType.KAKAO) + .build(); + + Profile profile = Profile.builder() + .user(user) + .build(); + + when(userRepository.getByIdOrThrow(userId)).thenReturn(user); + when(profileRepository.findByUser(user)).thenReturn(Optional.of(profile)); + when(profileRepository.existsByNicknameWithLock("테스트닉네임")).thenReturn(false); + when(jwtTokenProvider.validateVerificationToken(invalidToken, phoneNumber)).thenReturn(false); + + // when & then + assertThatThrownBy(() -> profileService.setupInitialProfile(userId, request)) + .isInstanceOf(BusinessException.class) + .satisfies(exception -> { + BusinessException businessException = (BusinessException) exception; + assertThat(businessException.getErrorCode()).isEqualTo(ErrorCode.INVALID_TOKEN); + }); + + verify(profileRepository).existsByNicknameWithLock("테스트닉네임"); + verify(jwtTokenProvider).validateVerificationToken(invalidToken, phoneNumber); + verify(profileRepository, never()).existsByPhoneNumber(anyString()); + verify(profileRepository, never()).save(any(Profile.class)); + } + + @Test + @DisplayName("프로필이 없는 경우 PROFILE_NOT_FOUND 예외 발생") + void 프로필없음_예외발생() { + // given + Long userId = 1L; + ProfileSetupRequest request = new ProfileSetupRequest( + "테스트닉네임", + "M", + "01012345678", + "2000-01-01", + "valid-token" + ); + + User user = User.builder() + .email("test@example.com") + .name("테스트사용자") + .oauthId("12345") + .oauthType(OauthType.KAKAO) + .build(); + + when(userRepository.getByIdOrThrow(userId)).thenReturn(user); + when(profileRepository.findByUser(user)).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> profileService.setupInitialProfile(userId, request)) + .isInstanceOf(BusinessException.class) + .satisfies(exception -> { + BusinessException businessException = (BusinessException) exception; + assertThat(businessException.getErrorCode()).isEqualTo(ErrorCode.PROFILE_NOT_FOUND); + }); + + verify(profileRepository).findByUser(user); + verify(profileRepository, never()).existsByNicknameWithLock(anyString()); + verify(profileRepository, never()).save(any(Profile.class)); + } + + @Test + @DisplayName("비관적 잠금을 사용하여 닉네임 중복 체크 메서드 호출 확인") + void 비관적잠금사용확인() { + // given + Long userId = 1L; + String nickname = "테스트닉네임"; + ProfileSetupRequest request = new ProfileSetupRequest( + nickname, + "F", + "01087654321", + "1995-05-15", + "valid-token" + ); + + User user = User.builder() + .email("test2@example.com") + .name("테스트사용자2") + .oauthId("67890") + .oauthType(OauthType.GOOGLE) + .build(); + + Profile profile = Profile.builder() + .user(user) + .build(); + + when(userRepository.getByIdOrThrow(userId)).thenReturn(user); + when(profileRepository.findByUser(user)).thenReturn(Optional.of(profile)); + when(profileRepository.existsByNicknameWithLock(nickname)).thenReturn(false); + when(jwtTokenProvider.validateVerificationToken("valid-token", "01087654321")).thenReturn(true); + when(profileRepository.existsByPhoneNumber("01087654321")).thenReturn(false); + when(profileRepository.save(any(Profile.class))).thenReturn(profile); + + // when + profileService.setupInitialProfile(userId, request); + + // then + // existsByNickname이 아닌 existsByNicknameWithLock이 호출되었는지 확인 + verify(profileRepository).existsByNicknameWithLock(nickname); + verify(profileRepository, never()).existsByNickname(nickname); + } +} diff --git a/src/test/java/com/dduru/gildongmu/survey/converter/SurveyConverterTest.java b/src/test/java/com/dduru/gildongmu/survey/converter/SurveyConverterTest.java new file mode 100644 index 00000000..e966dfd7 --- /dev/null +++ b/src/test/java/com/dduru/gildongmu/survey/converter/SurveyConverterTest.java @@ -0,0 +1,122 @@ +package com.dduru.gildongmu.survey.converter; + +import com.dduru.gildongmu.survey.domain.Survey; +import com.dduru.gildongmu.survey.domain.enums.*; +import com.dduru.gildongmu.survey.dto.SurveyRequest; +import com.dduru.gildongmu.survey.exception.InvalidSurveyAnswerCodeException; +import com.dduru.gildongmu.user.domain.User; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DisplayName("설문조사 Converter 테스트") +class SurveyConverterTest { + + private SurveyConverter converter; + private User testUser; + + @BeforeEach + void setUp() { + converter = new SurveyConverter(); + testUser = User.builder() + .email("test@example.com") + .name("테스트") + .oauthId("12345") + .oauthType(com.dduru.gildongmu.user.enums.OauthType.KAKAO) + .build(); + } + + @Test + @DisplayName("유효한_코드로_Survey_엔티티_생성_성공") + void 유효한_코드로_Survey_엔티티_생성_성공() { + // given + SurveyRequest request = new SurveyRequest( + 1, 1, 1, 1, 1, 2, + List.of(1, 2, 3), + 2, 1, 2, 1 + ); + + // when + Survey survey = converter.toEntity(testUser, request); + + // then + assertThat(survey.getUser()).isEqualTo(testUser); + assertThat(survey.getQ1Transport()).isEqualTo(Question1Transport.WALK_BUS); + assertThat(survey.getQ2Waiting()).isEqualTo(Question2Waiting.WAIT); + assertThat(survey.getQ3Stay()).isEqualTo(Question3Stay.HOTEL); + assertThat(survey.getQ4Wakeup()).isEqualTo(Question4Wakeup.EARLY); + assertThat(survey.getQ5Expense()).isEqualTo(Question5Expense.EACH_PAYS); + assertThat(survey.getQ6Spend()).isEqualTo(Question6Spend.SAVE); + assertThat(survey.getQ7Interests()).hasSize(3); + assertThat(survey.getQ7Interests()).containsExactly( + Question7Interest.SIGHTSEEING, + Question7Interest.EXHIBITION, + Question7Interest.NATURE + ); + assertThat(survey.getQ8Planning()).isEqualTo(Question8Planning.FLEXIBLE); + assertThat(survey.getQ9Menu()).isEqualTo(Question9Menu.SAFE); + assertThat(survey.getQ10Companion()).isEqualTo(Question10Companion.SITUATIONAL); + assertThat(survey.getQ11Photo()).isEqualTo(Question11Photo.LIFETIME_SHOT); + } + + @Test + @DisplayName("유효하지_않은_Q1_코드_예외발생") + void 유효하지_않은_Q1_코드_예외발생() { + // given + SurveyRequest request = new SurveyRequest( + 999, 1, 1, 1, 1, 1, List.of(1, 2, 3), 1, 1, 1, 1 + ); + + // when & then + assertThatThrownBy(() -> converter.toEntity(testUser, request)) + .isInstanceOf(InvalidSurveyAnswerCodeException.class) + .hasMessageContaining("Q1 이동수단"); + } + + @Test + @DisplayName("유효하지_않은_Q7_코드_예외발생") + void 유효하지_않은_Q7_코드_예외발생() { + // given + SurveyRequest request = new SurveyRequest( + 1, 1, 1, 1, 1, 1, + List.of(1, 2, 999), + 1, 1, 1, 1 + ); + + // when & then + assertThatThrownBy(() -> converter.toEntity(testUser, request)) + .isInstanceOf(InvalidSurveyAnswerCodeException.class) + .hasMessageContaining("Q7 선호활동"); + } + + @Test + @DisplayName("모든_질문_최대값_코드_정상변환") + void 모든_질문_최대값_코드_정상변환() { + // given + SurveyRequest request = new SurveyRequest( + 2, 2, 2, 2, 2, 1, + List.of(7, 8, 9), + 3, 3, 1, 3 + ); + + // when + Survey survey = converter.toEntity(testUser, request); + + // then + assertThat(survey.getQ1Transport()).isEqualTo(Question1Transport.TAXI); + assertThat(survey.getQ2Waiting()).isEqualTo(Question2Waiting.MOVE_ELSEWHERE); + assertThat(survey.getQ3Stay()).isEqualTo(Question3Stay.JUST_SLEEP); + assertThat(survey.getQ4Wakeup()).isEqualTo(Question4Wakeup.RELAXED); + assertThat(survey.getQ5Expense()).isEqualTo(Question5Expense.POOLED); + assertThat(survey.getQ6Spend()).isEqualTo(Question6Spend.SPLURGE); + assertThat(survey.getQ8Planning()).isEqualTo(Question8Planning.ON_SITE); + assertThat(survey.getQ9Menu()).isEqualTo(Question9Menu.CHALLENGE); + assertThat(survey.getQ10Companion()).isEqualTo(Question10Companion.WELCOME); + assertThat(survey.getQ11Photo()).isEqualTo(Question11Photo.EYES_ONLY); + } +} diff --git a/src/test/java/com/dduru/gildongmu/survey/service/AvatarMatcherTest.java b/src/test/java/com/dduru/gildongmu/survey/service/AvatarMatcherTest.java new file mode 100644 index 00000000..819aeb11 --- /dev/null +++ b/src/test/java/com/dduru/gildongmu/survey/service/AvatarMatcherTest.java @@ -0,0 +1,154 @@ +package com.dduru.gildongmu.survey.service; + +import com.dduru.gildongmu.survey.domain.enums.AvatarType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("아바타 매칭 테스트") +class AvatarMatcherTest { + + private AvatarMatcher avatarMatcher; + + @BeforeEach + void setUp() { + avatarMatcher = new AvatarMatcher(); + } + + @Test + @DisplayName("안정_x_독립_x_가성비_매칭") + void 안정_x_독립_x_가성비_매칭() { + // given + double r = 4.0; + double w = 4.0; + double s = 4.0; + + // when + AvatarType avatar = avatarMatcher.match(r, w, s); + + // then + assertThat(avatar).isEqualTo(AvatarType.TTUR_POGUNI); + } + + @Test + @DisplayName("안정_x_독립_x_플랙스_매칭") + void 안정_x_독립_x_플랙스_매칭() { + // given + double r = 4.0; + double w = 6.0; + double s = 4.0; + + // when + AvatarType avatar = avatarMatcher.match(r, w, s); + + // then + assertThat(avatar).isEqualTo(AvatarType.TTUR_MOOD); + } + + @Test + @DisplayName("안정_x_사교_x_가성비_매칭") + void 안정_x_사교_x_가성비_매칭() { + // given + double r = 4.0; + double w = 4.0; + double s = 6.0; + + // when + AvatarType avatar = avatarMatcher.match(r, w, s); + + // then + assertThat(avatar).isEqualTo(AvatarType.TTUR_MALLANGI); + } + + @Test + @DisplayName("안정_x_사교_x_플랙스_매칭") + void 안정_x_사교_x_플랙스_매칭() { + // given + double r = 4.0; + double w = 6.0; + double s = 6.0; + + // when + AvatarType avatar = avatarMatcher.match(r, w, s); + + // then + assertThat(avatar).isEqualTo(AvatarType.TTUR_SWEET); + } + + @Test + @DisplayName("모험_x_독립_x_가성비_매칭") + void 모험_x_독립_x_가성비_매칭() { + // given + double r = 6.0; + double w = 4.0; + double s = 4.0; + + // when + AvatarType avatar = avatarMatcher.match(r, w, s); + + // then + assertThat(avatar).isEqualTo(AvatarType.TTUR_POPO); + } + + @Test + @DisplayName("모험_x_독립_x_플랙스_매칭") + void 모험_x_독립_x_플랙스_매칭() { + // given + double r = 6.0; + double w = 6.0; + double s = 4.0; + + // when + AvatarType avatar = avatarMatcher.match(r, w, s); + + // then + assertThat(avatar).isEqualTo(AvatarType.TTUR_SPARKLE); + } + + @Test + @DisplayName("모험_x_사교_x_가성비_매칭") + void 모험_x_사교_x_가성비_매칭() { + // given + double r = 6.0; + double w = 4.0; + double s = 6.0; + + // when + AvatarType avatar = avatarMatcher.match(r, w, s); + + // then + assertThat(avatar).isEqualTo(AvatarType.TTUR_GLIMMING); + } + + @Test + @DisplayName("모험_x_사교_x_플랙스_매칭") + void 모험_x_사교_x_플랙스_매칭() { + // given + double r = 6.0; + double w = 6.0; + double s = 6.0; + + // when + AvatarType avatar = avatarMatcher.match(r, w, s); + + // then + assertThat(avatar).isEqualTo(AvatarType.TTUR_PADO); + } + + @Test + @DisplayName("임계값_5점_경계_테스트") + void 임계값_5점_경계_테스트() { + // given + double r = 5.0; + double w = 5.0; + double s = 5.0; + + // when + AvatarType avatar = avatarMatcher.match(r, w, s); + + // then + assertThat(avatar).isEqualTo(AvatarType.TTUR_PADO); + } +} diff --git a/src/test/java/com/dduru/gildongmu/survey/service/SurveyServiceTest.java b/src/test/java/com/dduru/gildongmu/survey/service/SurveyServiceTest.java new file mode 100644 index 00000000..bb5db99e --- /dev/null +++ b/src/test/java/com/dduru/gildongmu/survey/service/SurveyServiceTest.java @@ -0,0 +1,266 @@ +package com.dduru.gildongmu.survey.service; + +import com.dduru.gildongmu.auth.exception.UserNotFoundException; +import com.dduru.gildongmu.survey.converter.SurveyConverter; +import com.dduru.gildongmu.survey.domain.Survey; +import com.dduru.gildongmu.survey.domain.TravelTendency; +import com.dduru.gildongmu.survey.domain.enums.*; +import com.dduru.gildongmu.survey.dto.SurveyRequest; +import com.dduru.gildongmu.survey.dto.SurveyResponse; +import com.dduru.gildongmu.survey.exception.SurveyResultNotFoundException; +import com.dduru.gildongmu.survey.repository.SurveyRepository; +import com.dduru.gildongmu.survey.repository.TravelTendencyRepository; +import com.dduru.gildongmu.user.domain.User; +import com.dduru.gildongmu.user.repository.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +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 java.math.BigDecimal; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("설문조사 서비스 테스트") +class SurveyServiceTest { + + @Mock + private SurveyRepository surveyRepository; + @Mock + private TravelTendencyRepository travelTendencyRepository; + @Mock + private SurveyConverter surveyConverter; + @Mock + private TravelTendencyCalculator tendencyCalculator; + @Mock + private AvatarMatcher avatarMatcher; + @Mock + private AvatarProfileProvider avatarProfileProvider; + @Mock + private UserRepository userRepository; + + @InjectMocks + private SurveyService surveyService; + + private User testUser; + private SurveyRequest testRequest; + private Survey testSurvey; + + @BeforeEach + void setUp() { + testUser = User.builder() + .email("test@example.com") + .name("테스트") + .oauthId("12345") + .oauthType(com.dduru.gildongmu.user.enums.OauthType.KAKAO) + .build(); + + testRequest = new SurveyRequest( + 1, 1, 1, 1, 1, 1, + List.of(1, 2, 3), + 1, 1, 1, 1 + ); + + testSurvey = Survey.createSurvey( + testUser, + Question1Transport.WALK_BUS, + Question2Waiting.WAIT, + Question3Stay.HOTEL, + Question4Wakeup.EARLY, + Question5Expense.EACH_PAYS, + Question6Spend.SAVE, + List.of(Question7Interest.SIGHTSEEING, Question7Interest.EXHIBITION, Question7Interest.NATURE), + Question8Planning.DETAILED, + Question9Menu.SAFE, + Question10Companion.SITUATIONAL, + Question11Photo.LIFETIME_SHOT + ); + } + + @Test + @DisplayName("새로운_설문_제출_성공") + void 새로운_설문_제출_성공() { + // given + SurveyConverter.ParsedSurveyData parsedData = new SurveyConverter.ParsedSurveyData( + Question1Transport.WALK_BUS, + Question2Waiting.WAIT, + Question3Stay.HOTEL, + Question4Wakeup.EARLY, + Question5Expense.EACH_PAYS, + Question6Spend.SAVE, + List.of(Question7Interest.SIGHTSEEING, Question7Interest.EXHIBITION, Question7Interest.NATURE), + Question8Planning.DETAILED, + Question9Menu.SAFE, + Question10Companion.SITUATIONAL, + Question11Photo.LIFETIME_SHOT + ); + + when(userRepository.getByIdOrThrow(1L)).thenReturn(testUser); + when(surveyRepository.findByUser(testUser)).thenReturn(Optional.empty()); + when(surveyConverter.parseRequest(testRequest)).thenReturn(parsedData); + when(surveyConverter.toEntity(testUser, testRequest)).thenReturn(testSurvey); + when(surveyRepository.save(any(Survey.class))).thenReturn(testSurvey); + + TravelTendencyCalculator.TendencyScores scores = + new TravelTendencyCalculator.TendencyScores(5.5, 6.0, 7.0, 4.5); + when(tendencyCalculator.calculate(testSurvey)).thenReturn(scores); + when(avatarMatcher.match(scores.r(), scores.w(), scores.s())).thenReturn(AvatarType.TTUR_SWEET); + + AvatarProfileProvider.AvatarProfile profile = new AvatarProfileProvider.AvatarProfile( + "성격", "강점", "팁", List.of("태그1", "태그2", "태그3") + ); + when(avatarProfileProvider.getProfile(AvatarType.TTUR_SWEET)).thenReturn(profile); + + when(travelTendencyRepository.findByUser(testUser)).thenReturn(Optional.empty()); + when(travelTendencyRepository.save(any(TravelTendency.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + // when + SurveyResponse response = surveyService.submitSurvey(1L, testRequest); + + // then + assertThat(response.r()).isEqualTo(5.5); + assertThat(response.w()).isEqualTo(6.0); + assertThat(response.s()).isEqualTo(7.0); + assertThat(response.p()).isEqualTo(4.5); + assertThat(response.avatarType()).isEqualTo("TTUR_SWEET"); + assertThat(response.personality()).isEqualTo("성격"); + assertThat(response.strength()).isEqualTo("강점"); + assertThat(response.tip()).isEqualTo("팁"); + + verify(surveyRepository).save(any(Survey.class)); + verify(travelTendencyRepository).save(any(TravelTendency.class)); + } + + @Test + @DisplayName("기존_설문_업데이트_성공") + void 기존_설문_업데이트_성공() { + // given + Survey existingSurvey = Survey.createSurvey( + testUser, + Question1Transport.TAXI, + Question2Waiting.MOVE_ELSEWHERE, + Question3Stay.JUST_SLEEP, + Question4Wakeup.RELAXED, + Question5Expense.POOLED, + Question6Spend.SPLURGE, + List.of(Question7Interest.FOOD, Question7Interest.SHOPPING, Question7Interest.ACTIVITY), + Question8Planning.ON_SITE, + Question9Menu.CHALLENGE, + Question10Companion.WELCOME, + Question11Photo.EYES_ONLY + ); + + SurveyConverter.ParsedSurveyData parsedData = new SurveyConverter.ParsedSurveyData( + Question1Transport.WALK_BUS, + Question2Waiting.WAIT, + Question3Stay.HOTEL, + Question4Wakeup.EARLY, + Question5Expense.EACH_PAYS, + Question6Spend.SAVE, + List.of(Question7Interest.SIGHTSEEING, Question7Interest.EXHIBITION, Question7Interest.NATURE), + Question8Planning.DETAILED, + Question9Menu.SAFE, + Question10Companion.SITUATIONAL, + Question11Photo.LIFETIME_SHOT + ); + + when(userRepository.getByIdOrThrow(1L)).thenReturn(testUser); + when(surveyRepository.findByUser(testUser)).thenReturn(Optional.of(existingSurvey)); + when(surveyConverter.parseRequest(testRequest)).thenReturn(parsedData); + + TravelTendencyCalculator.TendencyScores scores = + new TravelTendencyCalculator.TendencyScores(6.0, 7.5, 8.0, 5.0); + when(tendencyCalculator.calculate(existingSurvey)).thenReturn(scores); + when(avatarMatcher.match(scores.r(), scores.w(), scores.s())).thenReturn(AvatarType.TTUR_PADO); + + AvatarProfileProvider.AvatarProfile profile = new AvatarProfileProvider.AvatarProfile( + "성격2", "강점2", "팁2", List.of("태그1", "태그2", "태그3") + ); + when(avatarProfileProvider.getProfile(AvatarType.TTUR_PADO)).thenReturn(profile); + + TravelTendency existingTendency = TravelTendency.create( + testUser, + BigDecimal.valueOf(5.0), + BigDecimal.valueOf(6.0), + BigDecimal.valueOf(7.0), + BigDecimal.valueOf(4.0), + AvatarType.TTUR_SWEET + ); + when(travelTendencyRepository.findByUser(testUser)).thenReturn(Optional.of(existingTendency)); + + // when + SurveyResponse response = surveyService.submitSurvey(1L, testRequest); + + // then + assertThat(response.avatarType()).isEqualTo("TTUR_PADO"); + verify(surveyRepository, never()).save(any(Survey.class)); + } + + @Test + @DisplayName("존재하지_않는_사용자_예외발생") + void 존재하지_않는_사용자_예외발생() { + // given + when(userRepository.getByIdOrThrow(999L)).thenThrow(UserNotFoundException.of(999L)); + + // when & then + assertThatThrownBy(() -> surveyService.submitSurvey(999L, testRequest)) + .isInstanceOf(UserNotFoundException.class); + + verify(surveyRepository, never()).save(any()); + verify(travelTendencyRepository, never()).save(any()); + } + + @Test + @DisplayName("설문_결과_조회_성공") + void 설문_결과_조회_성공() { + // given + TravelTendency travelTendency = TravelTendency.create( + testUser, + BigDecimal.valueOf(5.5), + BigDecimal.valueOf(6.0), + BigDecimal.valueOf(7.0), + BigDecimal.valueOf(4.5), + AvatarType.TTUR_SWEET + ); + + when(userRepository.getByIdOrThrow(1L)).thenReturn(testUser); + when(travelTendencyRepository.findByUser(testUser)).thenReturn(Optional.of(travelTendency)); + + AvatarProfileProvider.AvatarProfile profile = new AvatarProfileProvider.AvatarProfile( + "성격", "강점", "팁", List.of("태그1", "태그2", "태그3") + ); + when(avatarProfileProvider.getProfile(AvatarType.TTUR_SWEET)).thenReturn(profile); + + // when + SurveyResponse response = surveyService.getMySurveyResult(1L); + + // then + assertThat(response.r()).isEqualTo(5.5); + assertThat(response.w()).isEqualTo(6.0); + assertThat(response.s()).isEqualTo(7.0); + assertThat(response.p()).isEqualTo(4.5); + assertThat(response.avatarType()).isEqualTo("TTUR_SWEET"); + assertThat(response.avatarCode()).isEqualTo(4); + } + + @Test + @DisplayName("설문_결과_없을때_예외발생") + void 설문_결과_없을때_예외발생() { + // given + when(userRepository.getByIdOrThrow(1L)).thenReturn(testUser); + when(travelTendencyRepository.findByUser(testUser)).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> surveyService.getMySurveyResult(1L)) + .isInstanceOf(SurveyResultNotFoundException.class); + } +} diff --git a/src/test/java/com/dduru/gildongmu/survey/service/TravelTendencyCalculatorTest.java b/src/test/java/com/dduru/gildongmu/survey/service/TravelTendencyCalculatorTest.java new file mode 100644 index 00000000..211eca32 --- /dev/null +++ b/src/test/java/com/dduru/gildongmu/survey/service/TravelTendencyCalculatorTest.java @@ -0,0 +1,182 @@ +package com.dduru.gildongmu.survey.service; + +import com.dduru.gildongmu.survey.domain.Survey; +import com.dduru.gildongmu.survey.domain.enums.*; +import com.dduru.gildongmu.user.domain.User; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("여행 성향 점수 계산 테스트") +class TravelTendencyCalculatorTest { + + private TravelTendencyCalculator calculator; + private User testUser; + + @BeforeEach + void setUp() { + calculator = new TravelTendencyCalculator(); + testUser = User.builder() + .email("test@example.com") + .name("테스트") + .oauthId("12345") + .oauthType(com.dduru.gildongmu.user.enums.OauthType.KAKAO) + .build(); + } + + @Test + @DisplayName("기본값_모두_5점으로_시작") + void 기본값_모두_5점으로_시작() { + // given + Survey survey = Survey.createSurvey( + testUser, + Question1Transport.WALK_BUS, + Question2Waiting.WAIT, + Question3Stay.HOTEL, + Question4Wakeup.EARLY, + Question5Expense.EACH_PAYS, + Question6Spend.SAVE, + List.of(Question7Interest.SIGHTSEEING, Question7Interest.NATURE, Question7Interest.RESORT), + Question8Planning.FLEXIBLE, + Question9Menu.SAFE, + Question10Companion.SITUATIONAL, + Question11Photo.EYES_ONLY + ); + + // when + TravelTendencyCalculator.TendencyScores scores = calculator.calculate(survey); + + // then + assertThat(scores.r()).isBetween(0.0, 10.0); + assertThat(scores.w()).isBetween(0.0, 10.0); + assertThat(scores.s()).isBetween(0.0, 10.0); + assertThat(scores.p()).isBetween(0.0, 10.0); + } + + @Test + @DisplayName("Q1_걷기버스선택시_W감소_P증가") + void Q1_걷기버스선택시_W감소_P증가() { + // given + Survey walkBusSurvey = Survey.createSurvey( + testUser, Question1Transport.WALK_BUS, + Question2Waiting.WAIT, Question3Stay.HOTEL, Question4Wakeup.EARLY, + Question5Expense.EACH_PAYS, Question6Spend.SAVE, + List.of(Question7Interest.SIGHTSEEING, Question7Interest.NATURE, Question7Interest.RESORT), + Question8Planning.FLEXIBLE, Question9Menu.SAFE, + Question10Companion.SITUATIONAL, Question11Photo.EYES_ONLY + ); + + Survey taxiSurvey = Survey.createSurvey( + testUser, Question1Transport.TAXI, + Question2Waiting.WAIT, Question3Stay.HOTEL, Question4Wakeup.EARLY, + Question5Expense.EACH_PAYS, Question6Spend.SAVE, + List.of(Question7Interest.SIGHTSEEING, Question7Interest.NATURE, Question7Interest.RESORT), + Question8Planning.FLEXIBLE, Question9Menu.SAFE, + Question10Companion.SITUATIONAL, Question11Photo.EYES_ONLY + ); + + // when + TravelTendencyCalculator.TendencyScores walkBusScores = calculator.calculate(walkBusSurvey); + TravelTendencyCalculator.TendencyScores taxiScores = calculator.calculate(taxiSurvey); + + // then + assertThat(walkBusScores.w()).isLessThan(taxiScores.w()); + assertThat(walkBusScores.p()).isGreaterThan(taxiScores.p()); + } + + @Test + @DisplayName("Q7_선호활동_택3_누적합산_정상작동") + void Q7_선호활동_택3_누적합산_정상작동() { + // given + Survey survey = Survey.createSurvey( + testUser, + Question1Transport.WALK_BUS, + Question2Waiting.WAIT, + Question3Stay.JUST_SLEEP, + Question4Wakeup.EARLY, + Question5Expense.POOLED, + Question6Spend.SPLURGE, + List.of(Question7Interest.FOOD, Question7Interest.SHOPPING, Question7Interest.ACTIVITY), + Question8Planning.FLEXIBLE, + Question9Menu.CHECK_REVIEW, + Question10Companion.SITUATIONAL, + Question11Photo.MATCH_COMPANION + ); + + // when + TravelTendencyCalculator.TendencyScores scores = calculator.calculate(survey); + + // then + assertThat(scores.r()).isGreaterThan(5.0); + assertThat(scores.p()).isGreaterThan(5.0); + } + + @Test + @DisplayName("점수_범위_0점에서_10점으로_제한") + void 점수_범위_0점에서_10점으로_제한() { + // given + Survey minScoreSurvey = Survey.createSurvey( + testUser, + Question1Transport.WALK_BUS, + Question2Waiting.MOVE_ELSEWHERE, + Question3Stay.JUST_SLEEP, + Question4Wakeup.RELAXED, + Question5Expense.EACH_PAYS, + Question6Spend.SAVE, + List.of(Question7Interest.EXHIBITION, Question7Interest.NATURE, Question7Interest.RESORT), + Question8Planning.DETAILED, + Question9Menu.SAFE, + Question10Companion.US_ONLY, + Question11Photo.EYES_ONLY + ); + + // when + TravelTendencyCalculator.TendencyScores scores = calculator.calculate(minScoreSurvey); + + // then + assertThat(scores.r()).isGreaterThanOrEqualTo(0.0); + assertThat(scores.w()).isGreaterThanOrEqualTo(0.0); + assertThat(scores.s()).isGreaterThanOrEqualTo(0.0); + assertThat(scores.p()).isGreaterThanOrEqualTo(0.0); + assertThat(scores.r()).isLessThanOrEqualTo(10.0); + assertThat(scores.w()).isLessThanOrEqualTo(10.0); + assertThat(scores.s()).isLessThanOrEqualTo(10.0); + assertThat(scores.p()).isLessThanOrEqualTo(10.0); + } + + @Test + @DisplayName("Q10_완전환영선택시_S크게증가") + void Q10_완전환영선택시_S크게증가() { + // given + Survey welcomeSurvey = Survey.createSurvey( + testUser, + Question1Transport.WALK_BUS, Question2Waiting.WAIT, Question3Stay.HOTEL, + Question4Wakeup.EARLY, Question5Expense.EACH_PAYS, Question6Spend.SAVE, + List.of(Question7Interest.SIGHTSEEING, Question7Interest.NATURE, Question7Interest.RESORT), + Question8Planning.FLEXIBLE, Question9Menu.SAFE, + Question10Companion.WELCOME, + Question11Photo.EYES_ONLY + ); + + Survey onlyUsSurvey = Survey.createSurvey( + testUser, + Question1Transport.WALK_BUS, Question2Waiting.WAIT, Question3Stay.HOTEL, + Question4Wakeup.EARLY, Question5Expense.EACH_PAYS, Question6Spend.SAVE, + List.of(Question7Interest.SIGHTSEEING, Question7Interest.NATURE, Question7Interest.RESORT), + Question8Planning.FLEXIBLE, Question9Menu.SAFE, + Question10Companion.US_ONLY, + Question11Photo.EYES_ONLY + ); + + // when + TravelTendencyCalculator.TendencyScores welcomeScores = calculator.calculate(welcomeSurvey); + TravelTendencyCalculator.TendencyScores onlyUsScores = calculator.calculate(onlyUsSurvey); + + // then + assertThat(welcomeScores.s()).isGreaterThan(onlyUsScores.s() + 4.0); + } +} diff --git a/src/test/java/com/dduru/gildongmu/user/service/UserServiceTest.java b/src/test/java/com/dduru/gildongmu/user/service/UserServiceTest.java index 9635844c..48ce864c 100644 --- a/src/test/java/com/dduru/gildongmu/user/service/UserServiceTest.java +++ b/src/test/java/com/dduru/gildongmu/user/service/UserServiceTest.java @@ -1,8 +1,9 @@ package com.dduru.gildongmu.user.service; -import com.dduru.gildongmu.user.dto.NicknameRandomResponse; -import com.dduru.gildongmu.user.repository.UserRepository; -import com.dduru.gildongmu.user.utils.NicknameGenerator; +import com.dduru.gildongmu.profile.repository.ProfileRepository; +import com.dduru.gildongmu.profile.dto.NicknameRandomResponse; +import com.dduru.gildongmu.profile.service.ProfileService; +import com.dduru.gildongmu.profile.utils.NicknameGenerator; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -23,10 +24,10 @@ class UserServiceTest { private NicknameGenerator nicknameGenerator; @Mock - private UserRepository userRepository; + private ProfileRepository profileRepository; @InjectMocks - private UserService userService; + private ProfileService profileService; @DisplayName("중복이 없는 경우 첫 시도에 랜덤 닉네임을 반환한다") @Test @@ -38,16 +39,16 @@ void randomNickname_firstTrySuccess() { when(nicknameGenerator.generateBaseNickname()).thenReturn(baseNickname); when(nicknameGenerator.generateRandomNumber()).thenReturn(randomNumber); - when(userRepository.existsByNickname(expectedNickname)).thenReturn(false); + when(profileRepository.existsByNickname(expectedNickname)).thenReturn(false); // when - NicknameRandomResponse response = userService.generateRandomNickname(); + NicknameRandomResponse response = profileService.generateRandomNickname(); // then assertThat(response.nickname()).isEqualTo(expectedNickname); verify(nicknameGenerator, times(1)).generateBaseNickname(); verify(nicknameGenerator, times(1)).generateRandomNumber(); - verify(userRepository, times(1)).existsByNickname(eq(expectedNickname)); + verify(profileRepository, times(1)).existsByNickname(eq(expectedNickname)); } @DisplayName("중복이 발생하면 재시도하여 다음 닉네임을 반환한다") @@ -62,16 +63,16 @@ void randomNickname_retriesOnDuplicate() { when(nicknameGenerator.generateBaseNickname()).thenReturn(baseNickname); when(nicknameGenerator.generateRandomNumber()).thenReturn(firstNumber, secondNumber); - when(userRepository.existsByNickname(firstNickname)).thenReturn(true); - when(userRepository.existsByNickname(secondNickname)).thenReturn(false); + when(profileRepository.existsByNickname(firstNickname)).thenReturn(true); + when(profileRepository.existsByNickname(secondNickname)).thenReturn(false); // when - NicknameRandomResponse response = userService.generateRandomNickname(); + NicknameRandomResponse response = profileService.generateRandomNickname(); // then assertThat(response.nickname()).isEqualTo(secondNickname); - verify(userRepository, times(1)).existsByNickname(eq(firstNickname)); - verify(userRepository, times(1)).existsByNickname(eq(secondNickname)); + verify(profileRepository, times(1)).existsByNickname(eq(firstNickname)); + verify(profileRepository, times(1)).existsByNickname(eq(secondNickname)); verify(nicknameGenerator, times(2)).generateRandomNumber(); } @@ -85,16 +86,16 @@ void randomNickname_returnsFallbackAfterMaxAttempts() { when(nicknameGenerator.generateBaseNickname()).thenReturn(baseNickname); when(nicknameGenerator.generateRandomNumber()).thenReturn(duplicatedNumber); - when(userRepository.existsByNickname(duplicatedNickname)).thenReturn(true); + when(profileRepository.existsByNickname(duplicatedNickname)).thenReturn(true); // when - NicknameRandomResponse response = userService.generateRandomNickname(); + NicknameRandomResponse response = profileService.generateRandomNickname(); // then assertThat(response.nickname()).startsWith("뚜비"); verify(nicknameGenerator, times(10)).generateBaseNickname(); verify(nicknameGenerator, times(10)).generateRandomNumber(); - verify(userRepository, times(10)).existsByNickname(eq(duplicatedNickname)); + verify(profileRepository, times(10)).existsByNickname(eq(duplicatedNickname)); } } diff --git a/src/test/java/com/dduru/gildongmu/verification/service/PhoneVerificationServiceTest.java b/src/test/java/com/dduru/gildongmu/verification/service/PhoneVerificationServiceTest.java index c44395e5..d9ad427f 100644 --- a/src/test/java/com/dduru/gildongmu/verification/service/PhoneVerificationServiceTest.java +++ b/src/test/java/com/dduru/gildongmu/verification/service/PhoneVerificationServiceTest.java @@ -1,7 +1,7 @@ package com.dduru.gildongmu.verification.service; import com.dduru.gildongmu.common.jwt.JwtTokenProvider; -import com.dduru.gildongmu.user.repository.UserRepository; +import com.dduru.gildongmu.profile.repository.ProfileRepository; import com.dduru.gildongmu.verification.dto.VerificationVerifyResponse; import com.dduru.gildongmu.verification.exception.DuplicatePhoneNumberException; import com.dduru.gildongmu.verification.exception.SmsSendFailedException; @@ -25,7 +25,7 @@ class PhoneVerificationServiceTest { @Mock private JwtTokenProvider jwtTokenProvider; @Mock - private UserRepository userRepository; + private ProfileRepository profileRepository; @InjectMocks private PhoneVerificationService phoneVerificationService; @@ -33,7 +33,7 @@ class PhoneVerificationServiceTest { @Test void 인증번호발송_가입된번호면_예외발생() { // given - when(userRepository.existsByPhoneNumber("01012345678")).thenReturn(true); + when(profileRepository.existsByPhoneNumber("01012345678")).thenReturn(true); // when & then assertThatThrownBy(() -> phoneVerificationService.sendVerificationCode("01012345678")) @@ -45,7 +45,7 @@ class PhoneVerificationServiceTest { @Test void 인증번호발송_SMS실패시_롤백후_예외발생() { // given - when(userRepository.existsByPhoneNumber(anyString())).thenReturn(false); + when(profileRepository.existsByPhoneNumber(anyString())).thenReturn(false); VerificationCodeService.VerificationCreateResult result = new VerificationCodeService.VerificationCreateResult("999999", java.time.LocalDateTime.now().plusMinutes(3)); when(verificationCodeService.createVerification("01022223333")).thenReturn(result);