diff --git a/backend/.gitignore b/backend/.gitignore index 24b4a39..812c42e 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -37,4 +37,9 @@ out/ .vscode/ -/src/main/resources/** +/src/main/resources/*.yml +/src/main/resources/*.key +/src/main/resources/*.pub +/src/main/resources/*.properties +/src/main/resources/*.st + diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..8eb18f3 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,15 @@ +## 깃 컨벤션 + +- feat: 새로운 기능 추가 +- fix: 버그 수정 +- docs: 문서 수정 +- style: 코드 스타일 변경 (코드 포매팅, 세미콜론 누락 등) +- design: 사용자 UI 디자인 변경 (CSS 등) +- test: 테스트 코드, 리팩토링 (Test Code) +- refactor: 리팩토링 (Production Code) +- build: 빌드 파일 수정 +- ci: CI 설정 파일 수정 +- perf: 성능 개선 +- chore: 자잘한 수정이나 빌드 업데이트 +- rename: 파일 혹은 폴더명을 수정만 한 경우 +- remove: 파일을 삭제만 한 경우 diff --git a/backend/build.gradle b/backend/build.gradle index 7d88203..99914cd 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -20,7 +20,7 @@ version = '0.0.1-SNAPSHOT' jib { from { - image = 'openjdk:21-jdk' + image = 'bellsoft/liberica-openjdk-alpine:21' } to { @@ -39,6 +39,7 @@ jib { container { jvmFlags = [ + '-Duser.timezone="Asia/Seoul"', '--enable-preview', String.format('-Dspring.profiles.active=%s', "prod"), '-Xms1024m', @@ -75,7 +76,7 @@ dependencies { // jwt - implementation 'org.springframework.security:spring-security-oauth2-jose:6.2.4' + implementation 'org.springframework.security:spring-security-oauth2-jose:6.2.3' // cloud speech implementation 'com.google.cloud:google-cloud-speech:4.38.0' @@ -85,6 +86,15 @@ dependencies { // validation implementation 'org.springframework.boot:spring-boot-starter-validation' + // flyway + implementation 'org.flywaydb:flyway-core' + implementation 'org.flywaydb:flyway-mysql' + + // mysql + implementation platform("com.google.cloud:spring-cloud-gcp-dependencies:5.4.3") + implementation "com.google.cloud:spring-cloud-gcp-starter-sql-mysql" + implementation "com.google.cloud:spring-cloud-gcp-starter-storage" + compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' @@ -96,6 +106,7 @@ dependencies { //developmentOnly 'org.springframework.boot:spring-boot-devtools' // h2 runtimeOnly 'com.h2database:h2' + runtimeOnly 'com.mysql:mysql-connector-j' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } diff --git a/backend/secret b/backend/secret index 8cb2d29..f9aa86e 160000 --- a/backend/secret +++ b/backend/secret @@ -1 +1 @@ -Subproject commit 8cb2d29b4c78c432c723faa1bfeecaeccbc88fe3 +Subproject commit f9aa86e13aa178556cffa8e2bb56e8e4739ea345 diff --git a/backend/src/main/java/in/backend/core/auth/application/TokenReissue.java b/backend/src/main/java/in/backend/core/auth/application/TokenReissue.java index 0f4c5b4..42aa152 100644 --- a/backend/src/main/java/in/backend/core/auth/application/TokenReissue.java +++ b/backend/src/main/java/in/backend/core/auth/application/TokenReissue.java @@ -1,9 +1,7 @@ package in.backend.core.auth.application; -import in.backend.core.auth.application.payload.IssuedToken; -import in.backend.core.auth.domain.Visitor; -import in.backend.core.auth.infrastrcutrue.RefreshTokenWriter; +import in.backend.core.auth.infrastrcutrue.RefreshTokenReader; import in.backend.global.provider.JwtProvider; import java.time.Instant; import lombok.RequiredArgsConstructor; @@ -12,21 +10,16 @@ @Service @RequiredArgsConstructor public class TokenReissue { - private final JwtProvider jwtProvider; - private final RefreshTokenWriter refreshTokenWriter; - - public IssuedToken publish(Visitor visitor) { - var now = Instant.now(); + private final RefreshTokenReader refreshTokenReader; - var accessToken = jwtProvider.createAccessToken(visitor.memberId(), now); - var refreshToken = jwtProvider.createRefreshToken(visitor.memberId(), now); + public String publish(String refreshToken) { + jwtProvider.validRefreshToken(refreshToken); - refreshTokenWriter.write(visitor.memberId(), refreshToken); - - return IssuedToken.builder() - .accessToken(accessToken) - .refreshToken(refreshToken) - .build(); + return jwtProvider.createAccessToken( + refreshTokenReader.read(refreshToken).getId(), + Instant.now() + ); } + } diff --git a/backend/src/main/java/in/backend/core/auth/entity/RefreshTokenEntity.java b/backend/src/main/java/in/backend/core/auth/entity/RefreshTokenEntity.java index 433abf6..f6e968b 100644 --- a/backend/src/main/java/in/backend/core/auth/entity/RefreshTokenEntity.java +++ b/backend/src/main/java/in/backend/core/auth/entity/RefreshTokenEntity.java @@ -5,9 +5,11 @@ import jakarta.persistence.Entity; import jakarta.persistence.Id; import jakarta.persistence.Table; +import lombok.Getter; import lombok.NoArgsConstructor; +@Getter @Entity @NoArgsConstructor @Table(name = "REFRESH_TOKENS") diff --git a/backend/src/main/java/in/backend/core/auth/infrastrcutrue/RefreshTokenReader.java b/backend/src/main/java/in/backend/core/auth/infrastrcutrue/RefreshTokenReader.java index de466f9..2144600 100644 --- a/backend/src/main/java/in/backend/core/auth/infrastrcutrue/RefreshTokenReader.java +++ b/backend/src/main/java/in/backend/core/auth/infrastrcutrue/RefreshTokenReader.java @@ -1,6 +1,9 @@ package in.backend.core.auth.infrastrcutrue; +import in.backend.core.auth.entity.RefreshTokenEntity; +import in.backend.global.exception.GlobalExceptionCode; +import in.backend.global.exception.RefreshTokenException; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -14,4 +17,9 @@ public class RefreshTokenReader { public boolean existsBy(Long memberId) { return refreshTokenRepository.existsById(memberId); } + + public RefreshTokenEntity read(String refreshToken) { + return refreshTokenRepository.findByToken(refreshToken) + .orElseThrow(() -> new RefreshTokenException(GlobalExceptionCode.NOT_FOUND_REFRESH_TOKEN)); + } } diff --git a/backend/src/main/java/in/backend/core/auth/infrastrcutrue/RefreshTokenRepository.java b/backend/src/main/java/in/backend/core/auth/infrastrcutrue/RefreshTokenRepository.java index 61028e5..880a01f 100644 --- a/backend/src/main/java/in/backend/core/auth/infrastrcutrue/RefreshTokenRepository.java +++ b/backend/src/main/java/in/backend/core/auth/infrastrcutrue/RefreshTokenRepository.java @@ -1,7 +1,10 @@ package in.backend.core.auth.infrastrcutrue; import in.backend.core.auth.entity.RefreshTokenEntity; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; public interface RefreshTokenRepository extends JpaRepository { + + Optional findByToken(String refreshToken); } diff --git a/backend/src/main/java/in/backend/core/auth/presentation/AuthApi.java b/backend/src/main/java/in/backend/core/auth/presentation/AuthApi.java index 26268d9..726b8f0 100644 --- a/backend/src/main/java/in/backend/core/auth/presentation/AuthApi.java +++ b/backend/src/main/java/in/backend/core/auth/presentation/AuthApi.java @@ -20,6 +20,7 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.CookieValue; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ModelAttribute; @@ -69,17 +70,14 @@ public OAuthProfileResponse getProfile(@ModelAttribute OAuthProfileRequest profi return socialLoginProcessor.findProfile(profile); } - @MemberOnly @PostMapping("/token/reissue") public ResponseEntity reIssue( - @Auth Visitor visitor, - HttpServletResponse response + @CookieValue("refreshToken") final String refreshToken ) { - var issuedToken = tokenReissue.publish(visitor); - response.addHeader(SET_COOKIE, cookieProvider.createCookie(issuedToken.refreshToken()).toString()); + var accessToken = tokenReissue.publish(refreshToken); return ResponseEntity.status(CREATED) - .body(new AccessTokenResponse(issuedToken.accessToken())); + .body(new AccessTokenResponse(accessToken)); } diff --git a/backend/src/main/java/in/backend/core/interview/entity/InterviewEntity.java b/backend/src/main/java/in/backend/core/interview/entity/InterviewEntity.java index 94a04cd..5ab6239 100644 --- a/backend/src/main/java/in/backend/core/interview/entity/InterviewEntity.java +++ b/backend/src/main/java/in/backend/core/interview/entity/InterviewEntity.java @@ -22,7 +22,7 @@ @Getter @Entity -@Table(name = "interview") +@Table(name = "interviews") @NoArgsConstructor(access = AccessLevel.PROTECTED) public class InterviewEntity extends BaseEntity { @@ -42,7 +42,7 @@ public class InterviewEntity extends BaseEntity { /** * 현재 진행 중인 면접 질문 번호 */ - @Column(nullable = false) + @Column(nullable = false, name = "interview_index") private int index; /** * 인터뷰 면접 질문의 수 diff --git a/backend/src/main/java/in/backend/core/interview/presentation/InterviewApi.java b/backend/src/main/java/in/backend/core/interview/presentation/InterviewApi.java index 61baca3..be18d96 100644 --- a/backend/src/main/java/in/backend/core/interview/presentation/InterviewApi.java +++ b/backend/src/main/java/in/backend/core/interview/presentation/InterviewApi.java @@ -15,6 +15,7 @@ import in.backend.core.interview.presentation.payload.InterviewQuestionResponse; import in.backend.core.interview.presentation.payload.InterviewSubmitRequest; import jakarta.validation.constraints.NotNull; +import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -87,10 +88,10 @@ public FeedbackResponse requestFeedback( @Auth Visitor visitor, @RequestBody FeedbackRequest request ) { - return feedbackProvider.execute(request.question, request.answer); + return feedbackProvider.execute(request.question, request.answer, request.tailQuestions); } - public record FeedbackRequest(String question, String answer) { + public record FeedbackRequest(String question, String answer, List tailQuestions) { } } diff --git a/backend/src/main/java/in/backend/core/question/application/QuestionSaveCommand.java b/backend/src/main/java/in/backend/core/question/application/QuestionSaveCommand.java new file mode 100644 index 0000000..5408b5e --- /dev/null +++ b/backend/src/main/java/in/backend/core/question/application/QuestionSaveCommand.java @@ -0,0 +1,12 @@ +package in.backend.core.question.application; + +import in.backend.global.entity.ActionType; + +public record QuestionSaveCommand( + ActionType action, + Long questionId, + Long questionSetId, + Integer sequence, + String question +) { +} diff --git a/backend/src/main/java/in/backend/core/question/application/QuestionService.java b/backend/src/main/java/in/backend/core/question/application/QuestionService.java new file mode 100644 index 0000000..557a0c9 --- /dev/null +++ b/backend/src/main/java/in/backend/core/question/application/QuestionService.java @@ -0,0 +1,21 @@ +package in.backend.core.question.application; + + +import in.backend.core.question.application.QuestionWriter.QuestionInfo; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class QuestionService { + private final QuestionWriter questionWriter; + + public Long save(QuestionSaveCommand command) { + return switch (command.action()) { + case CREATE -> questionWriter.write(new QuestionInfo(command)); + case UPDATE -> questionWriter.write(command.questionId(), new QuestionInfo(command)); + case DELETE -> throw new UnsupportedOperationException(); + }; + } + +} diff --git a/backend/src/main/java/in/backend/core/question/application/QuestionWriter.java b/backend/src/main/java/in/backend/core/question/application/QuestionWriter.java index 47b66fa..252262c 100644 --- a/backend/src/main/java/in/backend/core/question/application/QuestionWriter.java +++ b/backend/src/main/java/in/backend/core/question/application/QuestionWriter.java @@ -15,25 +15,31 @@ public class QuestionWriter { private final QuestionReader questionReader; - public Long execute(QuestionInfo questionInfo) { - return questionRepository.save(questionInfo.toEntity()).getId(); + public Long write(QuestionInfo questionInfo) { + return questionRepository.save(questionInfo.toEntity()) + .getId(); } - public void update(Long questionId, QuestionInfo questionInfo) { + public Long write(Long questionId, QuestionInfo questionInfo) { var question = questionReader.read(questionId); question.update(questionInfo); + + return question.getId(); } public record QuestionInfo( String content, - String referenceLinks, int sequence ) { + + public QuestionInfo(QuestionSaveCommand command) { + this(command.question(), command.sequence()); + } + public QuestionEntity toEntity() { return QuestionEntity.builder() .content(content) - .referenceLinks(referenceLinks) .sequence(sequence) .build(); } diff --git a/backend/src/main/java/in/backend/core/question/entity/QuestionEntity.java b/backend/src/main/java/in/backend/core/question/entity/QuestionEntity.java index c44d1ef..a9e213c 100644 --- a/backend/src/main/java/in/backend/core/question/entity/QuestionEntity.java +++ b/backend/src/main/java/in/backend/core/question/entity/QuestionEntity.java @@ -66,7 +66,6 @@ public QuestionEntity( public void update(QuestionInfo questionInfo) { applyIfPresent(questionInfo.content(), value -> this.content = value); - applyIfPresent(questionInfo.referenceLinks(), value -> this.referenceLinks = value); applyIfPresent(questionInfo.sequence(), value -> this.sequence = value); } diff --git a/backend/src/main/java/in/backend/core/question/presentation/QuestionApi.java b/backend/src/main/java/in/backend/core/question/presentation/QuestionApi.java new file mode 100644 index 0000000..2e437bb --- /dev/null +++ b/backend/src/main/java/in/backend/core/question/presentation/QuestionApi.java @@ -0,0 +1,26 @@ +package in.backend.core.question.presentation; + + +import in.backend.core.auth.domain.Visitor; +import in.backend.core.auth.domain.attributes.Auth; +import in.backend.core.question.presentation.payload.QuestionSaveRequest; +import lombok.RequiredArgsConstructor; +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/questions") +public class QuestionApi { + + + @PostMapping + public void save( + @RequestBody QuestionSaveRequest questionSaveRequest, + @Auth Visitor visitor + ) { + + } +} diff --git a/backend/src/main/java/in/backend/core/question/presentation/payload/QuestionDetailResponse.java b/backend/src/main/java/in/backend/core/question/presentation/payload/QuestionDetailResponse.java new file mode 100644 index 0000000..74f3511 --- /dev/null +++ b/backend/src/main/java/in/backend/core/question/presentation/payload/QuestionDetailResponse.java @@ -0,0 +1,9 @@ +package in.backend.core.question.presentation.payload; + +public record QuestionDetailResponse( + Long questionId, + Long questionSetId, + String question, + Integer sequence +) { +} diff --git a/backend/src/main/java/in/backend/core/question/presentation/payload/QuestionSaveRequest.java b/backend/src/main/java/in/backend/core/question/presentation/payload/QuestionSaveRequest.java new file mode 100644 index 0000000..f1bdeb0 --- /dev/null +++ b/backend/src/main/java/in/backend/core/question/presentation/payload/QuestionSaveRequest.java @@ -0,0 +1,12 @@ +package in.backend.core.question.presentation.payload; + +import in.backend.global.entity.ActionType; + +public record QuestionSaveRequest( + ActionType action, + Long questionId, + Long questionSetId, + Integer sequence, + String question +) { +} diff --git a/backend/src/main/java/in/backend/core/questionset/application/QuestionSetCreator.java b/backend/src/main/java/in/backend/core/questionset/application/QuestionSetCreator.java new file mode 100644 index 0000000..529de59 --- /dev/null +++ b/backend/src/main/java/in/backend/core/questionset/application/QuestionSetCreator.java @@ -0,0 +1,16 @@ +package in.backend.core.questionset.application; + +import lombok.Builder; +import org.springframework.web.multipart.MultipartFile; + +@Builder +public record QuestionSetCreator( + String title, + String description, + String thumbnailUrl, + MultipartFile multipartFile, + int defaultTailQuestionDepth, + int defaultTimeToAnswer, + int defaultTimeToThink +) { +} diff --git a/backend/src/main/java/in/backend/core/questionset/application/QuestionSetService.java b/backend/src/main/java/in/backend/core/questionset/application/QuestionSetService.java index 8dd441b..34b0c6b 100644 --- a/backend/src/main/java/in/backend/core/questionset/application/QuestionSetService.java +++ b/backend/src/main/java/in/backend/core/questionset/application/QuestionSetService.java @@ -4,7 +4,9 @@ import static java.util.stream.Collectors.toMap; import in.backend.core.question.application.QuestionReader; +import in.backend.core.questionset.entity.QuestionSetEntity; import in.backend.core.questionset.infrastructure.QuestionSetReader; +import in.backend.core.questionset.infrastructure.QuestionSetWriter; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -15,6 +17,7 @@ public class QuestionSetService { private final QuestionSetReader questionSetReader; + private final QuestionSetWriter questionSetWriter; private final QuestionReader questionReader; public Page find(Pageable pageable) { @@ -29,4 +32,8 @@ public Page find(Pageable pageable) { questionSetProblemCounts.get(questionSet.getId()) )); } + + public QuestionSetEntity create(QuestionSetCreator questionSetCreator, Long adminId) { + return questionSetWriter.write(questionSetCreator, adminId); + } } diff --git a/backend/src/main/java/in/backend/core/questionset/entity/QuestionSetEntity.java b/backend/src/main/java/in/backend/core/questionset/entity/QuestionSetEntity.java index 2a0cdab..e234af4 100644 --- a/backend/src/main/java/in/backend/core/questionset/entity/QuestionSetEntity.java +++ b/backend/src/main/java/in/backend/core/questionset/entity/QuestionSetEntity.java @@ -65,7 +65,12 @@ public class QuestionSetEntity extends BaseEntity { @Builder - public QuestionSetEntity(Long adminId, String title, String description, QuestionSetRules questionSetRules) { + public QuestionSetEntity( + Long adminId, + String title, + String description, + QuestionSetRules questionSetRules + ) { this.adminId = adminId; this.title = title; this.questionSetRules = questionSetRules; @@ -90,10 +95,6 @@ public List extractQuestions(int count) { } - public int getQuestionSize() { - return questions.size(); - } - public int getTailQuestionDepth() { return questionSetRules.getDefaultTailQuestionDepth(); } diff --git a/backend/src/main/java/in/backend/core/questionset/infrastructure/QuestionSetWriter.java b/backend/src/main/java/in/backend/core/questionset/infrastructure/QuestionSetWriter.java new file mode 100644 index 0000000..f810228 --- /dev/null +++ b/backend/src/main/java/in/backend/core/questionset/infrastructure/QuestionSetWriter.java @@ -0,0 +1,31 @@ +package in.backend.core.questionset.infrastructure; + + +import in.backend.core.questionset.application.QuestionSetCreator; +import in.backend.core.questionset.entity.QuestionSetEntity; +import in.backend.core.questionset.entity.QuestionSetRules; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +@RequiredArgsConstructor +public class QuestionSetWriter { + private final QuestionSetRepository questionSetRepository; + + public QuestionSetEntity write(QuestionSetCreator questionSetCreator, Long adminId) { + var questionSet = QuestionSetEntity.builder() + .adminId(adminId) + .title(questionSetCreator.title()) + .description(questionSetCreator.description()) + .questionSetRules(QuestionSetRules.builder() + .defaultTimeToAnswer(questionSetCreator.defaultTimeToAnswer()) + .defaultTimeToThink(questionSetCreator.defaultTimeToThink()) + .defaultTailQuestionDepth(questionSetCreator.defaultTailQuestionDepth()) + .build()) + .build(); + + return questionSetRepository.save(questionSet); + } +} diff --git a/backend/src/main/java/in/backend/core/questionset/presentation/QuestionSetApi.java b/backend/src/main/java/in/backend/core/questionset/presentation/QuestionSetApi.java index 8fc2dc5..0724d7e 100644 --- a/backend/src/main/java/in/backend/core/questionset/presentation/QuestionSetApi.java +++ b/backend/src/main/java/in/backend/core/questionset/presentation/QuestionSetApi.java @@ -4,13 +4,21 @@ import in.backend.core.auth.domain.attributes.Auth; import in.backend.core.auth.domain.attributes.MemberOnly; import in.backend.core.questionset.application.QuestionSetService; +import in.backend.core.questionset.presentation.payload.QuestionSetCreateRequest; +import in.backend.core.questionset.presentation.payload.QuestionSetCreateResponse; import in.backend.core.questionset.presentation.payload.QuestionSetSearchResponse; +import in.backend.global.provider.ExternalImageProvider; +import java.io.IOException; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.web.bind.annotation.GetMapping; +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.RequestPart; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; @RestController @@ -18,7 +26,9 @@ @RequestMapping("/api/question-set") public class QuestionSetApi { + private static final Long DEFAULT_ADMIN_ID = 1L; private final QuestionSetService questionSetService; + private final ExternalImageProvider imageProvider; @MemberOnly @GetMapping @@ -29,4 +39,22 @@ public Page get( return questionSetService.find(pageable) .map(QuestionSetSearchResponse::from); } + + + @MemberOnly + @PostMapping + public QuestionSetCreateResponse create( + @Auth Visitor visitor, + @RequestPart(name = "thumbnail") MultipartFile thumbnail, + @RequestBody QuestionSetCreateRequest questionSetCreateRequest + + ) throws IOException { + var thumbnailUrl = imageProvider.write(thumbnail); + var questionSet = questionSetService.create( + questionSetCreateRequest.from(thumbnailUrl), + DEFAULT_ADMIN_ID + ); + + return QuestionSetCreateResponse.from(questionSet); + } } diff --git a/backend/src/main/java/in/backend/core/questionset/presentation/payload/QuestionSetCreateRequest.java b/backend/src/main/java/in/backend/core/questionset/presentation/payload/QuestionSetCreateRequest.java index e19b488..9d72f85 100644 --- a/backend/src/main/java/in/backend/core/questionset/presentation/payload/QuestionSetCreateRequest.java +++ b/backend/src/main/java/in/backend/core/questionset/presentation/payload/QuestionSetCreateRequest.java @@ -1,9 +1,49 @@ package in.backend.core.questionset.presentation.payload; +import in.backend.core.questionset.application.QuestionSetCreator; +import jakarta.validation.constraints.NotNull; +import org.hibernate.validator.constraints.Length; + public record QuestionSetCreateRequest( + + /** + * 질문 제목 + */ + @NotNull + @Length(min = 6, max = 50, message = "제목은 최소 6자이며 최대50자 입니다.") String title, - Long defaultTailQuestionDepth, - Long defaultTimeToThink, - Long defaultTimeToAnswer + + /** + * 질문 depth + */ + @NotNull + Integer defaultTailQuestionDepth, + + /** + * 질문 생각 시간 + */ + Integer defaultTimeToThink, + + /** + * 답변 소요 시간 + */ + Integer defaultTimeToAnswer ) { + + public QuestionSetCreateRequest { + // 이후 추가될 기능 + defaultTimeToThink = 1; + defaultTimeToAnswer = 1; + } + + + public QuestionSetCreator from(String thumbnailUrl) { + return QuestionSetCreator.builder() + .title(title) + .thumbnailUrl(thumbnailUrl) + .defaultTailQuestionDepth(defaultTailQuestionDepth) + .defaultTimeToThink(defaultTimeToThink) + .defaultTimeToAnswer(defaultTimeToAnswer) + .build(); + } } diff --git a/backend/src/main/java/in/backend/core/questionset/presentation/payload/QuestionSetCreateResponse.java b/backend/src/main/java/in/backend/core/questionset/presentation/payload/QuestionSetCreateResponse.java new file mode 100644 index 0000000..08ab1c2 --- /dev/null +++ b/backend/src/main/java/in/backend/core/questionset/presentation/payload/QuestionSetCreateResponse.java @@ -0,0 +1,16 @@ +package in.backend.core.questionset.presentation.payload; + +import in.backend.core.questionset.entity.QuestionSetEntity; + +public record QuestionSetCreateResponse( + Long questionSetId, + String thumbnailUrl +) { + + public static QuestionSetCreateResponse from(QuestionSetEntity questionSet) { + return new QuestionSetCreateResponse( + questionSet.getId(), + questionSet.getThumbnailUrl() + ); + } +} diff --git a/backend/src/main/java/in/backend/global/config/CorsConfig.java b/backend/src/main/java/in/backend/global/config/CorsConfig.java index 7323aad..4b72736 100644 --- a/backend/src/main/java/in/backend/global/config/CorsConfig.java +++ b/backend/src/main/java/in/backend/global/config/CorsConfig.java @@ -11,7 +11,8 @@ public class CorsConfig implements WebMvcConfigurer { public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOrigins("http://localhost:5173", - "http://localhost:4173" + "http://localhost:4173", + "https://mu-fe-kuwwdoyjsa-an.a.run.app" ) .allowedMethods("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS") .allowCredentials(true); diff --git a/backend/src/main/java/in/backend/global/config/GCSConfig.java b/backend/src/main/java/in/backend/global/config/GCSConfig.java new file mode 100644 index 0000000..df496fa --- /dev/null +++ b/backend/src/main/java/in/backend/global/config/GCSConfig.java @@ -0,0 +1,33 @@ +package in.backend.global.config; + +import com.google.auth.oauth2.GoogleCredentials; +import com.google.cloud.spring.autoconfigure.storage.GcpStorageAutoConfiguration; +import com.google.cloud.spring.core.UserAgentHeaderProvider; +import com.google.cloud.storage.Storage; +import com.google.cloud.storage.StorageOptions; +import in.backend.global.property.GCSProperty; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + + +@Configuration +@RequiredArgsConstructor +public class GCSConfig { + + private final GCSProperty gcsProperty; + + @Bean + @ConditionalOnMissingBean + public Storage storage() throws IOException { + GoogleCredentials credentials = GoogleCredentials.fromStream(gcsProperty.location().getInputStream()); + return StorageOptions.newBuilder() + .setHeaderProvider(new UserAgentHeaderProvider(GcpStorageAutoConfiguration.class)) + .setProjectId(gcsProperty.projectId()) + .setCredentials(credentials) + .build() + .getService(); + } +} diff --git a/backend/src/main/java/in/backend/global/entity/ActionType.java b/backend/src/main/java/in/backend/global/entity/ActionType.java new file mode 100644 index 0000000..e41a08a --- /dev/null +++ b/backend/src/main/java/in/backend/global/entity/ActionType.java @@ -0,0 +1,7 @@ +package in.backend.global.entity; + +public enum ActionType { + CREATE, + UPDATE, + DELETE +} diff --git a/backend/src/main/java/in/backend/global/exception/GlobalExceptionCode.java b/backend/src/main/java/in/backend/global/exception/GlobalExceptionCode.java index f5862f6..38783b3 100644 --- a/backend/src/main/java/in/backend/global/exception/GlobalExceptionCode.java +++ b/backend/src/main/java/in/backend/global/exception/GlobalExceptionCode.java @@ -21,7 +21,9 @@ public enum GlobalExceptionCode { UNAUTHORIZED_MEMBER(10007, "허가받지 않은 사용자입니다."), INVALID_OAUTH_CODE(10008, "유효하지 않은 OAUTH 요청입니다."), - INVALID_OAUTH_SERVER(100009, "현재 서버 연결이 불안정합니다."); + INVALID_OAUTH_SERVER(10009, "현재 서버 연결이 불안정합니다."), + + IMAGE_SAVE_FAIL(100010, "이미지 저장에 실패했습니다."); private final int code; private final String message; diff --git a/backend/src/main/java/in/backend/global/property/GCSProperty.java b/backend/src/main/java/in/backend/global/property/GCSProperty.java new file mode 100644 index 0000000..928fbce --- /dev/null +++ b/backend/src/main/java/in/backend/global/property/GCSProperty.java @@ -0,0 +1,25 @@ +package in.backend.global.property; + + +import com.google.cloud.storage.BlobId; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.ConfigurationPropertiesBinding; +import org.springframework.core.io.Resource; + +@ConfigurationProperties("gcs.resource.test") +@ConfigurationPropertiesBinding +public record GCSProperty( + String bucket, + Resource location, + String projectId +) { + + + public BlobId create(String fileName) { + return BlobId.of(bucket, fileName); + } + + public String getFilePath(String fileName) { + return STR."https://storage.googleapis.com/\{bucket}/\{fileName}"; + } +} diff --git a/backend/src/main/java/in/backend/global/provider/AIFeedbackProvider.java b/backend/src/main/java/in/backend/global/provider/AIFeedbackProvider.java index aba8386..e72dbf1 100644 --- a/backend/src/main/java/in/backend/global/provider/AIFeedbackProvider.java +++ b/backend/src/main/java/in/backend/global/provider/AIFeedbackProvider.java @@ -2,25 +2,29 @@ import in.backend.global.property.PromptProperty; +import java.util.List; import java.util.Map; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.ai.converter.BeanOutputConverter; import org.springframework.ai.openai.OpenAiChatModel; import org.springframework.stereotype.Service; +@Slf4j @Service @RequiredArgsConstructor public class AIFeedbackProvider { private final OpenAiChatModel openAiChatModel; private final PromptProperty promptProperty; - public FeedbackResponse execute(String question, String answer) { + public FeedbackResponse execute(String question, String answer, List tailQuestions) { var beanOutputConverter = new BeanOutputConverter<>(FeedbackResponse.class); var prompt = promptProperty.backendPromptTemplate() .create(Map.of( "format", beanOutputConverter.getFormat(), "question", question, + "tailQuestions", convertTailQuestions(tailQuestions), "answer", answer )); @@ -32,7 +36,16 @@ public FeedbackResponse execute(String question, String answer) { ); } - public record FeedbackResponse(int score, String feedback, String tailQuestion) { + private String convertTailQuestions(List tailQuestions) { + if (tailQuestions.isEmpty()) { + return ""; + } + + return String.join(",", tailQuestions); + } + + + public record FeedbackResponse(int score, String feedback, String tailQuestion, List referenceLinks) { } diff --git a/backend/src/main/java/in/backend/global/provider/ExternalImageProvider.java b/backend/src/main/java/in/backend/global/provider/ExternalImageProvider.java new file mode 100644 index 0000000..e1d3347 --- /dev/null +++ b/backend/src/main/java/in/backend/global/provider/ExternalImageProvider.java @@ -0,0 +1,42 @@ +package in.backend.global.provider; + +import com.google.cloud.storage.BlobInfo; +import com.google.cloud.storage.Storage; +import com.google.cloud.storage.StorageException; +import in.backend.global.exception.GlobalException; +import in.backend.global.exception.GlobalExceptionCode; +import in.backend.global.property.GCSProperty; +import java.io.IOException; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + + +@Slf4j +@Service +@RequiredArgsConstructor +public class ExternalImageProvider { + + private final Storage storage; + private final GCSProperty gcsProperty; + + public String write(MultipartFile file) { + try { + var fileName = UUID.randomUUID().toString().replace("-", ""); + var blobId = gcsProperty.create(fileName); + var blobInfo = BlobInfo.newBuilder(blobId) + .setContentType(file.getContentType()) + .build(); + + storage.create(blobInfo, file.getBytes()); + + return gcsProperty.getFilePath(fileName); + } catch (IOException | StorageException e) { + throw new GlobalException(GlobalExceptionCode.IMAGE_SAVE_FAIL); + } + + } + +} diff --git a/backend/src/main/java/in/backend/global/provider/JwtProvider.java b/backend/src/main/java/in/backend/global/provider/JwtProvider.java index d837f17..ad19c23 100644 --- a/backend/src/main/java/in/backend/global/provider/JwtProvider.java +++ b/backend/src/main/java/in/backend/global/provider/JwtProvider.java @@ -13,15 +13,15 @@ import java.util.Objects; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.context.annotation.Configuration; import org.springframework.security.oauth2.jwt.JwtClaimsSet; import org.springframework.security.oauth2.jwt.JwtDecoder; import org.springframework.security.oauth2.jwt.JwtEncoder; import org.springframework.security.oauth2.jwt.JwtEncoderParameters; import org.springframework.security.oauth2.jwt.JwtValidationException; +import org.springframework.stereotype.Service; @Slf4j -@Configuration +@Service @RequiredArgsConstructor public class JwtProvider { private static final String issuer = "MUBAEGSEU"; @@ -60,14 +60,6 @@ private Map decode(final String token) { return jwtDecoder.decode(token).getClaims(); } - - public Long extractToValueFrom(final String token) { - return Long.parseLong(this.decode(token) - .get("id") - .toString() - ); - } - public Long decodeAccessToken(final String accessToken) { try { return Long.parseLong(String.valueOf(this.decode(accessToken).get("id"))); @@ -91,10 +83,18 @@ public Long decodeRefreshToken(final String refreshToken) { public String extractToken(final HttpServletRequest request) { final String token = request.getHeader(HEADER_AUTHORIZATION); - if (!Objects.isNull(token) && token.startsWith(TOKEN_PREFIX)) { - return token.substring(TOKEN_PREFIX.length()); + return extractToken(token); + + } + + public String extractToken(final String headerValue) { + if (!Objects.isNull(headerValue) && headerValue.startsWith(TOKEN_PREFIX)) { + return headerValue.substring(TOKEN_PREFIX.length()); } return null; } + public void validRefreshToken(String refreshToken) { + decodeRefreshToken(refreshToken); + } } diff --git a/backend/src/main/resources/db/migration/V1__init.sql b/backend/src/main/resources/db/migration/V1__init.sql new file mode 100644 index 0000000..dd75b5f --- /dev/null +++ b/backend/src/main/resources/db/migration/V1__init.sql @@ -0,0 +1,95 @@ +create table if not exists interview_items +( + remain_tail_question_count int not null, + score int null, + time_to_answer int null, + created_at datetime(6) null, + id bigint auto_increment + primary key, + interview_id bigint not null, + member_id bigint not null, + question_id bigint not null, + updated_at datetime(6) null, + content varchar(2000) null, + feedback_content varchar(2000) null, + question_content varchar(255) not null, + tail_question varchar(255) null, + answer_state enum ('COMPLETE', 'INIT', 'PASS') null +); + +create table if not exists interviews +( + interview_index int not null, + size int not null, + tail_question_depth int null, + time_to_answer int null, + time_to_think int null, + created_at datetime(6) null, + id bigint auto_increment + primary key, + member_id bigint not null, + updated_at datetime(6) null, + title varchar(255) not null, + interview_state enum ('DONE', 'PROGRESS') null +); + +create table if not exists members +( + id bigint auto_increment + primary key, + avatar_url varchar(255) null, + nickname varchar(255) not null, + provider_id varchar(255) null +); + +create table if not exists question_sets +( + default_tail_question_depth int null, + default_time_to_answer int null, + default_time_to_think int null, + admin_id bigint not null, + created_at datetime(6) null, + id bigint auto_increment + primary key, + updated_at datetime(6) null, + description varchar(255) null, + thumbnail_url varchar(255) null, + title varchar(255) not null +); + +create table if not exists questions +( + sequence int not null, + created_at datetime(6) null, + id bigint auto_increment primary key, + question_set_id bigint not null, + updated_at datetime(6) null, + content varchar(255) not null, + reference_links varchar(255) null, + constraint FKkpkuc627fwg4g9prwdfb0mj2l + foreign key (question_set_id) references question_sets (id) +); + +create table if not exists refresh_tokens +( + id bigint not null + primary key, + token varchar(700) null +); + +create table if not exists tail_questions +( + score int null, + time_to_answer int null, + id bigint auto_increment + primary key, + interview_id bigint not null, + interview_question_id bigint not null, + member_id bigint not null, + content varchar(2000) null, + feedback_content varchar(2000) null, + question varchar(255) not null, + tail_question varchar(255) null, + answer_state enum ('COMPLETE', 'INIT', 'PASS') null +); +