diff --git a/backend/build.gradle b/backend/build.gradle index f0927d29f..f29a8d5d6 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -33,6 +33,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-aop' + implementation 'org.springframework.boot:spring-boot-starter-amqp' implementation 'org.springframework.retry:spring-retry' implementation 'com.google.cloud:spring-cloud-gcp-storage:5.8.0' implementation 'io.jsonwebtoken:jjwt-api:0.11.5' diff --git a/backend/src/main/java/moadong/club/controller/ClubApplyAdminController.java b/backend/src/main/java/moadong/club/controller/ClubApplyAdminController.java index 1c982b00a..97ba6603a 100644 --- a/backend/src/main/java/moadong/club/controller/ClubApplyAdminController.java +++ b/backend/src/main/java/moadong/club/controller/ClubApplyAdminController.java @@ -5,7 +5,6 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import jakarta.validation.constraints.NotEmpty; -import java.util.List; import lombok.AllArgsConstructor; import moadong.club.payload.request.ClubApplicantDeleteRequest; import moadong.club.payload.request.ClubApplicantEditRequest; @@ -18,17 +17,11 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.validation.annotation.Validated; -import org.springframework.web.bind.annotation.DeleteMapping; -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.PutMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.*; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; +import java.util.List; + @RestController @RequestMapping("/api/club") @@ -43,7 +36,7 @@ public class ClubApplyAdminController { @PreAuthorize("isAuthenticated()") @SecurityRequirement(name = "BearerAuth") public ResponseEntity createClubApplicationForm(@CurrentUser CustomUserDetails user, - @RequestBody @Validated ClubApplicationFormCreateRequest request) { + @RequestBody @Validated ClubApplicationFormCreateRequest request) { clubApplyAdminService.createClubApplicationForm(user, request); return Response.ok("success create application"); } @@ -65,7 +58,7 @@ public ResponseEntity editClubApplicationForm(@PathVariable String applicatio @SecurityRequirement(name = "BearerAuth") public ResponseEntity getClubApplications(@CurrentUser CustomUserDetails user, @RequestParam(defaultValue = "agg") String mode) { //agg면 aggregation사용, server면, 서비스에서 그룹 및 정렬 - if("server".equalsIgnoreCase(mode)) { + if ("server".equalsIgnoreCase(mode)) { return Response.ok(clubApplyAdminService.getGroupedClubApplicationForms(user)); } return Response.ok(clubApplyAdminService.getClubApplicationForms(user)); diff --git a/backend/src/main/java/moadong/club/payload/dto/ApplicantSummaryMessage.java b/backend/src/main/java/moadong/club/payload/dto/ApplicantSummaryMessage.java new file mode 100644 index 000000000..4e54f2ecd --- /dev/null +++ b/backend/src/main/java/moadong/club/payload/dto/ApplicantSummaryMessage.java @@ -0,0 +1,7 @@ +package moadong.club.payload.dto; + +public record ApplicantSummaryMessage( + String applicationFormId, + String applicantId +) { +} diff --git a/backend/src/main/java/moadong/club/service/ClubApplyAdminService.java b/backend/src/main/java/moadong/club/service/ClubApplyAdminService.java index 19eddda54..aed48c0ec 100644 --- a/backend/src/main/java/moadong/club/service/ClubApplyAdminService.java +++ b/backend/src/main/java/moadong/club/service/ClubApplyAdminService.java @@ -1,30 +1,21 @@ package moadong.club.service; import jakarta.transaction.Transactional; - -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.time.ZoneId; -import java.time.ZonedDateTime; -import java.util.*; -import java.util.function.Function; -import java.util.stream.Collectors; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; -import moadong.club.entity.ClubApplicant; -import moadong.club.entity.ClubApplicationForm; -import moadong.club.entity.ClubQuestionAnswer; -import moadong.club.entity.ClubApplicationFormQuestion; -import moadong.club.entity.ClubQuestionItem; -import moadong.club.entity.ClubQuestionOption; +import moadong.club.entity.*; import moadong.club.enums.SemesterTerm; import moadong.club.payload.dto.*; +import moadong.club.payload.request.ClubApplicantDeleteRequest; +import moadong.club.payload.request.ClubApplicantEditRequest; import moadong.club.payload.request.ClubApplicationFormCreateRequest; import moadong.club.payload.request.ClubApplicationFormEditRequest; -import moadong.club.payload.request.ClubApplicantEditRequest; -import moadong.club.payload.request.ClubApplicantDeleteRequest; -import moadong.club.payload.response.*; -import moadong.club.repository.*; +import moadong.club.payload.response.ClubApplicationFormsResponse; +import moadong.club.payload.response.ClubApplyInfoResponse; +import moadong.club.repository.ClubApplicantsRepository; +import moadong.club.repository.ClubApplicationFormsRepository; +import moadong.club.repository.ClubApplicationFormsRepositoryCustom; +import moadong.club.repository.ClubRepository; import moadong.global.exception.ErrorCode; import moadong.global.exception.RestApiException; import moadong.global.util.AESCipher; @@ -37,6 +28,14 @@ import org.springframework.transaction.support.TransactionSynchronizationManager; import org.springframework.transaction.support.TransactionSynchronization; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.*; +import java.util.function.Function; +import java.util.stream.Collectors; + @Service @AllArgsConstructor @Slf4j diff --git a/backend/src/main/java/moadong/club/service/ClubApplyPublicService.java b/backend/src/main/java/moadong/club/service/ClubApplyPublicService.java index be0d5572a..ef9f2c937 100644 --- a/backend/src/main/java/moadong/club/service/ClubApplyPublicService.java +++ b/backend/src/main/java/moadong/club/service/ClubApplyPublicService.java @@ -14,12 +14,14 @@ import moadong.club.payload.response.ClubApplicationFormResponse; import moadong.club.repository.ClubApplicantsRepository; import moadong.club.repository.ClubApplicationFormsRepository; +import moadong.club.summary.ApplicantIdMessagePublisher; import moadong.global.exception.ErrorCode; import moadong.global.exception.RestApiException; import moadong.global.payload.Response; import moadong.global.util.AESCipher; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.util.*; import java.util.function.Function; @@ -32,49 +34,35 @@ public class ClubApplyPublicService { private final ClubApplicationFormsRepository clubApplicationFormsRepository; private final ClubApplicantsRepository clubApplicantsRepository; private final AESCipher cipher; - + private final ApplicantIdMessagePublisher applicantIdMessagePublisher; public ClubActiveFormsResponse getActiveApplicationForms(String clubId) { List forms = clubApplicationFormsRepository.findClubActiveFormsByClubId(clubId); - if (forms == null || forms.isEmpty()) - throw new RestApiException(ErrorCode.ACTIVE_APPLICATION_NOT_FOUND); + if (forms == null || forms.isEmpty()) throw new RestApiException(ErrorCode.ACTIVE_APPLICATION_NOT_FOUND); List results = new ArrayList<>(); for (ClubActiveFormSlim form : forms) { - ClubActiveFormResult result = ClubActiveFormResult.builder() - .id(form.getId()) - .title(form.getTitle()) - .build(); + ClubActiveFormResult result = ClubActiveFormResult.builder().id(form.getId()).title(form.getTitle()).build(); results.add(result); } - return ClubActiveFormsResponse.builder() - .forms(results) - .build(); + return ClubActiveFormsResponse.builder().forms(results).build(); } public ResponseEntity getClubApplicationForm(String clubId, String applicationFormId) { - ClubApplicationForm clubApplicationForm = clubApplicationFormsRepository.findByClubIdAndId(clubId, applicationFormId) - .orElseThrow(() -> new RestApiException(ErrorCode.APPLICATION_NOT_FOUND)); + ClubApplicationForm clubApplicationForm = clubApplicationFormsRepository.findByClubIdAndId(clubId, applicationFormId).orElseThrow(() -> new RestApiException(ErrorCode.APPLICATION_NOT_FOUND)); - ClubApplicationFormResponse clubApplicationFormResponse = ClubApplicationFormResponse.builder() - .title(clubApplicationForm.getTitle()) - .description(Optional.ofNullable(clubApplicationForm.getDescription()).orElse("")) - .questions(clubApplicationForm.getQuestions()) - .semesterYear(clubApplicationForm.getSemesterYear()) - .semesterTerm(clubApplicationForm.getSemesterTerm()) - .status(clubApplicationForm.getStatus()) - .build(); + ClubApplicationFormResponse clubApplicationFormResponse = ClubApplicationFormResponse.builder().title(clubApplicationForm.getTitle()).description(Optional.ofNullable(clubApplicationForm.getDescription()).orElse("")).questions(clubApplicationForm.getQuestions()).semesterYear(clubApplicationForm.getSemesterYear()).semesterTerm(clubApplicationForm.getSemesterTerm()).status(clubApplicationForm.getStatus()).build(); return Response.ok(clubApplicationFormResponse); } + @Transactional public void applyToClub(String clubId, String applicationFormId, ClubApplyRequest request) { - ClubApplicationForm clubApplicationForm = clubApplicationFormsRepository.findByClubIdAndId(clubId, applicationFormId) - .orElseThrow(() -> new RestApiException(ErrorCode.APPLICATION_NOT_FOUND)); + ClubApplicationForm clubApplicationForm = clubApplicationFormsRepository.findByClubIdAndId(clubId, applicationFormId).orElseThrow(() -> new RestApiException(ErrorCode.APPLICATION_NOT_FOUND)); validateAnswers(request.questions(), clubApplicationForm); @@ -83,32 +71,25 @@ public void applyToClub(String clubId, String applicationFormId, ClubApplyReques try { for (ClubApplyRequest.Answer answer : request.questions()) { String encryptedValue = cipher.encrypt(answer.value()); - answers.add(ClubQuestionAnswer.builder() - .id(answer.id()) - .value(encryptedValue) - .build()); + answers.add(ClubQuestionAnswer.builder().id(answer.id()).value(encryptedValue).build()); } } catch (Exception e) { log.error("AES_CIPHER_ERROR", e); throw new RestApiException(ErrorCode.AES_CIPHER_ERROR); } - ClubApplicant application = ClubApplicant.builder() - .formId(applicationFormId) - .answers(answers) - .build(); + ClubApplicant applicant = ClubApplicant.builder().formId(applicationFormId).answers(answers).build(); + + clubApplicantsRepository.save(applicant); - clubApplicantsRepository.save(application); + applicantIdMessagePublisher.addApplicantIdToQueue(applicationFormId, applicant.getId()); } private void validateAnswers(List answers, ClubApplicationForm clubApplicationForm) { // 미리 질문과 응답 id 만들어두기 - Map questionMap = clubApplicationForm.getQuestions().stream() - .collect(Collectors.toMap(ClubApplicationFormQuestion::getId, Function.identity())); + Map questionMap = clubApplicationForm.getQuestions().stream().collect(Collectors.toMap(ClubApplicationFormQuestion::getId, Function.identity())); - Set answerIds = answers.stream() - .map(ClubApplyRequest.Answer::id) - .collect(Collectors.toSet()); + Set answerIds = answers.stream().map(ClubApplyRequest.Answer::id).collect(Collectors.toSet()); // 필수 질문이 누락되었는지 검증 for (ClubApplicationFormQuestion question : clubApplicationForm.getQuestions()) { diff --git a/backend/src/main/java/moadong/club/summary/ApplicantIdMessageConsumer.java b/backend/src/main/java/moadong/club/summary/ApplicantIdMessageConsumer.java new file mode 100644 index 000000000..2fe78e250 --- /dev/null +++ b/backend/src/main/java/moadong/club/summary/ApplicantIdMessageConsumer.java @@ -0,0 +1,63 @@ +package moadong.club.summary; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import moadong.club.entity.ClubApplicant; +import moadong.club.entity.ClubApplicationForm; +import moadong.club.entity.ClubApplicationFormQuestion; +import moadong.club.entity.ClubQuestionAnswer; +import moadong.club.payload.dto.ApplicantSummaryMessage; +import moadong.club.repository.ClubApplicantsRepository; +import moadong.club.repository.ClubApplicationFormsRepository; +import moadong.gemma.dto.AIResponse; +import moadong.gemma.service.GemmaService; +import moadong.global.exception.ErrorCode; +import moadong.global.exception.RestApiException; +import moadong.global.util.AESCipher; +import org.springframework.amqp.rabbit.annotation.RabbitListener; +import org.springframework.stereotype.Component; + +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Component +@Slf4j +@RequiredArgsConstructor +public class ApplicantIdMessageConsumer { + + private final ClubApplicantsRepository clubApplicantsRepository; + private final ClubApplicationFormsRepository clubApplicationFormsRepository; + private final AESCipher cipher; + private final GemmaService gemmaService; + private final ApplicantIdMessagePublisher publisher; + + @RabbitListener(queues = "${rabbitmq.summary.queue}", concurrency = "1") + public void receiveMessage(ApplicantSummaryMessage message) { + StringBuilder prompt = new StringBuilder("너는 전문 면접관이다. 다음은 동아리 application의 질문과 지원자의 답변이다. 질문은 무시하고, 지원자의 '답변'에서 핵심만 뽑아라. summarize max length 100 response format: '{response: summarize}'. application: "); + ClubApplicant clubApplicant = clubApplicantsRepository.findById(message.applicantId()).orElseThrow(() -> new RestApiException(ErrorCode.APPLICANT_NOT_FOUND)); + ClubApplicationForm clubApplicationForm = clubApplicationFormsRepository.findById(message.applicationFormId()).orElseThrow(() -> new RestApiException(ErrorCode.APPLICATION_NOT_FOUND)); + Map questionMap = clubApplicationForm.getQuestions().stream() + .collect(Collectors.toMap(ClubApplicationFormQuestion::getId, Function.identity())); + + try { + for (ClubQuestionAnswer answer : clubApplicant.getAnswers()) { + String decryptedValue = cipher.decrypt(answer.getValue()); + prompt.append(answer.getId()).append(". ") + .append(questionMap.get(answer.getId()).getTitle()) + .append(": ") + .append(decryptedValue); + prompt.append(","); + } + } catch (Exception e) { + log.error("AES_CIPHER_ERROR", e); + throw new RestApiException(ErrorCode.AES_CIPHER_ERROR); + } + + AIResponse summarizeContent = gemmaService.getSummarizeContent(prompt.toString()); + + clubApplicant.updateMemo(summarizeContent.response()); + + clubApplicantsRepository.save(clubApplicant); + } +} \ No newline at end of file diff --git a/backend/src/main/java/moadong/club/summary/ApplicantIdMessagePublisher.java b/backend/src/main/java/moadong/club/summary/ApplicantIdMessagePublisher.java new file mode 100644 index 000000000..43af55b89 --- /dev/null +++ b/backend/src/main/java/moadong/club/summary/ApplicantIdMessagePublisher.java @@ -0,0 +1,19 @@ +package moadong.club.summary; + +import lombok.RequiredArgsConstructor; +import moadong.club.payload.dto.ApplicantSummaryMessage; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class ApplicantIdMessagePublisher { + + private final RabbitTemplate applicantIdTemplate; + + public void addApplicantIdToQueue(String applicationFormId, String applicantId) { + ApplicantSummaryMessage message = new ApplicantSummaryMessage(applicationFormId, applicantId); + + applicantIdTemplate.convertAndSend(message); + } +} diff --git a/backend/src/main/java/moadong/gemma/dto/AIRequest.java b/backend/src/main/java/moadong/gemma/dto/AIRequest.java new file mode 100644 index 000000000..a29e38505 --- /dev/null +++ b/backend/src/main/java/moadong/gemma/dto/AIRequest.java @@ -0,0 +1,12 @@ +package moadong.gemma.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record AIRequest( + String model, + String prompt, + String format, + boolean stream, + @JsonProperty("keep_alive") int keepAlive +) { +} diff --git a/backend/src/main/java/moadong/gemma/dto/AIResponse.java b/backend/src/main/java/moadong/gemma/dto/AIResponse.java new file mode 100644 index 000000000..dc095d2c9 --- /dev/null +++ b/backend/src/main/java/moadong/gemma/dto/AIResponse.java @@ -0,0 +1,6 @@ +package moadong.gemma.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record AIResponse(@JsonProperty("response") String response) { +} \ No newline at end of file diff --git a/backend/src/main/java/moadong/gemma/service/GemmaService.java b/backend/src/main/java/moadong/gemma/service/GemmaService.java new file mode 100644 index 000000000..bb2063e5c --- /dev/null +++ b/backend/src/main/java/moadong/gemma/service/GemmaService.java @@ -0,0 +1,40 @@ +package moadong.gemma.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import moadong.gemma.dto.AIRequest; +import moadong.gemma.dto.AIResponse; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +@Service +@RequiredArgsConstructor +@Slf4j +public class GemmaService { + + private final RestTemplate restTemplate; + private final ObjectMapper objectMapper; + + @Value("${gemma.server.host}") + private String gemmaServerHost; + + @Value("${gemma.server.port}") + private String gemmaServerPort; + + public AIResponse getSummarizeContent(String prompt) { + try { + String gemmaServerUrl = "http://" + gemmaServerHost + ":" + gemmaServerPort + "/api/generate"; + AIRequest request = new AIRequest("gemma3:4b", prompt, "json", false, -1); + AIResponse response = restTemplate.postForObject(gemmaServerUrl, request, AIResponse.class); + if (response != null) { + return objectMapper.readValue(response.response(), AIResponse.class); + } + } catch (Exception e) { + log.error("Json Serialize Error: ", e); + return null; + } + return null; + } +} diff --git a/backend/src/main/java/moadong/global/config/RabbitMQConfig.java b/backend/src/main/java/moadong/global/config/RabbitMQConfig.java new file mode 100644 index 000000000..12eee1815 --- /dev/null +++ b/backend/src/main/java/moadong/global/config/RabbitMQConfig.java @@ -0,0 +1,98 @@ +package moadong.global.config; + +import org.springframework.amqp.core.Binding; +import org.springframework.amqp.core.BindingBuilder; +import org.springframework.amqp.core.DirectExchange; +import org.springframework.amqp.core.Queue; +import org.springframework.amqp.rabbit.annotation.EnableRabbit; +import org.springframework.amqp.rabbit.connection.CachingConnectionFactory; +import org.springframework.amqp.rabbit.connection.ConnectionFactory; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter; +import org.springframework.amqp.support.converter.MessageConverter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.Map; + +@Configuration +@EnableRabbit +public class RabbitMQConfig { + + @Value("${spring.rabbitmq.host}") private String host; + @Value("${spring.rabbitmq.port}") private int port; + @Value("${spring.rabbitmq.username}") private String username; + @Value("${spring.rabbitmq.password}") private String password; + + @Value("${rabbitmq.summary.queue}") + private String APPLICANT_ID_QUEUE_NAME; + + @Value("${rabbitmq.summary.exchange}") + private String APPLICANT_ID_EXCHANGE_NAME; + + @Value("${rabbitmq.summary.routingKey}") + private String APPLICANT_ID_ROUTING_KEY; + + private static final String DEAD_LETTER_EXCHANGE_NAME = "dead.letter.exchange"; + private static final String DEAD_LETTER_QUEUE_NAME = "dead.letter.queue"; + private static final String DEAD_LETTER_ROUTING_KEY = "dead.letter.routing.key"; + + @Bean + public Queue applicantIdQueue() { + return new Queue(APPLICANT_ID_QUEUE_NAME, true, false, false, + Map.of( + "x-dead-letter-exchange", DEAD_LETTER_EXCHANGE_NAME, + "x-dead-letter-routing-key", DEAD_LETTER_ROUTING_KEY + ) + ); + } + + @Bean + public DirectExchange applicantIdExchange() { + return new DirectExchange(APPLICANT_ID_EXCHANGE_NAME); + } + + @Bean + public Binding applicantIdBinding(Queue applicantIdQueue, DirectExchange applicantIdExchange) { + return BindingBuilder.bind(applicantIdQueue).to(applicantIdExchange).with(APPLICANT_ID_ROUTING_KEY); + } + + @Bean + public Queue deadLetterQueue() { + return new Queue(DEAD_LETTER_QUEUE_NAME, true); + } + + @Bean + public DirectExchange deadLetterExchange() { + return new DirectExchange(DEAD_LETTER_EXCHANGE_NAME); + } + + @Bean + public Binding deadLetterBinding(Queue deadLetterQueue, DirectExchange deadLetterExchange) { + return BindingBuilder.bind(deadLetterQueue).to(deadLetterExchange).with(DEAD_LETTER_ROUTING_KEY); + } + + @Bean + public RabbitTemplate applicantIdTemplate(ConnectionFactory connectionFactory) { + RabbitTemplate template = new RabbitTemplate(connectionFactory); + template.setMessageConverter(Jackson2JsonMessageConverter()); + template.setExchange(APPLICANT_ID_EXCHANGE_NAME); + template.setRoutingKey(APPLICANT_ID_ROUTING_KEY); + + return template; + } + + @Bean + public MessageConverter Jackson2JsonMessageConverter() { + return new Jackson2JsonMessageConverter(); + } + + @Bean + public ConnectionFactory connectionFactory() { + CachingConnectionFactory cf = new CachingConnectionFactory(host, port); + cf.setUsername(username); + cf.setPassword(password); + return cf; + } +} \ No newline at end of file diff --git a/backend/src/main/java/moadong/global/config/RestTemplateConfig.java b/backend/src/main/java/moadong/global/config/RestTemplateConfig.java new file mode 100644 index 000000000..465b9a833 --- /dev/null +++ b/backend/src/main/java/moadong/global/config/RestTemplateConfig.java @@ -0,0 +1,20 @@ +package moadong.global.config; + +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; + +import java.time.Duration; + +@Configuration +public class RestTemplateConfig { + + @Bean + public RestTemplate restTemplate(RestTemplateBuilder builder) { + return builder + .setConnectTimeout(Duration.ofSeconds(5)) + .setReadTimeout(Duration.ofSeconds(60)) + .build(); + } +}