Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,9 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-batch'
testImplementation 'org.springframework.batch:spring-batch-test'

// Apache PDF Box
// Apache
implementation group: 'org.apache.pdfbox', name: 'pdfbox', version: '2.0.31'
implementation "org.apache.httpcomponents.client5:httpclient5"

// Open AI
implementation("com.openai:openai-java:4.15.0")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,17 +37,17 @@ public CreateOnboardingResponse execute(CreateOnboardingRequest request) {
.build();
}

String providerId = request.getUserPrincipal().getProviderId();
if (providerId == null || providerId.isBlank()) {
Long userId = request.getUserPrincipal().getUserId();
if (userId == null) {
return CreateOnboardingResponse.builder()
.success(false)
.errorCode(CreateOnboardingErrorCode.NOT_EXIST_PROVIDER_ID)
.build();
}

Optional<User> userOptional = userRepository.findByProviderId(providerId);
Optional<User> userOptional = userRepository.findById(userId);
if (userOptional.isEmpty()) {
log.error("[CreateOnboardingService] User not found for providerId: {}", providerId);
log.error("[CreateOnboardingService] User not found for userId: {}", userId);
return CreateOnboardingResponse.builder()
.success(false)
.errorCode(CreateOnboardingErrorCode.NOT_FOUND_USER)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,17 +34,17 @@ public ReadUserResponse execute(ReadUserRequest request) {
.build();
}

String providerId = request.getUserPrincipal().getProviderId();
if (providerId == null || providerId.isBlank()) {
Long userId = request.getUserPrincipal().getUserId();
if (userId == null) {
return ReadUserResponse.builder()
.success(false)
.errorCode(ReadUserErrorCode.NOT_EXIST_PROVIDER_ID)
.build();
}

Optional<User> userOptional = userRepository.findByProviderId(providerId);
Optional<User> userOptional = userRepository.findById(userId);
if (userOptional.isEmpty()) {
log.error("[ReadUserService] User not found for providerId: {}", providerId);
log.error("[ReadUserService] User not found for userId: {}", userId);
return ReadUserResponse.builder()
.success(false)
.errorCode(ReadUserErrorCode.NOT_FOUND_USER)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,17 +41,17 @@ public BatchAggregateSummaryResponse execute(BatchAggregateSummaryRequest reques
.build();
}

String providerId = request.getUserPrincipal().getProviderId();
if (providerId == null || providerId.isBlank()) {
Long userId = request.getUserPrincipal().getUserId();
if (userId == null) {
return BatchAggregateSummaryResponse.builder()
.success(false)
.errorCode(BatchAggregateSummaryErrorCode.INVALID_PARAMETER)
.build();
}

var userOptional = userRepository.findByProviderId(providerId);
var userOptional = userRepository.findById(userId);
if (userOptional.isEmpty()) {
log.error("[BatchAggregateSummaryService] User not found for providerId: {}", providerId);
log.error("[BatchAggregateSummaryService] User not found for userId: {}", userId);
return BatchAggregateSummaryResponse.builder()
.success(false)
.errorCode(BatchAggregateSummaryErrorCode.NOT_FOUND_USER)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package org.quizly.quizly.batch.listener;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.quizly.quizly.batch.message.BatchFailureNotificationMessage;
import org.quizly.quizly.core.notification.NotificationProvider;
import org.springframework.batch.core.BatchStatus;
import org.springframework.batch.core.JobExecution;
import org.springframework.batch.core.JobExecutionListener;
import org.springframework.stereotype.Component;

import java.util.stream.Collectors;

@Slf4j
@Component
@RequiredArgsConstructor
public class BatchFailureAlertListener implements JobExecutionListener {

private final NotificationProvider notificationProvider;

@Override
public void afterJob(JobExecution jobExecution) {
if (jobExecution.getStatus() == BatchStatus.FAILED) {

String jobName = jobExecution.getJobInstance().getJobName();

String reason = jobExecution.getAllFailureExceptions().isEmpty()
? "unknown"
: jobExecution.getAllFailureExceptions().get(0).getMessage();

String step = jobExecution.getStepExecutions().stream()
.filter(se -> se.getStatus() == BatchStatus.FAILED)
.map(se -> se.getStepName() + " (" + se.getExitStatus().getExitCode() + ")")
.collect(Collectors.joining(", "));


String parameters = formatJobParameters(jobExecution);

notificationProvider.send(
new BatchFailureNotificationMessage(jobName, reason, step, parameters)
);
}
}

private String formatJobParameters(JobExecution jobExecution){
return jobExecution.getJobParameters()
.getParameters()
.entrySet()
.stream()
.map(e -> e.getKey() + "=" + e.getValue().getValue())
.collect(Collectors.joining(", "));
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package org.quizly.quizly.batch.message;

import org.quizly.quizly.core.notification.NotificationMessage;

public class BatchFailureNotificationMessage implements NotificationMessage {

private final String jobName;
private final String reason;

private final String step;

private final String parameters;

public BatchFailureNotificationMessage(String jobName, String reason, String step, String parameters) {
this.jobName = jobName;
this.reason = reason;
this.step = step;
this.parameters = parameters;
}

@Override
public String title() {
return "Batch Failure";
}

@Override
public String body() {
return "job=" + jobName + "\nreason=" + reason + "\nstep=" + step + "\nparameters=" + parameters;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package org.quizly.quizly.configuration;

import org.apache.hc.client5.http.config.RequestConfig;
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
import org.apache.hc.client5.http.impl.classic.HttpClients;
import org.apache.hc.core5.util.Timeout;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
import org.springframework.web.client.RestTemplate;

import java.time.Duration;

@Configuration
public class RestClientConfig {

@Bean
public RestTemplate restTemplate() {

HttpComponentsClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory();
factory.setConnectTimeout(Duration.ofSeconds(3));

CloseableHttpClient httpClient = HttpClients.custom()
.setDefaultRequestConfig(RequestConfig.custom()
.setResponseTimeout(Timeout.ofSeconds(3))
.build())
.build();
factory.setHttpClient(httpClient);
return new RestTemplate(factory);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,7 @@
public class RefreshToken extends BaseEntity {

@Column(nullable = false, unique = true)
private String providerId;

@Column(nullable = false)
private String name;
private Long userId;

@Column(nullable = false)
private String token;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,7 @@

public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {

Optional<RefreshToken> findByProviderId(String providerId);
Optional<RefreshToken> findByUserId(Long userId);

void deleteByUserId(Long userId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package org.quizly.quizly.core.notification;

public interface NotificationMessage {
String title();
String body();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package org.quizly.quizly.core.notification;

public interface NotificationProvider {
void send(NotificationMessage message);
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executor;
import java.util.stream.Collectors;

@Log4j2
@Component
Expand Down Expand Up @@ -60,27 +59,73 @@ public OcrExtractResponse execute(OcrExtractRequest request) {

private CompletableFuture<String> extractMergedPlainTextAsync(MultipartFile file) {

List<MultipartFile> batches =
PdfBoxPageBatchExtractor.splitToPdfBatches(file);
String contentType = file.getContentType();
String extension = getFileExtension(file);

List<CompletableFuture<ClovaOcrService.ClovaOcrResponse>> futures =
batches.stream()
boolean isPdf =
contentType != null
&& "application/pdf".equalsIgnoreCase(contentType)
&& "pdf".equals(extension);

boolean isImage =
contentType != null
&& contentType.startsWith("image/")
&& List.of("jpg", "jpeg", "png", "bmp", "webp").contains(extension);

if (isPdf) {
try {
List<MultipartFile> batches =
PdfBoxPageBatchExtractor.splitToPdfBatches(file);

List<CompletableFuture<ClovaOcrService.ClovaOcrResponse>> futures =
batches.stream()
.map(this::callAsync)
.toList();

CompletableFuture<Void> allDone =
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]));
return CompletableFuture
.allOf(futures.toArray(new CompletableFuture[0]))
.thenApply(v -> {
List<ClovaOcrService.ClovaOcrResponse> results = futures.stream()
.map(CompletableFuture::join)
.toList();

boolean hasFailure = results.stream()
.anyMatch(response -> !response.isSuccess());

if (hasFailure) {
throw OcrExtractErrorCode.OCR_PROCESS_FAILED.toException();
}

List<String> plainTexts = results.stream()
.map(ClovaOcrService.ClovaOcrResponse::getPlainText)
.toList();

return String.join("\n", plainTexts);
});

} catch (Exception e) {
log.error("[AsyncOcrService] PDF split failed", e);

return allDone.thenApply(v ->
futures.stream()
.map(CompletableFuture::join)
.filter(ClovaOcrService.ClovaOcrResponse::isSuccess)
.map(ClovaOcrService.ClovaOcrResponse::getPlainText)
.collect(Collectors.joining("\n"))
return CompletableFuture.failedFuture(
OcrExtractErrorCode.PDF_SPLIT_FAILED.toException()
);
}
}
if (isImage) {
return callAsync(file)
.thenApply(response -> {
if (!response.isSuccess()) {
throw OcrExtractErrorCode.OCR_PROCESS_FAILED.toException();
}
return response.getPlainText();
});
}

return CompletableFuture.failedFuture(
OcrExtractErrorCode.UNSUPPORTED_FILE_TYPE.toException()
);
}


public CompletableFuture<ClovaOcrService.ClovaOcrResponse> callAsync(MultipartFile batch) {
ClovaOcrService.ClovaOcrRequest request =
ClovaOcrService.ClovaOcrRequest.builder()
Expand All @@ -92,6 +137,14 @@ public CompletableFuture<ClovaOcrService.ClovaOcrResponse> callAsync(MultipartFi
ocrExecutor
);
}
private String getFileExtension(MultipartFile file) {
String filename = file.getOriginalFilename();
if (filename == null || !filename.contains(".")) {
return "";
}
return filename.substring(filename.lastIndexOf('.') + 1).toLowerCase();
}


@Getter
@Builder
Expand Down Expand Up @@ -119,7 +172,10 @@ public static class OcrExtractResponse extends BaseResponse<OcrExtractErrorCode>
@Getter
@RequiredArgsConstructor
public enum OcrExtractErrorCode implements BaseErrorCode<DomainException> {
NOT_EXIST_FILE(HttpStatus.BAD_REQUEST, "요청 파일이 존재하지 않습니다.");
NOT_EXIST_FILE(HttpStatus.BAD_REQUEST, "요청 파일이 존재하지 않습니다."),
UNSUPPORTED_FILE_TYPE(HttpStatus.BAD_REQUEST, "지원하지 않는 파일 형식입니다."),
PDF_SPLIT_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "PDF 페이지 분할에 실패했습니다."),
OCR_PROCESS_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "OCR 처리에 실패했습니다.");

private final HttpStatus httpStatus;
private final String message;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package org.quizly.quizly.external.slack.dto.Request;

public record SlackRequest(String text) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package org.quizly.quizly.external.slack.service;

import org.quizly.quizly.core.notification.NotificationMessage;
import org.quizly.quizly.core.notification.NotificationProvider;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;

@Profile({"local"})
@Service
public class NoOpSlackNotificationService implements NotificationProvider {
@Override
public void send(NotificationMessage message) {

}
}
Loading
Loading