diff --git a/src/main/java/com/moongeul/backend/api/member/controller/MemberController.java b/src/main/java/com/moongeul/backend/api/member/controller/MemberController.java index d598c4a..bf383f2 100644 --- a/src/main/java/com/moongeul/backend/api/member/controller/MemberController.java +++ b/src/main/java/com/moongeul/backend/api/member/controller/MemberController.java @@ -27,7 +27,8 @@ public class MemberController { @Operation( summary = "구글 로그인 API", - description = "구글 인가코드을 통해 사용자의 정보를 등록 및 토큰 + 역할을 발급합니다. (ROLE -> 처음사용자 : GUEST, 일반사용자 : USER, 관리자 : ADMIN)" + description = "구글 인가코드을 통해 사용자의 정보를 등록 및 토큰 + 역할을 발급합니다. " + + "

[enum]ROLE -> 처음사용자 : GUEST, 일반사용자 : USER, 관리자 : ADMIN" ) @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "로그인 성공"), @@ -61,7 +62,16 @@ public ResponseEntity> loginWithKakao(@Valid @Requ @Operation( summary = "사용자 정보 조회 API", - description = "토큰을 통해 인증된 사용자의 정보를 반환합니다." + description = "토큰을 통해 인증된 사용자의 정보를 반환합니다." + + "

[enum]독서 취향 유형 ->" + + "
- EMOTIONAL_REFLECTOR: 감성 사색 정리러" + + "
- CHATTY_READER: 수다쟁이 책러" + + "
- TREND_HUNTER: 신상 헌터" + + "
- SYSTEMATIC_READER: 정리왕 서평러" + + "
- IMMERSIVE_READER: 넷플릭스급 몰입러" + + "
- SECRET_DIARIST: 비밀 일기장 주인" + + "
- GENRE_SPECIALIST: 장르 고인물" + + "
- RANDOM_PICKER: 랜덤 피커" ) @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "사용자 정보 조회 성공"), diff --git a/src/main/java/com/moongeul/backend/api/member/controller/ReadingTasteController.java b/src/main/java/com/moongeul/backend/api/member/controller/ReadingTasteController.java new file mode 100644 index 0000000..58616a7 --- /dev/null +++ b/src/main/java/com/moongeul/backend/api/member/controller/ReadingTasteController.java @@ -0,0 +1,62 @@ +package com.moongeul.backend.api.member.controller; + +import com.moongeul.backend.api.member.dto.TestRequestDTO; +import com.moongeul.backend.api.member.dto.TestResponseDTO; +import com.moongeul.backend.api.member.service.ReadingTasteService; +import com.moongeul.backend.common.response.ApiResponse; +import com.moongeul.backend.common.response.SuccessStatus; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "ReadingTaste", description = "ReadingTaste(독서취향테스트) 관련 API 입니다.") +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v2/reading-taste") +public class ReadingTasteController { + + private final ReadingTasteService readingTasteService; + + + @Operation( + summary = "독서 취향 테스트 API", + description = "독서 취향 테스트 결과를 계산하여 유형을 반환하는 API 입니다." + + "

요청 형식:" + + "
- answers: 질문 번호(1-12)와 답변(A or B)의 Map으로 전달바랍니다." + + "

**예시:**\n" + + "```json\n" + + "{\n" + + " \"answers\": {\n" + + " \"1\": \"A\",\n" + + " \"2\": \"B\",\n" + + " \"3\": \"A\",\n" + + " \"4\": \"B\",\n" + + " \"5\": \"A\",\n" + + " \"6\": \"B\",\n" + + " \"7\": \"A\",\n" + + " \"8\": \"B\",\n" + + " \"9\": \"A\",\n" + + " \"10\": \"B\",\n" + + " \"11\": \"A\",\n" + + " \"12\": \"B\"\n" + + "}\n" + + " " + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "독서 취향 테스트 결과 계산 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "답변이 비어있습니다.") + }) + @PostMapping + public ResponseEntity> calculateReadingTasteType(@AuthenticationPrincipal UserDetails userDetails, + @RequestBody TestRequestDTO testRequestDTO) { + + TestResponseDTO response = readingTasteService.calculateReadingTasteType(testRequestDTO, userDetails.getUsername()); + return ApiResponse.success(SuccessStatus.CALCULATE_READING_TASTE_SUCCESS, response); + } + +} diff --git a/src/main/java/com/moongeul/backend/api/member/dto/TestRequestDTO.java b/src/main/java/com/moongeul/backend/api/member/dto/TestRequestDTO.java new file mode 100644 index 0000000..f20375d --- /dev/null +++ b/src/main/java/com/moongeul/backend/api/member/dto/TestRequestDTO.java @@ -0,0 +1,24 @@ +package com.moongeul.backend.api.member.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.Map; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class TestRequestDTO { + + @Schema( + description = "질문 번호(1-12)와 답변(A or B) 매핑", + example = "{\"1\":\"A\",\"2\":\"A\",\"3\":\"A\",\"4\":\"A\",\"5\":\"A\",\"6\":\"A\",\"7\":\"A\",\"8\":\"A\",\"9\":\"A\",\"10\":\"A\",\"11\":\"A\",\"12\":\"A\"}" + ) + @NotNull(message = "테스트 답변 입력은 필수입니다") + private Map answers; // {1: "A", 2: "B", ...} +} diff --git a/src/main/java/com/moongeul/backend/api/member/dto/TestResponseDTO.java b/src/main/java/com/moongeul/backend/api/member/dto/TestResponseDTO.java new file mode 100644 index 0000000..37a736b --- /dev/null +++ b/src/main/java/com/moongeul/backend/api/member/dto/TestResponseDTO.java @@ -0,0 +1,16 @@ +package com.moongeul.backend.api.member.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class TestResponseDTO { + + private String readingTasteType; // 독서 취향 유형 + private String intro; // 한줄 소개 +} diff --git a/src/main/java/com/moongeul/backend/api/member/dto/UserInfoDTO.java b/src/main/java/com/moongeul/backend/api/member/dto/UserInfoDTO.java index 409f0d6..6102b22 100644 --- a/src/main/java/com/moongeul/backend/api/member/dto/UserInfoDTO.java +++ b/src/main/java/com/moongeul/backend/api/member/dto/UserInfoDTO.java @@ -1,5 +1,6 @@ package com.moongeul.backend.api.member.dto; +import com.moongeul.backend.api.member.entity.ReadingTasteType; import lombok.Builder; import lombok.Getter; @@ -11,4 +12,5 @@ public class UserInfoDTO { private final String name; // 회원 이름(실명) private final String profileImage; // 회원 이미지 private final String nickname; //닉네임 (초기랜덤생성) + private ReadingTasteType readingTasteType; // 독서 취향 유형 } diff --git a/src/main/java/com/moongeul/backend/api/member/entity/Member.java b/src/main/java/com/moongeul/backend/api/member/entity/Member.java index 7e36452..a4aa9ec 100644 --- a/src/main/java/com/moongeul/backend/api/member/entity/Member.java +++ b/src/main/java/com/moongeul/backend/api/member/entity/Member.java @@ -27,6 +27,9 @@ public class Member extends BaseTimeEntity { private String nickname; //닉네임 (초기랜덤생성) private String password; + @Enumerated(EnumType.STRING) + private ReadingTasteType readingTasteType; // 독서 취향 유형 + @Column(unique = true) private String socialId; // 소셜 로그인 ID (고유값) @@ -53,7 +56,17 @@ public Member update(String name, String picture) { return this; } + /** + * 리프레시 토큰 업데이트 + */ public void updateRefreshToken(String refreshToken) { this.refreshToken = refreshToken; } + + /** + * 독서 취향 유형 업데이트 + */ + public void updateReadingTasteType(ReadingTasteType readingTasteType) { + this.readingTasteType = readingTasteType; + } } diff --git a/src/main/java/com/moongeul/backend/api/member/entity/ReadingTasteType.java b/src/main/java/com/moongeul/backend/api/member/entity/ReadingTasteType.java new file mode 100644 index 0000000..f61b23e --- /dev/null +++ b/src/main/java/com/moongeul/backend/api/member/entity/ReadingTasteType.java @@ -0,0 +1,22 @@ +package com.moongeul.backend.api.member.entity; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ReadingTasteType { + + EMOTIONAL_REFLECTOR("감성 사색 정리러", "감성 새벽 독거노인"), + CHATTY_READER("수다쟁이 책러", "문화센터 전도사"), + TREND_HUNTER("신상 헌터", "신간과 트렌드만이 답이다"), + SYSTEMATIC_READER("정리왕 서평러", "안 쓰면 안 읽은 거다"), + IMMERSIVE_READER("넷플릭스급 몰입러", "드라마 오타쿠 몽상가"), + SECRET_DIARIST("비밀 일기장 주인", "내 기록은 흑역사, 공개 불가"), + GENRE_SPECIALIST("장르 고인물", "난 같은 장르만 조진다"), + RANDOM_PICKER("랜덤 피커", "책 선택은 운명이다"); + + + private final String name; + private final String intro; +} diff --git a/src/main/java/com/moongeul/backend/api/member/service/MemberService.java b/src/main/java/com/moongeul/backend/api/member/service/MemberService.java index 63cfd9a..8ffdd4d 100644 --- a/src/main/java/com/moongeul/backend/api/member/service/MemberService.java +++ b/src/main/java/com/moongeul/backend/api/member/service/MemberService.java @@ -111,6 +111,7 @@ public UserInfoDTO getUserInfo(String email){ .name(member.getName()) .profileImage(member.getProfileImage()) .nickname(member.getNickname()) + .readingTasteType(member.getReadingTasteType()) .build(); } diff --git a/src/main/java/com/moongeul/backend/api/member/service/ReadingTasteService.java b/src/main/java/com/moongeul/backend/api/member/service/ReadingTasteService.java new file mode 100644 index 0000000..0d9dbec --- /dev/null +++ b/src/main/java/com/moongeul/backend/api/member/service/ReadingTasteService.java @@ -0,0 +1,236 @@ +package com.moongeul.backend.api.member.service; + +import com.moongeul.backend.api.member.dto.TestRequestDTO; +import com.moongeul.backend.api.member.dto.TestResponseDTO; +import com.moongeul.backend.api.member.entity.Member; +import com.moongeul.backend.api.member.entity.ReadingTasteType; +import com.moongeul.backend.api.member.repository.MemberRepository; +import com.moongeul.backend.common.exception.BadRequestException; +import com.moongeul.backend.common.exception.NotFoundException; +import com.moongeul.backend.common.response.ErrorStatus; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.HashMap; +import java.util.Map; + +@Service +@RequiredArgsConstructor +@Slf4j +public class ReadingTasteService { + + private final MemberRepository memberRepository; + + private static final String ANSWER_A = "A"; + private static final String ANSWER_B = "B"; + + @Transactional + public TestResponseDTO calculateReadingTasteType(TestRequestDTO testRequestDTO, String email){ + + Member member = memberRepository.findByEmail(email) + .orElseThrow(() -> new NotFoundException(ErrorStatus.USER_NOTFOUND_EXCEPTION.getMessage())); + + // 테스트 결과 계산 (가장 높은 유형 반환) + ReadingTasteType type = findTopType(calculateScore(testRequestDTO.getAnswers())); + + // member 필드 readingTasteType 저장 + member.updateReadingTasteType(type); + memberRepository.save(member); + + return TestResponseDTO.builder() + .readingTasteType(type.getName()) + .intro(type.getIntro()) + .build(); + } + + // 점수표 - 입력 + private Map calculateScore(Map answers){ + + // 테스트 요청 데이터 검증(에러처리) + validateAnswers(answers); + + Map typeScores = new HashMap<>(); + + for(Map.Entry entry : answers.entrySet()){ + int questionNo = entry.getKey(); + String answer = entry.getValue(); + + switch (questionNo) { + case 1: + if(answer.equals(ANSWER_A)){ + addScore(typeScores, ReadingTasteType.EMOTIONAL_REFLECTOR, 6.01); + addScore(typeScores, ReadingTasteType.SECRET_DIARIST, 3.99); + } else if(answer.equals(ANSWER_B)){ + addScore(typeScores, ReadingTasteType.IMMERSIVE_READER, 6.50); + addScore(typeScores, ReadingTasteType.GENRE_SPECIALIST, 2.50); + } + break; + case 2: + if(answer.equals(ANSWER_A)){ + addScore(typeScores, ReadingTasteType.GENRE_SPECIALIST, 6.01); + addScore(typeScores, ReadingTasteType.SECRET_DIARIST, 4.99); + } else if(answer.equals(ANSWER_B)){ + addScore(typeScores, ReadingTasteType.TREND_HUNTER, 6.01); + addScore(typeScores, ReadingTasteType.CHATTY_READER, 3.99); + addScore(typeScores, ReadingTasteType.RANDOM_PICKER, 2.99); + } + break; + case 3: + if(answer.equals(ANSWER_A)){ + addScore(typeScores, ReadingTasteType.RANDOM_PICKER, 6.01); + addScore(typeScores, ReadingTasteType.TREND_HUNTER, 2.99); + } else if(answer.equals(ANSWER_B)){ + addScore(typeScores, ReadingTasteType.SYSTEMATIC_READER, 6.01); + addScore(typeScores, ReadingTasteType.GENRE_SPECIALIST, 3.99); + } + break; + case 4: + if(answer.equals(ANSWER_A)){ + addScore(typeScores, ReadingTasteType.TREND_HUNTER, 6.01); + addScore(typeScores, ReadingTasteType.RANDOM_PICKER, 3.99); + } else if(answer.equals(ANSWER_B)){ + addScore(typeScores, ReadingTasteType.GENRE_SPECIALIST, 6.01); + addScore(typeScores, ReadingTasteType.EMOTIONAL_REFLECTOR, 4.49); + } + break; + case 5: + if(answer.equals(ANSWER_A)){ + addScore(typeScores, ReadingTasteType.EMOTIONAL_REFLECTOR, 6.51); + addScore(typeScores, ReadingTasteType.IMMERSIVE_READER, 4.49); + } else if(answer.equals(ANSWER_B)){ + addScore(typeScores, ReadingTasteType.CHATTY_READER, 6.51); + addScore(typeScores, ReadingTasteType.RANDOM_PICKER, 3.49); + } + break; + case 6: + if(answer.equals(ANSWER_A)){ + addScore(typeScores, ReadingTasteType.SYSTEMATIC_READER, 5.51); + addScore(typeScores, ReadingTasteType.EMOTIONAL_REFLECTOR, 4.49); + } else if(answer.equals(ANSWER_B)){ + addScore(typeScores, ReadingTasteType.TREND_HUNTER, 5.51); + addScore(typeScores, ReadingTasteType.RANDOM_PICKER, 4.49); + } + break; + case 7: + if(answer.equals(ANSWER_A)){ + addScore(typeScores, ReadingTasteType.SYSTEMATIC_READER, 7.01); + addScore(typeScores, ReadingTasteType.SECRET_DIARIST, 3.99); + } else if(answer.equals(ANSWER_B)){ + addScore(typeScores, ReadingTasteType.CHATTY_READER, 6.01); + addScore(typeScores, ReadingTasteType.TREND_HUNTER, 2.50); + } + break; + case 8: + if(answer.equals(ANSWER_A)){ + addScore(typeScores, ReadingTasteType.SECRET_DIARIST, 7.50); + addScore(typeScores, ReadingTasteType.SYSTEMATIC_READER, 3.50); + } else if(answer.equals(ANSWER_B)){ + addScore(typeScores, ReadingTasteType.CHATTY_READER, 7.50); + addScore(typeScores, ReadingTasteType.TREND_HUNTER, 2.50); + } + break; + case 9: + if(answer.equals(ANSWER_A)){ + addScore(typeScores, ReadingTasteType.CHATTY_READER, 6.01); + addScore(typeScores, ReadingTasteType.GENRE_SPECIALIST, 4.01); + } else if(answer.equals(ANSWER_B)){ + addScore(typeScores, ReadingTasteType.IMMERSIVE_READER, 6.01); + addScore(typeScores, ReadingTasteType.SECRET_DIARIST, 4.01); + } + break; + case 10: + if(answer.equals(ANSWER_A)){ + addScore(typeScores, ReadingTasteType.RANDOM_PICKER, 5.51); + addScore(typeScores, ReadingTasteType.GENRE_SPECIALIST, 3.49); + } else if(answer.equals(ANSWER_B)){ + addScore(typeScores, ReadingTasteType.SYSTEMATIC_READER, 7.51); + addScore(typeScores, ReadingTasteType.SECRET_DIARIST, 3.49); + } + break; + case 11: + if(answer.equals(ANSWER_A)){ + addScore(typeScores, ReadingTasteType.IMMERSIVE_READER, 6.01); + addScore(typeScores, ReadingTasteType.GENRE_SPECIALIST, 3.99); + } else if(answer.equals(ANSWER_B)){ + addScore(typeScores, ReadingTasteType.EMOTIONAL_REFLECTOR, 6.00); + addScore(typeScores, ReadingTasteType.SECRET_DIARIST, 4.00); + } + break; + case 12: + if(answer.equals(ANSWER_A)){ + addScore(typeScores, ReadingTasteType.TREND_HUNTER, 6.01); + addScore(typeScores, ReadingTasteType.RANDOM_PICKER, 4.99); + } else if(answer.equals(ANSWER_B)){ + addScore(typeScores, ReadingTasteType.IMMERSIVE_READER, 6.00); + addScore(typeScores, ReadingTasteType.EMOTIONAL_REFLECTOR, 4.00); + } + break; + default: + log.error("잘못된 questionNo(1-12) 입력: {}번", questionNo); + throw new BadRequestException( + String.format(ErrorStatus.INVALID_QUESTION_NUMBER.getMessage(), questionNo) + ); + } + } + + return typeScores; + } + + // 검증 로직(에러처리) 분리 + private void validateAnswers(Map answers){ + // 비어 있는 답변(null 또는 empty 체크) + if(answers == null || answers.isEmpty()){ + log.error("테스트 요청 데이터가 비어있음."); + throw new BadRequestException(ErrorStatus.EMPTY_TEST_ANSWERS.getMessage()); + } + + // 답변 개수 체크 + if(answers.size() != 12) { + log.error("테스트 답변 개수(총 12개) 부족 (현재 답변 개수: {}개)", answers.size()); + throw new BadRequestException( + String.format(ErrorStatus.INCOMPLETE_TEST_ANSWERS.getMessage(), answers.size()) + ); + } + + // A/B 체크 + for(Map.Entry entry : answers.entrySet()){ + int questionNo = entry.getKey(); + String answer = entry.getValue(); + + if(!answer.equals(ANSWER_A) && !answer.equals(ANSWER_B)){ + log.error("테스트 답변은 A 또는 B여야 합니다.(현재 질문번호: {}, 답변: {})", questionNo, answer); + throw new BadRequestException( + String.format(ErrorStatus.INVALID_ANSWER_VALUE.getMessage(), questionNo, answer) + ); + } + } + } + + // 점수 계산 + private void addScore(Map typeScores, + ReadingTasteType type, double score){ + typeScores.put(type, typeScores.getOrDefault(type, 0.0) + score); + } + + // 가장 점수가 높은 type 찾기 + private ReadingTasteType findTopType(Map typeScores){ + ReadingTasteType topType = null; + double maxScore = Double.MIN_VALUE; // 가장 작은 값으로 시작 + + for (Map.Entry entry : typeScores.entrySet()) { + if (entry.getValue() > maxScore) { + maxScore = entry.getValue(); + topType = entry.getKey(); + } + } + + if (topType == null) { + log.info("점수 계산 결과가 없음, topType = {}", topType); + throw new BadRequestException("점수 계산 결과가 없습니다."); + } + + return topType; + } +} diff --git a/src/main/java/com/moongeul/backend/common/response/ErrorStatus.java b/src/main/java/com/moongeul/backend/common/response/ErrorStatus.java index 747953d..e6ab88b 100644 --- a/src/main/java/com/moongeul/backend/common/response/ErrorStatus.java +++ b/src/main/java/com/moongeul/backend/common/response/ErrorStatus.java @@ -19,6 +19,12 @@ public enum ErrorStatus { INVALID_TOKEN_REQUEST(HttpStatus.BAD_REQUEST, "잘못된 토큰 요청입니다."), INVALID_INFO_REQUEST(HttpStatus.BAD_REQUEST, "잘못된 로그인 인증 요청입니다."), + EMPTY_TEST_ANSWERS(HttpStatus.BAD_REQUEST, "답변이 비어있습니다."), + INCOMPLETE_TEST_ANSWERS(HttpStatus.BAD_REQUEST, "12개 질문에 모두 답변해야 합니다. (현재: %d개)"), + INVALID_QUESTION_NUMBER(HttpStatus.BAD_REQUEST, "테스트에 잘못된 questionNo(질문번호)가 입력되었습니다. (질문번호: %d)"), + INVALID_ANSWER_VALUE(HttpStatus.BAD_REQUEST, "테스트 답변은 A 또는 B여야 합니다. (질문번호: %d, 현재답변: %s)"), + NO_SCORE_RESULT(HttpStatus.BAD_REQUEST, "테스트 점수 계산 결과가 없습니다."), + /** * 401 UNAUTHORIZED */ diff --git a/src/main/java/com/moongeul/backend/common/response/SuccessStatus.java b/src/main/java/com/moongeul/backend/common/response/SuccessStatus.java index 0059761..cd8fa20 100644 --- a/src/main/java/com/moongeul/backend/common/response/SuccessStatus.java +++ b/src/main/java/com/moongeul/backend/common/response/SuccessStatus.java @@ -18,6 +18,7 @@ public enum SuccessStatus { SEND_LOGIN_SUCCESS(HttpStatus.OK, "로그인 성공"), GET_USERINFO_SUCCESS(HttpStatus.OK, "사용자 정보 조회 성공"), REISSUE_TOKEN_SUCCESS(HttpStatus.OK, "토큰 재발급 성공"), + CALCULATE_READING_TASTE_SUCCESS(HttpStatus.OK, "독서 취향 테스트 결과 계산 성공"), /* BOOK */ SEARCH_BOOK_SUCCESS(HttpStatus.OK, "도서 검색 성공"),