diff --git a/build.gradle b/build.gradle index f680a92..17ca273 100644 --- a/build.gradle +++ b/build.gradle @@ -31,6 +31,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'org.springframework.boot:spring-boot-starter-webflux' // google api client implementation 'com.google.api-client:google-api-client:2.4.0' diff --git a/src/main/java/com/edu/kobridge/global/common/filter/JwtAuthorizationFilter.java b/src/main/java/com/edu/kobridge/global/common/filter/JwtAuthorizationFilter.java index 477044e..d900e3e 100644 --- a/src/main/java/com/edu/kobridge/global/common/filter/JwtAuthorizationFilter.java +++ b/src/main/java/com/edu/kobridge/global/common/filter/JwtAuthorizationFilter.java @@ -8,7 +8,7 @@ import com.edu.kobridge.global.error.exception.FilterException; import com.edu.kobridge.global.util.JwtUtil; import com.edu.kobridge.global.util.ResponseUtil; -import com.edu.kobridge.user.domain.entity.User; +import com.edu.kobridge.module.user.domain.entity.User; import io.jsonwebtoken.ExpiredJwtException; import io.jsonwebtoken.JwtException; @@ -31,6 +31,7 @@ public class JwtAuthorizationFilter implements Filter { // JWT 검사 제외할 경로 설정 final String LOGIN_PATH = "/api/user/google-login"; final String TOKEN_PATH = "/api/user/token"; + final String LESSON_PATH = "/api/lesson/chat"; @Override public void init(FilterConfig filterConfig) throws ServletException { @@ -54,13 +55,16 @@ public void doFilter(ServletRequest request, ServletResponse response, FilterCha return; } - // 로그인 및 토큰 재발급 요청은 JWT 인증 필터링 없이 처리 + // 로그인 및 토큰 재발급 요청, 레슨 시도는 JWT 인증 필터링 없이 처리 if (requestURI.equals(LOGIN_PATH) && req.getMethod().equals("GET")) { chain.doFilter(request, response); return; } else if (requestURI.equals(TOKEN_PATH) && req.getMethod().equals("GET")) { chain.doFilter(request, response); return; + } else if (requestURI.startsWith(LESSON_PATH)) { + chain.doFilter(request, response); + return; } else if (requestURI.contains("test/no-auth")) { chain.doFilter(request, response); return; diff --git a/src/main/java/com/edu/kobridge/global/config/WebClientConfig.java b/src/main/java/com/edu/kobridge/global/config/WebClientConfig.java new file mode 100644 index 0000000..d3b1152 --- /dev/null +++ b/src/main/java/com/edu/kobridge/global/config/WebClientConfig.java @@ -0,0 +1,30 @@ +package com.edu.kobridge.global.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.web.reactive.function.client.WebClient; + +@Configuration +public class WebClientConfig { + + @Bean + public WebClient.Builder webClientBuilder() { + return WebClient.builder() + .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE); + } + + @Bean(name = "pronunciationWebClient") + public WebClient pronunciationWebClient(WebClient.Builder builder, + @Value("${api.e-pre-tx.url}") String baseUrl) { + return builder.baseUrl(baseUrl).build(); + } + + @Bean(name = "chatGptWebClient") + public WebClient chatGptWebClient(WebClient.Builder builder, + @Value("${api.chat-gpt.url}") String baseUrl) { + return builder.baseUrl(baseUrl).build(); + } +} diff --git a/src/main/java/com/edu/kobridge/global/enums/LangType.java b/src/main/java/com/edu/kobridge/global/enums/LangType.java index 8d34f10..403c67d 100644 --- a/src/main/java/com/edu/kobridge/global/enums/LangType.java +++ b/src/main/java/com/edu/kobridge/global/enums/LangType.java @@ -19,11 +19,11 @@ public enum LangType { this.code = code; this.name = name; } - - public static UserRoleType of(String code) { - return Arrays.stream(UserRoleType.values()) + + public static LangType of(String code) { + return Arrays.stream(LangType.values()) .filter(r -> r.getCode().equals(code)) .findAny() - .orElse(null); + .orElse(ENG); } } diff --git a/src/main/java/com/edu/kobridge/global/util/FileUtil.java b/src/main/java/com/edu/kobridge/global/util/FileUtil.java new file mode 100644 index 0000000..c645494 --- /dev/null +++ b/src/main/java/com/edu/kobridge/global/util/FileUtil.java @@ -0,0 +1,29 @@ +package com.edu.kobridge.global.util; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.util.Base64; + +import org.springframework.stereotype.Component; + +import com.edu.kobridge.global.error.GlobalErrorCode; +import com.edu.kobridge.global.error.exception.AppException; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Component +@RequiredArgsConstructor +@Slf4j +public class FileUtil { + public String convertS3UrlToBase64(String s3Url) { + try (InputStream in = new URL(s3Url).openStream()) { + byte[] fileBytes = in.readAllBytes(); + return Base64.getEncoder().encodeToString(fileBytes); + } catch (IOException e) { + log.error("[FileUtil] file convert error -- " + e.getMessage()); + throw new AppException(GlobalErrorCode.INTERNAL_SERVER_ERROR); + } + } +} diff --git a/src/main/java/com/edu/kobridge/global/util/GoogleOAuthUtil.java b/src/main/java/com/edu/kobridge/global/util/GoogleOAuthUtil.java index 17c7029..e9191e3 100644 --- a/src/main/java/com/edu/kobridge/global/util/GoogleOAuthUtil.java +++ b/src/main/java/com/edu/kobridge/global/util/GoogleOAuthUtil.java @@ -8,8 +8,8 @@ import org.springframework.stereotype.Component; import com.edu.kobridge.global.error.exception.AppException; -import com.edu.kobridge.user.domain.entity.User; -import com.edu.kobridge.user.error.UserErrorCode; +import com.edu.kobridge.module.user.domain.entity.User; +import com.edu.kobridge.module.user.error.UserErrorCode; import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken; import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier; import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport; diff --git a/src/main/java/com/edu/kobridge/global/util/JwtUtil.java b/src/main/java/com/edu/kobridge/global/util/JwtUtil.java index 7d94507..0a4ebc7 100644 --- a/src/main/java/com/edu/kobridge/global/util/JwtUtil.java +++ b/src/main/java/com/edu/kobridge/global/util/JwtUtil.java @@ -11,8 +11,8 @@ import com.edu.kobridge.global.enums.JwtVo; import com.edu.kobridge.global.error.GlobalErrorCode; import com.edu.kobridge.global.error.exception.AppException; -import com.edu.kobridge.user.domain.entity.User; -import com.edu.kobridge.user.domain.repository.UserRepository; +import com.edu.kobridge.module.user.domain.entity.User; +import com.edu.kobridge.module.user.domain.repository.UserRepository; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Header; diff --git a/src/main/java/com/edu/kobridge/infra/api/chatgpt/ChatGptService.java b/src/main/java/com/edu/kobridge/infra/api/chatgpt/ChatGptService.java new file mode 100644 index 0000000..fc780b9 --- /dev/null +++ b/src/main/java/com/edu/kobridge/infra/api/chatgpt/ChatGptService.java @@ -0,0 +1,91 @@ +package com.edu.kobridge.infra.api.chatgpt; + +import java.util.List; +import java.util.Map; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatusCode; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.reactive.function.client.WebClient; + +import com.edu.kobridge.global.enums.LangType; +import com.edu.kobridge.global.error.exception.AppException; +import com.edu.kobridge.infra.api.chatgpt.error.ChatGptErrorCode; +import com.edu.kobridge.infra.api.chatgpt.res.ChatGptCorrectionResDto; +import com.edu.kobridge.infra.api.chatgpt.res.ChatGptResDto; +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import reactor.core.publisher.Mono; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +@Slf4j +public class ChatGptService { + + @Value("${api.chat-gpt.key}") + private String key; + + @Value("${api.chat-gpt.model}") + private String model; + + private final WebClient chatGptWebClient; + + public ChatGptCorrectionResDto postAnswerCorrectionAndResponse(String userSentence, Boolean isNextChatExist, + LangType lang) { + String userPrompt = String.format( + "User sentence: %s | NextChat: %s | Language: %s:", + userSentence, isNextChatExist, lang.getName() + ); + + Map request = Map.of( + "model", model, + "input", List.of( + Map.of("role", "system", "content", + "너는 한국어 학습자를 위한 교정 선생님이자 친근한 대화 파트너야. " + + "항상 아래 JSON 형식으로만 답해. 출력은 반드시 한국어로 해.\n\n" + + "{ \"correction\": \"교정된 한국어 문장\", " + + "\"reason\": \"제공하는 교정 이유 (선생님 톤)\", " + + "\"translation\": \"Language 로 번역된 correction\", " + + "\"response\": \"교정된 문장에 대한 답변 / isNextChatExist 이 false 이면 답변 후 대화 마무리\"}\n\n" + + + "규칙:\n" + + "- correction / reason → 선생님 톤\n" + + "- response → 친구처럼 친근한 톤\n" + + "- 반드시 유효한 JSON만 출력하고, JSON 외의 다른 텍스트는 출력하지 마." + ), + Map.of("role", "user", "content", userPrompt) + ), + "temperature", 0.7 + ); + + ObjectMapper objectMapper = new ObjectMapper(); + try { + ChatGptResDto chatGptRes = chatGptWebClient.post() + .header("Authorization", "Bearer " + key) + .bodyValue(request) + .retrieve() + .onStatus(HttpStatusCode::isError, resp -> + resp.bodyToMono(String.class) + .flatMap(body -> { + log.error("[ChatGPT API] connection error -- Status: {}, Body: {}", + resp.statusCode(), body); + return Mono.error(new AppException(ChatGptErrorCode.CHAT_GPT_CHAT_API_FAILED)); + }) + ) + .bodyToMono(ChatGptResDto.class) + .block(); + + String jsonContent = chatGptRes.getOutput().get(0).getContent().get(0).getText(); + + return objectMapper.readValue(jsonContent, ChatGptCorrectionResDto.class); + + } catch (Exception e) { + log.error("[ChatGPT Chat API] internal error -- " + e.getMessage(), e); + throw new AppException(ChatGptErrorCode.CHAT_GPT_CHAT_API_FAILED); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/edu/kobridge/infra/api/chatgpt/error/ChatGptErrorCode.java b/src/main/java/com/edu/kobridge/infra/api/chatgpt/error/ChatGptErrorCode.java new file mode 100644 index 0000000..1712cfa --- /dev/null +++ b/src/main/java/com/edu/kobridge/infra/api/chatgpt/error/ChatGptErrorCode.java @@ -0,0 +1,20 @@ +package com.edu.kobridge.infra.api.chatgpt.error; + +import org.springframework.http.HttpStatus; + +import com.edu.kobridge.global.error.ErrorCode; + +import lombok.Getter; + +@Getter +public enum ChatGptErrorCode implements ErrorCode { + CHAT_GPT_CHAT_API_FAILED(HttpStatus.SERVICE_UNAVAILABLE, "chat gpt chat 답변 기능 사용 불가"); + + private final HttpStatus httpStatus; + private final String message; + + ChatGptErrorCode(HttpStatus httpStatus, String message) { + this.httpStatus = httpStatus; + this.message = message; + } +} diff --git a/src/main/java/com/edu/kobridge/infra/api/chatgpt/req/ChatGptReqDto.java b/src/main/java/com/edu/kobridge/infra/api/chatgpt/req/ChatGptReqDto.java new file mode 100644 index 0000000..de73931 --- /dev/null +++ b/src/main/java/com/edu/kobridge/infra/api/chatgpt/req/ChatGptReqDto.java @@ -0,0 +1,35 @@ +package com.edu.kobridge.infra.api.chatgpt.req; + +import java.util.List; + +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder(access = AccessLevel.PRIVATE) +public class ChatGptReqDto { + private final String model; + private final List messages; + + @Getter + @Builder(access = AccessLevel.PRIVATE) + public static class Message { + private final String role; + private final String content; + + public static Message of(String role, String content) { + return Message.builder() + .role(role) + .content(content) + .build(); + } + } + + public static ChatGptReqDto of(String model, List messages) { + return ChatGptReqDto.builder() + .model(model) + .messages(messages) + .build(); + } +} diff --git a/src/main/java/com/edu/kobridge/infra/api/chatgpt/res/ChatGptCorrectionResDto.java b/src/main/java/com/edu/kobridge/infra/api/chatgpt/res/ChatGptCorrectionResDto.java new file mode 100644 index 0000000..61073d2 --- /dev/null +++ b/src/main/java/com/edu/kobridge/infra/api/chatgpt/res/ChatGptCorrectionResDto.java @@ -0,0 +1,15 @@ +package com.edu.kobridge.infra.api.chatgpt.res; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ChatGptCorrectionResDto { + private String correction; + private String reason; + private String translation; + private String response; +} diff --git a/src/main/java/com/edu/kobridge/infra/api/chatgpt/res/ChatGptResDto.java b/src/main/java/com/edu/kobridge/infra/api/chatgpt/res/ChatGptResDto.java new file mode 100644 index 0000000..aa9a046 --- /dev/null +++ b/src/main/java/com/edu/kobridge/infra/api/chatgpt/res/ChatGptResDto.java @@ -0,0 +1,53 @@ +package com.edu.kobridge.infra.api.chatgpt.res; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class ChatGptResDto { + private String id; + private String object; + private long created_at; + private String status; + private List output; + private Usage usage; + + @Data + @NoArgsConstructor + @AllArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public static class Output { + private String id; + private String type; + private String status; + private List content; + private String role; + } + + @Data + @NoArgsConstructor + @AllArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public static class Content { + private String type; + private String text; // JSON 문자열 그대로 저장 + } + + @Data + @NoArgsConstructor + @AllArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public static class Usage { + private int input_tokens; + private int output_tokens; + private int total_tokens; + } +} \ No newline at end of file diff --git a/src/main/java/com/edu/kobridge/infra/api/epretx/PronunciationEvaluationService.java b/src/main/java/com/edu/kobridge/infra/api/epretx/PronunciationEvaluationService.java new file mode 100644 index 0000000..877c334 --- /dev/null +++ b/src/main/java/com/edu/kobridge/infra/api/epretx/PronunciationEvaluationService.java @@ -0,0 +1,75 @@ +package com.edu.kobridge.infra.api.epretx; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClientResponseException; + +import com.edu.kobridge.global.error.exception.AppException; +import com.edu.kobridge.infra.api.epretx.error.PronunciationErrorCode; +import com.edu.kobridge.infra.api.epretx.req.PronunciationReqDto; +import com.edu.kobridge.infra.api.epretx.res.PronunciationResDto; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +@Slf4j +public class PronunciationEvaluationService { + + @Value("${api.e-pre-tx.uri}") + private String uri; + + @Value("${api.e-pre-tx.key}") + private String key; + + private final WebClient pronunciationWebClient; + + public PronunciationResDto checkPronunciation(String script, String audioBase64) { + PronunciationReqDto pronunciationReq = new PronunciationReqDto( + new PronunciationReqDto.Argument("korean", script, audioBase64) + ); + + try { + return pronunciationWebClient.post() + .uri(uri) + .header("Authorization", key) + .bodyValue(pronunciationReq) + .retrieve() + .bodyToMono(PronunciationResDto.class) + .block(); + + } catch (WebClientResponseException e) { + switch (e.getStatusCode().value()) { + case 403: // FORBIDDEN + log.error("[Pronunciation API] api key or else -- {}", e.getResponseBodyAsString()); + throw new AppException(PronunciationErrorCode.PRONUNCIATION_INVALID_KEY); + + case 408: // REQUEST_TIMEOUT + throw new AppException(PronunciationErrorCode.PRONUNCIATION_TIMEOUT); + + case 413: // PAYLOAD_TOO_LARGE + throw new AppException(PronunciationErrorCode.PRONUNCIATION_BODY_TOO_LARGE); + + case 429: // TOO_MANY_REQUESTS + throw new AppException(PronunciationErrorCode.PRONUNCIATION_LIMIT_EXCEEDED); + + default: + if (e.getStatusCode().is5xxServerError()) { + log.error("[Pronunciation API] external server error -- {}", e.getResponseBodyAsString()); + throw new AppException(PronunciationErrorCode.PRONUNCIATION_API_SERVER_ERROR); + } else { + log.error("[Pronunciation API] unexpected error -- {}", e.getResponseBodyAsString(), e); + throw new AppException(PronunciationErrorCode.PRONUNCIATION_EVALUATION_API_FAILED); + } + } + + } catch (Exception e) { + log.error("[Pronunciation API] internal error -- " + e.getMessage()); + throw new AppException(PronunciationErrorCode.PRONUNCIATION_EVALUATION_API_FAILED); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/edu/kobridge/infra/api/epretx/error/PronunciationErrorCode.java b/src/main/java/com/edu/kobridge/infra/api/epretx/error/PronunciationErrorCode.java new file mode 100644 index 0000000..350ad25 --- /dev/null +++ b/src/main/java/com/edu/kobridge/infra/api/epretx/error/PronunciationErrorCode.java @@ -0,0 +1,25 @@ +package com.edu.kobridge.infra.api.epretx.error; + +import org.springframework.http.HttpStatus; + +import com.edu.kobridge.global.error.ErrorCode; + +import lombok.Getter; + +@Getter +public enum PronunciationErrorCode implements ErrorCode { + PRONUNCIATION_INVALID_KEY(HttpStatus.FORBIDDEN, "API 키 문제 또는 접근 권한 없음"), + PRONUNCIATION_TIMEOUT(HttpStatus.REQUEST_TIMEOUT, "발음 평가 API 요청 시간 초과"), + PRONUNCIATION_LIMIT_EXCEEDED(HttpStatus.TOO_MANY_REQUESTS, "호출 제한 횟수 초과"), + PRONUNCIATION_BODY_TOO_LARGE(HttpStatus.PAYLOAD_TOO_LARGE, "전송된 오디오 데이터가 너무 큼"), + PRONUNCIATION_API_SERVER_ERROR(HttpStatus.BAD_GATEWAY, "외부 발음 평가 서버 오류"), + PRONUNCIATION_EVALUATION_API_FAILED(HttpStatus.SERVICE_UNAVAILABLE, "발음 평가 서비스 사용 불가"); + + private final HttpStatus httpStatus; + private final String message; + + PronunciationErrorCode(HttpStatus httpStatus, String message) { + this.httpStatus = httpStatus; + this.message = message; + } +} diff --git a/src/main/java/com/edu/kobridge/infra/api/epretx/req/PronunciationReqDto.java b/src/main/java/com/edu/kobridge/infra/api/epretx/req/PronunciationReqDto.java new file mode 100644 index 0000000..eb585dd --- /dev/null +++ b/src/main/java/com/edu/kobridge/infra/api/epretx/req/PronunciationReqDto.java @@ -0,0 +1,21 @@ +package com.edu.kobridge.infra.api.epretx.req; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class PronunciationReqDto { + private Argument argument; + + @Data + @AllArgsConstructor + @NoArgsConstructor + public static class Argument { + private String language_code; + private String script; + private String audio; + } +} diff --git a/src/main/java/com/edu/kobridge/infra/api/epretx/res/PronunciationResDto.java b/src/main/java/com/edu/kobridge/infra/api/epretx/res/PronunciationResDto.java new file mode 100644 index 0000000..22c7ff0 --- /dev/null +++ b/src/main/java/com/edu/kobridge/infra/api/epretx/res/PronunciationResDto.java @@ -0,0 +1,17 @@ +package com.edu.kobridge.infra.api.epretx.res; + +import lombok.Data; + +@Data +public class PronunciationResDto { + private String request_id; + private int result; + private String return_type; + private ReturnObject return_object; + + @Data + public static class ReturnObject { + private String recognized; + private float score; + } +} diff --git a/src/main/java/com/edu/kobridge/module/lesson/controller/LessonController.java b/src/main/java/com/edu/kobridge/module/lesson/controller/LessonController.java new file mode 100644 index 0000000..1b0787e --- /dev/null +++ b/src/main/java/com/edu/kobridge/module/lesson/controller/LessonController.java @@ -0,0 +1,58 @@ +package com.edu.kobridge.module.lesson.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestAttribute; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.edu.kobridge.global.common.DataResponseDto; +import com.edu.kobridge.global.common.ResponseDto; +import com.edu.kobridge.module.lesson.dto.req.ChatCorrectionReqDto; +import com.edu.kobridge.module.lesson.dto.req.PronunciationEvaluationReqDto; +import com.edu.kobridge.module.lesson.dto.res.ChatCorrectionResDto; +import com.edu.kobridge.module.lesson.dto.res.LessonListResDto; +import com.edu.kobridge.module.lesson.dto.res.LessonSentenceListResDto; +import com.edu.kobridge.module.lesson.service.LessonService; +import com.edu.kobridge.module.user.domain.entity.User; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@RestController +@RequestMapping("/api/lesson") +@RequiredArgsConstructor +@Slf4j +public class LessonController implements LessonControllerDocs { + private final LessonService lessonService; + + @GetMapping("/{id}") + public ResponseEntity getLessonDetail(@RequestAttribute("user") User user, + @PathVariable("id") Long id) { + LessonSentenceListResDto resDto = lessonService.getLessonDetail(user, id); + return ResponseEntity.status(200).body(DataResponseDto.of(resDto, 200)); + } + + @GetMapping + public ResponseEntity getLessonList(@RequestAttribute("user") User user) { + LessonListResDto resDto = lessonService.getLessonList(user); + return ResponseEntity.status(200).body(DataResponseDto.of(resDto, 200)); + } + + @PostMapping("/sentence/{id}/pronunciation-evaluation") + public ResponseEntity postPronunciationEvaluationValue(@PathVariable("id") Long id, + @RequestBody PronunciationEvaluationReqDto pronunciationEvaluationReq) { + Integer res = lessonService.postPronunciationEvaluationValue(id, pronunciationEvaluationReq); + return ResponseEntity.status(200).body(DataResponseDto.of(res, 200)); + } + + @PostMapping("/chat/{id}/correction") + public ResponseEntity postChatCorrection(@PathVariable("id") Long id, + @RequestBody ChatCorrectionReqDto chatCorrectionReq) { + ChatCorrectionResDto resDto = lessonService.postChatCorrection(id, chatCorrectionReq); + return ResponseEntity.status(200).body(DataResponseDto.of(resDto, 200)); + } +} diff --git a/src/main/java/com/edu/kobridge/module/lesson/controller/LessonControllerDocs.java b/src/main/java/com/edu/kobridge/module/lesson/controller/LessonControllerDocs.java new file mode 100644 index 0000000..f8fd529 --- /dev/null +++ b/src/main/java/com/edu/kobridge/module/lesson/controller/LessonControllerDocs.java @@ -0,0 +1,184 @@ +package com.edu.kobridge.module.lesson.controller; + +import org.springframework.http.ResponseEntity; + +import com.edu.kobridge.global.common.ResponseDto; +import com.edu.kobridge.module.lesson.dto.req.ChatCorrectionReqDto; +import com.edu.kobridge.module.lesson.dto.req.PronunciationEvaluationReqDto; +import com.edu.kobridge.module.user.domain.entity.User; + +import io.swagger.v3.oas.annotations.Operation; +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.tags.Tag; + +@Tag(name = "Lesson", description = "학습 관련 API") +public interface LessonControllerDocs { + + @Operation(summary = "레슨 상세 조회", description = "레슨 별 문장과 주제를 확인할 수 있습니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Ok", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ResponseDto.class), + examples = + @ExampleObject(value = "{\n" + + " \"code\": 200,\n" + + " \"message\": \"OK\",\n" + + " \"data\": {\n" + + " \"subject\": \"오늘은 학교에 가는 상황에서 자주 쓰는 표현들을 배워봐요. 교실에서 친구와 이야기할 때 유용하게 쓸 수 있어요.\",\n" + + " \"lessonSentences\": [\n" + + " {\n" + + " \"id\": 1,\n" + + " \"sentence\": \"우리 1교시 뭐야?\",\n" + + " \"translation\": \"What is our first class?\",\n" + + " \"pronunciation\": \"Uri il-gyosi mwoya?\"\n" + + " },\n" + + " {\n" + + " \"id\": 2,\n" + + " \"sentence\": \"수업 언제 시작해?\",\n" + + " \"translation\": \"When does the class start?\",\n" + + " \"pronunciation\": \"Sueop eonje sijakhae?\"\n" + + " },\n" + + " {\n" + + " \"id\": 3,\n" + + " \"sentence\": \"오늘 숙제 다 했어?\",\n" + + " \"translation\": \"Did you finish today’s homework?\",\n" + + " \"pronunciation\": \"Oneul sukjje da haesseo?\"\n" + + " }\n" + + " ],\n" + + " \"startChat\": {\n" + + " \"id\": 3,\n" + + " \"question\": \"안녕~ 오늘 첫 수업 뭐였지?\",\n" + + " \"questionTrans\": \"Hi~ What was the first class today?\"\n" + + " }\n" + + " }\n" + + "}") + ) + ), + @ApiResponse(responseCode = "404", description = "해당 자원을 찾을 수 없습니다.", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ResponseDto.class), + examples = @ExampleObject(value = "[" + + "{ \"code\": 401, \"message\": \"해당하는 레슨을 찾을 수 없습니다.\" }," + + "{ \"code\": 404, \"message\": \"해당하는 질문을 찾을 수 없습니다.\" }" + + "]" + ) + ) + ) + }) + public ResponseEntity getLessonDetail(User user, Long id); + + @Operation(summary = "레슨 전체 조회", description = "전체 레슨 리스트를 확인할 수있습니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Ok", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ResponseDto.class), + examples = + @ExampleObject(value = "{\n" + + " \"code\": 200,\n" + + " \"message\": \"OK\",\n" + + " \"data\": {\n" + + " \"level\": 1,\n" + + " \"lessons\": [\n" + + " {\n" + + " \"id\": 1,\n" + + " \"number\": 1,\n" + + " \"title\": \"학교에 가자!\",\n" + + " \"subTitle1\": \"사용할 문장 알아볼까?\",\n" + + " \"subTitle2\": \"같이 대화해볼까?\",\n" + + " \"subTitle3\": \"오늘의 레슨은 어땠어?\"\n" + + " },\n" + + " {\n" + + " \"id\": 2,\n" + + " \"number\": 2,\n" + + " \"title\": \"친구랑 인사하기\",\n" + + " \"subTitle1\": \"친구랑 어떻게 말할까?\",\n" + + " \"subTitle2\": \"인사 연습해보자!\",\n" + + " \"subTitle3\": \"오늘 배운 인사, 어땠어?\"\n" + + " }, ...\n" + + " \n" + + " {\n" + + " \"id\": 15,\n" + + " \"number\": 15,\n" + + " \"title\": \"미래 꿈 이야기\",\n" + + " \"subTitle1\": \"꿈을 어떻게 말할까?\",\n" + + " \"subTitle2\": \"내 꿈 얘기해보자!\",\n" + + " \"subTitle3\": \"꿈 이야기 표현은 쉬웠어?\"\n" + + " }\n" + + " ]\n" + + " }\n" + + "}") + ) + ) + }) + public ResponseEntity getLessonList(User user); + + @Operation(summary = "발음 평가", description = "발음에 대한 별점을 확인할 수 있습니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Ok", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ResponseDto.class), + examples = + @ExampleObject(value = "{\n" + + " \"code\": 200,\n" + + " \"message\": \"OK\",\n" + + " \"data\": 3\n" + + "}") + ) + ), + @ApiResponse(responseCode = "404", description = "해당 자원을 찾을 수 없습니다.", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ResponseDto.class), + examples = @ExampleObject(value = "{ \"code\": 404, \"message\": \"해당하는 레슨 문장을 찾을 수 없습니다.\" }" + ) + ) + ) + }) + public ResponseEntity postPronunciationEvaluationValue(Long id, + PronunciationEvaluationReqDto audioUrl); + + @Operation(summary = "대화 답변 분석 및 다음 질문 확인", description = "사용자 답변의 번역, 교정본, 이유 및 다음 답변의 질문, 번역본 등을 제공한다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Ok", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ResponseDto.class), + examples = + @ExampleObject(value = "{\n" + + " \"code\": 200,\n" + + " \"message\": \"OK\",\n" + + " \"data\": {\n" + + " \"answerTrans\": \"So, what is the first class today?\",\n" + + " \"correction\": \"그러게 오늘 1교시가 뭐지?\",\n" + + " \"reason\": \"‘워지’는 맞지 않아서 ‘뭐지’로 수정했어요. 그리고 1굣은 1교시로 바꿔야 해요.\",\n" + + " \"response\": \"응, 오늘 1교시 수업 국어야!\",\n" + + " \"nextChat\": {\n" + + " \"id\": 2,\n" + + " \"question\": \"우리 쉬는 시간 거의 끝났다..\",\n" + + " \"questionTrans\": \"Our break is almost over..\"\n" + + " }\n" + + " }\n" + + "}") + ) + ), + @ApiResponse(responseCode = "404", description = "해당 자원을 찾을 수 없습니다.", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ResponseDto.class), + examples = @ExampleObject(value = "{ \"code\": 404, \"message\": \"해당하는 질문을 찾을 수 없습니다.\" }" + ) + ) + ) + }) + public ResponseEntity postChatCorrection(Long id, ChatCorrectionReqDto chatCorrectionReq); + +} + diff --git a/src/main/java/com/edu/kobridge/module/lesson/domain/entity/Lesson.java b/src/main/java/com/edu/kobridge/module/lesson/domain/entity/Lesson.java new file mode 100644 index 0000000..2369c49 --- /dev/null +++ b/src/main/java/com/edu/kobridge/module/lesson/domain/entity/Lesson.java @@ -0,0 +1,56 @@ +package com.edu.kobridge.module.lesson.domain.entity; + +import java.util.List; + +import com.edu.kobridge.global.common.BaseTime; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; +import jakarta.persistence.OrderBy; +import jakarta.validation.constraints.NotNull; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder(access = AccessLevel.PRIVATE) +public class Lesson extends BaseTime { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @NotNull + @Column(unique = true) + private Integer number; + + @NotNull + private String title; + + @NotNull + private String subject; + + @NotNull + private String subTitle1; + + @NotNull + private String subTitle2; + + @NotNull + private String subTitle3; + + @OneToMany(mappedBy = "lesson", cascade = CascadeType.ALL, orphanRemoval = true) + @OrderBy("number ASC") + private List sentences; + +} diff --git a/src/main/java/com/edu/kobridge/module/lesson/domain/entity/LessonChat.java b/src/main/java/com/edu/kobridge/module/lesson/domain/entity/LessonChat.java new file mode 100644 index 0000000..18c31a6 --- /dev/null +++ b/src/main/java/com/edu/kobridge/module/lesson/domain/entity/LessonChat.java @@ -0,0 +1,62 @@ +package com.edu.kobridge.module.lesson.domain.entity; + +import com.edu.kobridge.global.common.BaseTime; +import com.edu.kobridge.global.enums.LangType; + +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToOne; +import jakarta.validation.constraints.NotNull; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder(access = AccessLevel.PRIVATE) +public class LessonChat extends BaseTime { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @NotNull + private String contentKo; + + @NotNull + private String contentEng; + + @NotNull + private String contentVet; + + @NotNull + private String contentJpn; + + @NotNull + private String contentChn; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "next_chat_id", unique = true) + private LessonChat nextChat; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "lesson_sentence_id", nullable = false, unique = true) + private LessonSentence lessonSentence; + + public String getTransByLang(LangType langType) { + return switch (langType) { + case VET -> contentVet; + case JPN -> contentJpn; + case CHN -> contentChn; + default -> contentEng; + }; + } +} diff --git a/src/main/java/com/edu/kobridge/module/lesson/domain/entity/LessonSentence.java b/src/main/java/com/edu/kobridge/module/lesson/domain/entity/LessonSentence.java new file mode 100644 index 0000000..6d770ae --- /dev/null +++ b/src/main/java/com/edu/kobridge/module/lesson/domain/entity/LessonSentence.java @@ -0,0 +1,82 @@ +package com.edu.kobridge.module.lesson.domain.entity; + +import com.edu.kobridge.global.common.BaseTime; +import com.edu.kobridge.global.enums.LangType; + +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.validation.constraints.NotNull; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder(access = AccessLevel.PRIVATE) +public class LessonSentence extends BaseTime { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @NotNull + private Integer number; + + @NotNull + private String contentKo; + + @NotNull + private String contentEng; + + @NotNull + private String contentVet; + + @NotNull + private String contentJpn; + + @NotNull + private String contentChn; + + @NotNull + private String pronunciationEng; + + @NotNull + private String pronunciationVet; + + @NotNull + private String pronunciationJpn; + + @NotNull + private String pronunciationChn; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "lesson_id", nullable = false) + private Lesson lesson; + + public String getPronunciationByLang(LangType langType) { + return switch (langType) { + case VET -> pronunciationVet; + case JPN -> pronunciationJpn; + case CHN -> pronunciationChn; + default -> pronunciationEng; + }; + } + + public String getTransByLang(LangType langType) { + return switch (langType) { + case VET -> contentVet; + case JPN -> contentJpn; + case CHN -> contentChn; + default -> contentEng; + }; + } +} diff --git a/src/main/java/com/edu/kobridge/module/lesson/domain/repository/LessonChatRepository.java b/src/main/java/com/edu/kobridge/module/lesson/domain/repository/LessonChatRepository.java new file mode 100644 index 0000000..3987e58 --- /dev/null +++ b/src/main/java/com/edu/kobridge/module/lesson/domain/repository/LessonChatRepository.java @@ -0,0 +1,13 @@ +package com.edu.kobridge.module.lesson.domain.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import com.edu.kobridge.module.lesson.domain.entity.LessonChat; + +@Repository +public interface LessonChatRepository extends JpaRepository { + Optional findByLessonSentenceId(Long lessonSentenceId); +} diff --git a/src/main/java/com/edu/kobridge/module/lesson/domain/repository/LessonRepository.java b/src/main/java/com/edu/kobridge/module/lesson/domain/repository/LessonRepository.java new file mode 100644 index 0000000..7f864be --- /dev/null +++ b/src/main/java/com/edu/kobridge/module/lesson/domain/repository/LessonRepository.java @@ -0,0 +1,13 @@ +package com.edu.kobridge.module.lesson.domain.repository; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import com.edu.kobridge.module.lesson.domain.entity.Lesson; + +@Repository +public interface LessonRepository extends JpaRepository { + public List findAllByOrderByNumberAsc(); +} diff --git a/src/main/java/com/edu/kobridge/module/lesson/domain/repository/LessonSentenceRepository.java b/src/main/java/com/edu/kobridge/module/lesson/domain/repository/LessonSentenceRepository.java new file mode 100644 index 0000000..4a210cd --- /dev/null +++ b/src/main/java/com/edu/kobridge/module/lesson/domain/repository/LessonSentenceRepository.java @@ -0,0 +1,10 @@ +package com.edu.kobridge.module.lesson.domain.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import com.edu.kobridge.module.lesson.domain.entity.LessonSentence; + +@Repository +public interface LessonSentenceRepository extends JpaRepository { +} diff --git a/src/main/java/com/edu/kobridge/module/lesson/dto/req/ChatCorrectionReqDto.java b/src/main/java/com/edu/kobridge/module/lesson/dto/req/ChatCorrectionReqDto.java new file mode 100644 index 0000000..b6d8f3f --- /dev/null +++ b/src/main/java/com/edu/kobridge/module/lesson/dto/req/ChatCorrectionReqDto.java @@ -0,0 +1,12 @@ +package com.edu.kobridge.module.lesson.dto.req; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +@Schema(description = "채팅 답변 교정 요청 DTO") +public record ChatCorrectionReqDto( + @Schema(description = "채팅 답변", example = "만나서 반가워!") + @NotBlank(message = "답변 값은 필수입니다.") + String answer +) { +} diff --git a/src/main/java/com/edu/kobridge/module/lesson/dto/req/PronunciationEvaluationReqDto.java b/src/main/java/com/edu/kobridge/module/lesson/dto/req/PronunciationEvaluationReqDto.java new file mode 100644 index 0000000..e37da53 --- /dev/null +++ b/src/main/java/com/edu/kobridge/module/lesson/dto/req/PronunciationEvaluationReqDto.java @@ -0,0 +1,12 @@ +package com.edu.kobridge.module.lesson.dto.req; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +@Schema(description = "발음 평가 요청 DTO") +public record PronunciationEvaluationReqDto( + @Schema(description = "오디오 url", example = "https://{buket-url}/pronumciation-evaluation-audio/{audio-url-name}.m4a") + @NotBlank(message = "오디오 url은 필수 값입니다.") + String audioUrl +) { +} diff --git a/src/main/java/com/edu/kobridge/module/lesson/dto/res/ChatCorrectionResDto.java b/src/main/java/com/edu/kobridge/module/lesson/dto/res/ChatCorrectionResDto.java new file mode 100644 index 0000000..f2d0ab3 --- /dev/null +++ b/src/main/java/com/edu/kobridge/module/lesson/dto/res/ChatCorrectionResDto.java @@ -0,0 +1,36 @@ +package com.edu.kobridge.module.lesson.dto.res; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder(access = AccessLevel.PRIVATE) +public class ChatCorrectionResDto { + @Schema(description = "번역된 문장", example = "I know. When does class start?") + private final String answerTrans; + + @Schema(description = "교정된 문장", example = "그러게~ 수업 언제 시작해?") + private final String correction; + + @Schema(description = "교정된 이유", example = "은제’는 ‘언제’로 교정해야 해요. '시작햐'는 '시작해'로 수정해야 해요.") + private final String reason; + + @Schema(description = "답변에 대한 대답", example = "곧 시작할 것 같아~") + private final String response; + + @Schema(description = "다음 chat 정보") + private final ChatResDto nextChat; + + public static ChatCorrectionResDto of(String answerTrans, String correction, String reason, String response, + ChatResDto chatRes) { + return ChatCorrectionResDto.builder() + .answerTrans(answerTrans) + .correction(correction) + .reason(reason) + .response(response) + .nextChat(chatRes) + .build(); + } +} diff --git a/src/main/java/com/edu/kobridge/module/lesson/dto/res/ChatResDto.java b/src/main/java/com/edu/kobridge/module/lesson/dto/res/ChatResDto.java new file mode 100644 index 0000000..1fda378 --- /dev/null +++ b/src/main/java/com/edu/kobridge/module/lesson/dto/res/ChatResDto.java @@ -0,0 +1,27 @@ +package com.edu.kobridge.module.lesson.dto.res; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder(access = AccessLevel.PRIVATE) +public class ChatResDto { + @Schema(description = "chat id", example = "1") + private final Long id; + + @Schema(description = "질문", example = "숙제 있었는데, 너 다 했어?") + private final String question; + + @Schema(description = "질문 번역본", example = "There was homework, did you finish it?") + private final String questionTrans; + + public static ChatResDto of(Long id, String question, String questionTrans) { + return ChatResDto.builder() + .id(id) + .question(question) + .questionTrans(questionTrans) + .build(); + } +} diff --git a/src/main/java/com/edu/kobridge/module/lesson/dto/res/LessonBriefResDto.java b/src/main/java/com/edu/kobridge/module/lesson/dto/res/LessonBriefResDto.java new file mode 100644 index 0000000..e2ec43e --- /dev/null +++ b/src/main/java/com/edu/kobridge/module/lesson/dto/res/LessonBriefResDto.java @@ -0,0 +1,40 @@ +package com.edu.kobridge.module.lesson.dto.res; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder(access = AccessLevel.PRIVATE) +public class LessonBriefResDto { + @Schema(description = "레슨 id", example = "1") + private final Long id; + + @Schema(description = "레슨 순서", example = "1") + private final int number; + + @Schema(description = "레슨 제목", example = "") + private final String title; + + @Schema(description = "레슨 1단계 제목(따라하기)", example = "") + private final String subTitle1; + + @Schema(description = "레슨 2단계 제목(대화하기)", example = "") + private final String subTitle2; + + @Schema(description = "레슨 3단계 제목(평가하기)", example = "") + private final String subTitle3; + + public static LessonBriefResDto of(Long id, int number, String title, String subTitle1, String subTitle2, + String subTitle3) { + return LessonBriefResDto.builder() + .id(id) + .number(number) + .title(title) + .subTitle1(subTitle1) + .subTitle2(subTitle2) + .subTitle3(subTitle3) + .build(); + } +} diff --git a/src/main/java/com/edu/kobridge/module/lesson/dto/res/LessonListResDto.java b/src/main/java/com/edu/kobridge/module/lesson/dto/res/LessonListResDto.java new file mode 100644 index 0000000..b8dd220 --- /dev/null +++ b/src/main/java/com/edu/kobridge/module/lesson/dto/res/LessonListResDto.java @@ -0,0 +1,25 @@ +package com.edu.kobridge.module.lesson.dto.res; + +import java.util.List; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder(access = AccessLevel.PRIVATE) +public class LessonListResDto { + @Schema(description = "현재 레벨", example = "1") + private final int level; + + @Schema(description = "레슨 전체 리스트") + private final List lessons; + + public static LessonListResDto of(int level, List lessons) { + return LessonListResDto.builder() + .level(level) + .lessons(lessons) + .build(); + } +} diff --git a/src/main/java/com/edu/kobridge/module/lesson/dto/res/LessonSentenceListResDto.java b/src/main/java/com/edu/kobridge/module/lesson/dto/res/LessonSentenceListResDto.java new file mode 100644 index 0000000..8d417c1 --- /dev/null +++ b/src/main/java/com/edu/kobridge/module/lesson/dto/res/LessonSentenceListResDto.java @@ -0,0 +1,30 @@ +package com.edu.kobridge.module.lesson.dto.res; + +import java.util.List; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder(access = AccessLevel.PRIVATE) +public class LessonSentenceListResDto { + @Schema(description = "주제", example = "오늘은 학교에 가는 상황에서 자주 쓰는 표현들을 배워봐요. 교실에서 친구와 이야기할 때 유용하게 쓸 수 있어요.") + private final String subject; + + @Schema(description = "레슨 문장 리스트") + private final List lessonSentences; + + @Schema(description = "대화 시작 chat 정보") + private final ChatResDto startChat; + + public static LessonSentenceListResDto of(String subject, List lessonSentences, + ChatResDto chatRes) { + return LessonSentenceListResDto.builder() + .subject(subject) + .lessonSentences(lessonSentences) + .startChat(chatRes) + .build(); + } +} diff --git a/src/main/java/com/edu/kobridge/module/lesson/dto/res/LessonSentenceResDto.java b/src/main/java/com/edu/kobridge/module/lesson/dto/res/LessonSentenceResDto.java new file mode 100644 index 0000000..7481e7f --- /dev/null +++ b/src/main/java/com/edu/kobridge/module/lesson/dto/res/LessonSentenceResDto.java @@ -0,0 +1,32 @@ +package com.edu.kobridge.module.lesson.dto.res; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder(access = AccessLevel.PRIVATE) +public class LessonSentenceResDto { + + @Schema(description = "문장 id", example = "1") + private final Long id; + + @Schema(description = "오늘의 문장", example = "이름이 뭐야?") + private final String sentence; + + @Schema(description = "문장 번역", example = "What's your name?") + private final String translation; + + @Schema(description = "문장 발음", example = "I-reum-i mwoya?") + private final String pronunciation; + + public static LessonSentenceResDto of(Long id, String sentence, String translation, String pronunciation) { + return LessonSentenceResDto.builder() + .id(id) + .sentence(sentence) + .translation(translation) + .pronunciation(pronunciation) + .build(); + } +} diff --git a/src/main/java/com/edu/kobridge/module/lesson/error/LessonErrorCode.java b/src/main/java/com/edu/kobridge/module/lesson/error/LessonErrorCode.java new file mode 100644 index 0000000..e135de9 --- /dev/null +++ b/src/main/java/com/edu/kobridge/module/lesson/error/LessonErrorCode.java @@ -0,0 +1,23 @@ +package com.edu.kobridge.module.lesson.error; + +import org.springframework.http.HttpStatus; + +import com.edu.kobridge.global.error.ErrorCode; + +import lombok.Getter; + +@Getter +public enum LessonErrorCode implements ErrorCode { + + LESSON_CHAT_NOT_FOUND(HttpStatus.NOT_FOUND, "해당하는 질문을 찾을 수 없습니다."), + LESSON_SENTENCE_NOT_FOUND(HttpStatus.NOT_FOUND, "해당하는 레슨 문장을 찾을 수 없습니다."), + LESSON_NOT_FOUND(HttpStatus.NOT_FOUND, "해당하는 레슨을 찾을 수 없습니다."); + + private final HttpStatus httpStatus; + private final String message; + + LessonErrorCode(HttpStatus httpStatus, String message) { + this.httpStatus = httpStatus; + this.message = message; + } +} diff --git a/src/main/java/com/edu/kobridge/module/lesson/service/LessonService.java b/src/main/java/com/edu/kobridge/module/lesson/service/LessonService.java new file mode 100644 index 0000000..8d87364 --- /dev/null +++ b/src/main/java/com/edu/kobridge/module/lesson/service/LessonService.java @@ -0,0 +1,137 @@ +package com.edu.kobridge.module.lesson.service; + +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.edu.kobridge.global.enums.LangType; +import com.edu.kobridge.global.error.exception.AppException; +import com.edu.kobridge.global.util.FileUtil; +import com.edu.kobridge.infra.api.chatgpt.ChatGptService; +import com.edu.kobridge.infra.api.chatgpt.res.ChatGptCorrectionResDto; +import com.edu.kobridge.infra.api.epretx.PronunciationEvaluationService; +import com.edu.kobridge.module.lesson.domain.entity.Lesson; +import com.edu.kobridge.module.lesson.domain.entity.LessonChat; +import com.edu.kobridge.module.lesson.domain.entity.LessonSentence; +import com.edu.kobridge.module.lesson.domain.repository.LessonChatRepository; +import com.edu.kobridge.module.lesson.domain.repository.LessonRepository; +import com.edu.kobridge.module.lesson.domain.repository.LessonSentenceRepository; +import com.edu.kobridge.module.lesson.dto.req.ChatCorrectionReqDto; +import com.edu.kobridge.module.lesson.dto.req.PronunciationEvaluationReqDto; +import com.edu.kobridge.module.lesson.dto.res.ChatCorrectionResDto; +import com.edu.kobridge.module.lesson.dto.res.ChatResDto; +import com.edu.kobridge.module.lesson.dto.res.LessonBriefResDto; +import com.edu.kobridge.module.lesson.dto.res.LessonListResDto; +import com.edu.kobridge.module.lesson.dto.res.LessonSentenceListResDto; +import com.edu.kobridge.module.lesson.dto.res.LessonSentenceResDto; +import com.edu.kobridge.module.lesson.error.LessonErrorCode; +import com.edu.kobridge.module.user.domain.entity.User; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +@Slf4j +public class LessonService { + private final LessonRepository lessonRepository; + private final LessonSentenceRepository lessonSentenceRepository; + private final LessonChatRepository lessonChatRepository; + private final PronunciationEvaluationService pronunciationEvaluationService; + private final ChatGptService chatGptService; + private final FileUtil fileUtil; + + // 레슨 상세 조회 + public LessonSentenceListResDto getLessonDetail(User user, Long id) { + // lesson 번호 유효성 검증 + Lesson lesson = lessonRepository.findById(id) + .orElseThrow(() -> new AppException(LessonErrorCode.LESSON_NOT_FOUND)); + + // lesson sentence list 변환하여 dto 에 담기 + List lessonSentences = lesson.getSentences().stream() + .map(sentence -> LessonSentenceResDto.of( + sentence.getId(), + sentence.getContentKo(), + sentence.getTransByLang(user.getLang()), + sentence.getPronunciationByLang(user.getLang()) + )) + .toList(); + + // lesson sentence 첫번째로 발화시킬 lesson chat 가져오기 + LessonChat lessonChat = lessonChatRepository.findByLessonSentenceId(lessonSentences.get(0).getId()) + .orElseThrow(() -> new AppException(LessonErrorCode.LESSON_CHAT_NOT_FOUND)); + + return LessonSentenceListResDto.of(lesson.getSubject(), lessonSentences, + ChatResDto.of(lessonChat.getId(), lessonChat.getContentKo(), lessonChat.getTransByLang(user.getLang()))); + } + + // 레슨 전체 간단 조회 + public LessonListResDto getLessonList(User user) { + // TODO entity 변경에 따라 trans 부분 추가로 매핑해서 전달하기 + + return LessonListResDto.of( + user.getLevel(), + lessonRepository.findAllByOrderByNumberAsc().stream() + .map(lesson -> LessonBriefResDto.of( + lesson.getId(), + lesson.getNumber(), + lesson.getTitle(), + lesson.getSubTitle1(), + lesson.getSubTitle2(), + lesson.getSubTitle3() + )) + .toList() + ); + } + + // 발음 평가 + public Integer postPronunciationEvaluationValue(Long id, PronunciationEvaluationReqDto pronunciationEvaluationReq) { + // sentence id 검증 + LessonSentence lessonSentence = lessonSentenceRepository.findById(id) + .orElseThrow(() -> new AppException(LessonErrorCode.LESSON_SENTENCE_NOT_FOUND)); + + // audio url 을 base 64 값으로 전환 + String audioBase64Url = fileUtil.convertS3UrlToBase64(pronunciationEvaluationReq.audioUrl()); + log.error(audioBase64Url); + + // 외부 api 호출 + float score = pronunciationEvaluationService + .checkPronunciation(lessonSentence.getContentKo(), audioBase64Url) + .getReturn_object() + .getScore(); + + // 소수점 첫째 자리에서 반올림 → 정수 + return Math.round(score); + } + + // 대화 답변 교정 및 전달 + public ChatCorrectionResDto postChatCorrection(Long id, ChatCorrectionReqDto chatCorrectionReq) { + // chat id 검증 + LessonChat lessonChat = lessonChatRepository.findById(id) + .orElseThrow(() -> new AppException(LessonErrorCode.LESSON_CHAT_NOT_FOUND)); + + // 다음 채팅 정보 가져오기 + LessonChat nextChat = lessonChat.getNextChat(); + Boolean isNextChatExist = nextChat != null; + + // TODO : user 받아와서 Lang 매핑 + + // chat gpt 호출 및 응답 확인 + ChatGptCorrectionResDto chatGptCorrectionRes = chatGptService.postAnswerCorrectionAndResponse( + chatCorrectionReq.answer(), isNextChatExist, LangType.ENG); + + // 결과 반환 + return ChatCorrectionResDto.of( + chatGptCorrectionRes.getTranslation(), + chatGptCorrectionRes.getCorrection(), + chatGptCorrectionRes.getReason(), + chatGptCorrectionRes.getResponse(), + nextChat == null ? null : + ChatResDto.of(nextChat.getId(), nextChat.getContentKo(), + nextChat.getTransByLang(LangType.ENG)) + ); + } + +} diff --git a/src/main/java/com/edu/kobridge/user/controller/UserController.java b/src/main/java/com/edu/kobridge/module/user/controller/UserController.java similarity index 90% rename from src/main/java/com/edu/kobridge/user/controller/UserController.java rename to src/main/java/com/edu/kobridge/module/user/controller/UserController.java index 7cc6348..508eb58 100644 --- a/src/main/java/com/edu/kobridge/user/controller/UserController.java +++ b/src/main/java/com/edu/kobridge/module/user/controller/UserController.java @@ -1,4 +1,4 @@ -package com.edu.kobridge.user.controller; +package com.edu.kobridge.module.user.controller; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; @@ -14,10 +14,10 @@ import com.edu.kobridge.global.common.DataResponseDto; import com.edu.kobridge.global.common.ResponseDto; import com.edu.kobridge.global.enums.LangType; -import com.edu.kobridge.user.domain.entity.User; -import com.edu.kobridge.user.dto.req.SignUpReqDto; -import com.edu.kobridge.user.dto.res.LoginResDto; -import com.edu.kobridge.user.service.UserService; +import com.edu.kobridge.module.user.domain.entity.User; +import com.edu.kobridge.module.user.dto.req.SignUpReqDto; +import com.edu.kobridge.module.user.dto.res.LoginResDto; +import com.edu.kobridge.module.user.service.UserService; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/com/edu/kobridge/user/controller/UserControllerDocs.java b/src/main/java/com/edu/kobridge/module/user/controller/UserControllerDocs.java similarity index 98% rename from src/main/java/com/edu/kobridge/user/controller/UserControllerDocs.java rename to src/main/java/com/edu/kobridge/module/user/controller/UserControllerDocs.java index a96ff63..40f4330 100644 --- a/src/main/java/com/edu/kobridge/user/controller/UserControllerDocs.java +++ b/src/main/java/com/edu/kobridge/module/user/controller/UserControllerDocs.java @@ -1,11 +1,11 @@ -package com.edu.kobridge.user.controller; +package com.edu.kobridge.module.user.controller; import org.springframework.http.ResponseEntity; import com.edu.kobridge.global.common.ResponseDto; import com.edu.kobridge.global.enums.LangType; -import com.edu.kobridge.user.domain.entity.User; -import com.edu.kobridge.user.dto.req.SignUpReqDto; +import com.edu.kobridge.module.user.domain.entity.User; +import com.edu.kobridge.module.user.dto.req.SignUpReqDto; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; diff --git a/src/main/java/com/edu/kobridge/user/domain/entity/User.java b/src/main/java/com/edu/kobridge/module/user/domain/entity/User.java similarity index 97% rename from src/main/java/com/edu/kobridge/user/domain/entity/User.java rename to src/main/java/com/edu/kobridge/module/user/domain/entity/User.java index 8f594ba..c32d6fd 100644 --- a/src/main/java/com/edu/kobridge/user/domain/entity/User.java +++ b/src/main/java/com/edu/kobridge/module/user/domain/entity/User.java @@ -1,4 +1,4 @@ -package com.edu.kobridge.user.domain.entity; +package com.edu.kobridge.module.user.domain.entity; import com.edu.kobridge.global.common.BaseTime; import com.edu.kobridge.global.enums.LangType; diff --git a/src/main/java/com/edu/kobridge/user/domain/repository/UserRepository.java b/src/main/java/com/edu/kobridge/module/user/domain/repository/UserRepository.java similarity index 70% rename from src/main/java/com/edu/kobridge/user/domain/repository/UserRepository.java rename to src/main/java/com/edu/kobridge/module/user/domain/repository/UserRepository.java index c03bda2..ef64bac 100644 --- a/src/main/java/com/edu/kobridge/user/domain/repository/UserRepository.java +++ b/src/main/java/com/edu/kobridge/module/user/domain/repository/UserRepository.java @@ -1,11 +1,11 @@ -package com.edu.kobridge.user.domain.repository; +package com.edu.kobridge.module.user.domain.repository; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; -import com.edu.kobridge.user.domain.entity.User; +import com.edu.kobridge.module.user.domain.entity.User; @Repository public interface UserRepository extends JpaRepository { diff --git a/src/main/java/com/edu/kobridge/user/dto/req/SignUpReqDto.java b/src/main/java/com/edu/kobridge/module/user/dto/req/SignUpReqDto.java similarity index 92% rename from src/main/java/com/edu/kobridge/user/dto/req/SignUpReqDto.java rename to src/main/java/com/edu/kobridge/module/user/dto/req/SignUpReqDto.java index 8569fa4..8fbd395 100644 --- a/src/main/java/com/edu/kobridge/user/dto/req/SignUpReqDto.java +++ b/src/main/java/com/edu/kobridge/module/user/dto/req/SignUpReqDto.java @@ -1,4 +1,4 @@ -package com.edu.kobridge.user.dto.req; +package com.edu.kobridge.module.user.dto.req; import com.edu.kobridge.global.enums.LangType; import com.edu.kobridge.global.enums.SchoolType; @@ -36,7 +36,7 @@ public record SignUpReqDto( @Max(value = 6, message = "학년은 6 이하여야 합니다.") byte grade, - @Schema(description = "음성", example = "BASIC, CUSTOM") + @Schema(description = "음성", example = "ONE, TWO, THREE, FOUR") @NotNull(message = "음성은 필수 값입니다.") VoiceType voice ) { diff --git a/src/main/java/com/edu/kobridge/user/dto/res/LoginResDto.java b/src/main/java/com/edu/kobridge/module/user/dto/res/LoginResDto.java similarity index 95% rename from src/main/java/com/edu/kobridge/user/dto/res/LoginResDto.java rename to src/main/java/com/edu/kobridge/module/user/dto/res/LoginResDto.java index e059245..f641ae8 100644 --- a/src/main/java/com/edu/kobridge/user/dto/res/LoginResDto.java +++ b/src/main/java/com/edu/kobridge/module/user/dto/res/LoginResDto.java @@ -1,4 +1,4 @@ -package com.edu.kobridge.user.dto.res; +package com.edu.kobridge.module.user.dto.res; import io.swagger.v3.oas.annotations.media.Schema; import lombok.AccessLevel; diff --git a/src/main/java/com/edu/kobridge/user/dto/res/UserResDto.java b/src/main/java/com/edu/kobridge/module/user/dto/res/UserResDto.java similarity index 95% rename from src/main/java/com/edu/kobridge/user/dto/res/UserResDto.java rename to src/main/java/com/edu/kobridge/module/user/dto/res/UserResDto.java index ce76ee0..ec38c3d 100644 --- a/src/main/java/com/edu/kobridge/user/dto/res/UserResDto.java +++ b/src/main/java/com/edu/kobridge/module/user/dto/res/UserResDto.java @@ -1,4 +1,4 @@ -package com.edu.kobridge.user.dto.res; +package com.edu.kobridge.module.user.dto.res; import com.edu.kobridge.global.enums.LangType; import com.edu.kobridge.global.enums.VoiceType; diff --git a/src/main/java/com/edu/kobridge/user/error/UserErrorCode.java b/src/main/java/com/edu/kobridge/module/user/error/UserErrorCode.java similarity index 93% rename from src/main/java/com/edu/kobridge/user/error/UserErrorCode.java rename to src/main/java/com/edu/kobridge/module/user/error/UserErrorCode.java index 12b37e2..cec5158 100644 --- a/src/main/java/com/edu/kobridge/user/error/UserErrorCode.java +++ b/src/main/java/com/edu/kobridge/module/user/error/UserErrorCode.java @@ -1,4 +1,4 @@ -package com.edu.kobridge.user.error; +package com.edu.kobridge.module.user.error; import org.springframework.http.HttpStatus; diff --git a/src/main/java/com/edu/kobridge/user/service/UserService.java b/src/main/java/com/edu/kobridge/module/user/service/UserService.java similarity index 90% rename from src/main/java/com/edu/kobridge/user/service/UserService.java rename to src/main/java/com/edu/kobridge/module/user/service/UserService.java index 9ff5e85..03a165a 100644 --- a/src/main/java/com/edu/kobridge/user/service/UserService.java +++ b/src/main/java/com/edu/kobridge/module/user/service/UserService.java @@ -1,4 +1,4 @@ -package com.edu.kobridge.user.service; +package com.edu.kobridge.module.user.service; import java.io.IOException; import java.security.GeneralSecurityException; @@ -15,12 +15,12 @@ import com.edu.kobridge.global.util.GoogleOAuthUtil; import com.edu.kobridge.global.util.JwtUtil; import com.edu.kobridge.global.util.RedisUtil; -import com.edu.kobridge.user.domain.entity.User; -import com.edu.kobridge.user.domain.repository.UserRepository; -import com.edu.kobridge.user.dto.req.SignUpReqDto; -import com.edu.kobridge.user.dto.res.LoginResDto; -import com.edu.kobridge.user.dto.res.UserResDto; -import com.edu.kobridge.user.error.UserErrorCode; +import com.edu.kobridge.module.user.domain.entity.User; +import com.edu.kobridge.module.user.domain.repository.UserRepository; +import com.edu.kobridge.module.user.dto.req.SignUpReqDto; +import com.edu.kobridge.module.user.dto.res.LoginResDto; +import com.edu.kobridge.module.user.dto.res.UserResDto; +import com.edu.kobridge.module.user.error.UserErrorCode; import io.jsonwebtoken.ExpiredJwtException; import io.jsonwebtoken.JwtException;