From d77a4be15760f46c177f385c91f255441a97608d Mon Sep 17 00:00:00 2001 From: minju Date: Tue, 19 May 2026 20:54:14 +0900 Subject: [PATCH 01/33] =?UTF-8?q?chore:=20AI=20Swagger=20=EC=A0=91?= =?UTF-8?q?=EA=B7=BC=20=EB=B3=B4=ED=98=B8=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/nginx/nginx.conf | 30 ++++++++++++++++++++++++ deploy/nginx/nginx.https.template.conf | 32 +++++++++++++++++++++++++- 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/deploy/nginx/nginx.conf b/deploy/nginx/nginx.conf index 8be026d..819aed4 100644 --- a/deploy/nginx/nginx.conf +++ b/deploy/nginx/nginx.conf @@ -100,6 +100,36 @@ http { proxy_set_header X-Forwarded-Proto $scheme; } + location /ai/docs { + auth_basic "GACHI Swagger"; + auth_basic_user_file /etc/nginx/secrets/swagger_htpasswd.txt; + proxy_pass http://ai_upstream; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location = /ai/openapi.json { + auth_basic "GACHI Swagger"; + auth_basic_user_file /etc/nginx/secrets/swagger_htpasswd.txt; + proxy_pass http://ai_upstream; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /ai/redoc { + auth_basic "GACHI Swagger"; + auth_basic_user_file /etc/nginx/secrets/swagger_htpasswd.txt; + proxy_pass http://ai_upstream; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + location /ai/ { proxy_pass http://ai_upstream; proxy_set_header Host $host; diff --git a/deploy/nginx/nginx.https.template.conf b/deploy/nginx/nginx.https.template.conf index 929b4fe..8e02ded 100644 --- a/deploy/nginx/nginx.https.template.conf +++ b/deploy/nginx/nginx.https.template.conf @@ -80,6 +80,36 @@ http { proxy_set_header X-Forwarded-Proto $scheme; } + location /ai/docs { + auth_basic "GACHI Swagger"; + auth_basic_user_file /etc/nginx/secrets/swagger_htpasswd.txt; + proxy_pass http://ai_upstream; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location = /ai/openapi.json { + auth_basic "GACHI Swagger"; + auth_basic_user_file /etc/nginx/secrets/swagger_htpasswd.txt; + proxy_pass http://ai_upstream; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /ai/redoc { + auth_basic "GACHI Swagger"; + auth_basic_user_file /etc/nginx/secrets/swagger_htpasswd.txt; + proxy_pass http://ai_upstream; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + location /ai/ { proxy_pass http://ai_upstream; proxy_set_header Host $host; @@ -93,4 +123,4 @@ http { add_header Content-Type text/plain; } } -} \ No newline at end of file +} From d461503fa92d0aa8d0857805c2ab5e5877acfc03 Mon Sep 17 00:00:00 2001 From: minju Date: Tue, 19 May 2026 22:22:49 +0900 Subject: [PATCH 02/33] =?UTF-8?q?refactor:=20=EA=B0=80=EC=A0=95=ED=86=B5?= =?UTF-8?q?=EC=8B=A0=EB=AC=B8=20=EB=B6=84=EC=84=9D=EC=9D=84=20AI=20?= =?UTF-8?q?=EC=84=9C=EB=B2=84=20client=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 1 - deploy/.env.example | 1 + deploy/docker-compose.yml | 4 +- .../pipeline/AiNewsletterClient.java | 149 +++++ .../pipeline/NewsletterAiAnalyzer.java | 537 +++--------------- .../pipeline/NewsletterPipelineService.java | 150 +---- .../config/external/AiServerProperties.java | 15 + .../config/external/ExternalApiConfig.java | 2 +- .../config/external/OpenAiProperties.java | 16 - src/main/resources/application.yml | 10 +- 10 files changed, 270 insertions(+), 615 deletions(-) create mode 100644 src/main/java/com/gachi/be/domain/newsletter/pipeline/AiNewsletterClient.java create mode 100644 src/main/java/com/gachi/be/global/config/external/AiServerProperties.java delete mode 100644 src/main/java/com/gachi/be/global/config/external/OpenAiProperties.java diff --git a/build.gradle b/build.gradle index 7fd7d46..efbf5da 100644 --- a/build.gradle +++ b/build.gradle @@ -41,7 +41,6 @@ dependencies { implementation 'software.amazon.awssdk:s3' implementation 'com.drewnoakes:metadata-extractor:2.19.0' implementation 'com.fasterxml.jackson.core:jackson-databind' - implementation 'org.apache.pdfbox:pdfbox:3.0.3' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6' compileOnly 'org.projectlombok:lombok' diff --git a/deploy/.env.example b/deploy/.env.example index 0c89bde..ac31d36 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -31,6 +31,7 @@ SPRING_MAIL_PROPERTIES_MAIL_SMTP_AUTH=true SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_ENABLE=true SPRING_MAIL_USERNAME= +AI_SERVER_BASE_URL=http://ai:8000 OPENAI_API_KEY= AUTH_EMAIL_FROM_ADDRESS= diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 78c79f7..ed05e1f 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -97,9 +97,7 @@ services: CLOVA_OCR_SECRET_KEY: ${CLOVA_OCR_SECRET_KEY} PAPAGO_CLIENT_ID: ${PAPAGO_CLIENT_ID} PAPAGO_CLIENT_SECRET: ${PAPAGO_CLIENT_SECRET} - OPENAI_API_KEY: ${OPENAI_API_KEY} - OPENAI_MODEL: ${OPENAI_MODEL:-gpt-4.1-mini} - OPENAI_MAX_TOKENS: ${OPENAI_MAX_TOKENS:-2000} + AI_SERVER_BASE_URL: ${AI_SERVER_BASE_URL:-http://ai:8000} secrets: - source: spring_mail_password target: spring_mail_password diff --git a/src/main/java/com/gachi/be/domain/newsletter/pipeline/AiNewsletterClient.java b/src/main/java/com/gachi/be/domain/newsletter/pipeline/AiNewsletterClient.java new file mode 100644 index 0000000..2b3301b --- /dev/null +++ b/src/main/java/com/gachi/be/domain/newsletter/pipeline/AiNewsletterClient.java @@ -0,0 +1,149 @@ +package com.gachi.be.domain.newsletter.pipeline; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.gachi.be.domain.newsletter.entity.NewsletterDateCandidate; +import com.gachi.be.global.code.ErrorCode; +import com.gachi.be.global.config.external.AiServerProperties; +import com.gachi.be.global.exception.ExternalApiException; +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class AiNewsletterClient { + + private static final ZoneId DEFAULT_ZONE = ZoneId.of("Asia/Seoul"); + private static final String EXTRACT_ITEMS_PATH = "/ai/newsletters/extract-items"; + + private final AiServerProperties aiServerProperties; + private final ObjectMapper objectMapper; + + public List extractItems( + String originalText, + String translatedText, + String language, + List dateCandidates) { + try { + String requestBody = + objectMapper.writeValueAsString( + new ExtractionRequest( + originalText, + translatedText, + language != null ? language : "KO", + LocalDate.now(DEFAULT_ZONE), + DEFAULT_ZONE.getId(), + toDateCandidateRequests(dateCandidates))); + + HttpRequest request = + HttpRequest.newBuilder() + .uri(URI.create(normalizedBaseUrl() + EXTRACT_ITEMS_PATH)) + .header("Content-Type", "application/json") + .timeout(Duration.ofSeconds(aiServerProperties.getReadTimeoutSeconds())) + .POST(HttpRequest.BodyPublishers.ofString(requestBody)) + .build(); + + HttpClient httpClient = + HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(aiServerProperties.getConnectTimeoutSeconds())) + .build(); + + HttpResponse response = + httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() < 200 || response.statusCode() >= 300) { + log.error( + "[AiNewsletterClient] AI 서버 항목 추출 실패. status={}, body={}", + response.statusCode(), + response.body()); + throw new ExternalApiException( + ErrorCode.EXTERNAL_API_ERROR, "AI 서버 항목 추출 실패. status=" + response.statusCode()); + } + + ExtractionResponse extractionResponse = + objectMapper.readValue(response.body(), ExtractionResponse.class); + return extractionResponse.items() != null ? extractionResponse.items() : List.of(); + } catch (ExternalApiException e) { + throw e; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new ExternalApiException( + ErrorCode.EXTERNAL_API_ERROR, "AI 서버 통신 인터럽트: " + e.getMessage()); + } catch (IOException e) { + throw new ExternalApiException( + ErrorCode.EXTERNAL_API_ERROR, "AI 서버 통신 오류: " + e.getMessage()); + } + } + + private String normalizedBaseUrl() { + String baseUrl = aiServerProperties.getBaseUrl(); + if (baseUrl == null || baseUrl.isBlank()) { + throw new ExternalApiException(ErrorCode.EXTERNAL_API_ERROR, "AI 서버 base-url이 비어 있습니다."); + } + return baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl; + } + + private List toDateCandidateRequests( + List dateCandidates) { + if (dateCandidates == null || dateCandidates.isEmpty()) { + return List.of(); + } + + List requests = new ArrayList<>(); + for (int i = 0; i < dateCandidates.size(); i++) { + NewsletterDateCandidate candidate = dateCandidates.get(i); + requests.add( + new DateCandidateRequest( + "dc_" + (i + 1), + candidate.originalText(), + candidate.normalizedDate(), + candidate.startOffset(), + candidate.endOffset(), + candidate.extractionType() != null ? candidate.extractionType().name() : null)); + } + return requests; + } + + record ExtractionRequest( + String originalText, + String translatedText, + String language, + LocalDate referenceDate, + String timezone, + List dateCandidates) {} + + record DateCandidateRequest( + String candidateId, + String originalText, + LocalDate normalizedDate, + int startOffset, + int endOffset, + String extractionType) {} + + @JsonIgnoreProperties(ignoreUnknown = true) + record ExtractionResponse(List items) {} + + @JsonIgnoreProperties(ignoreUnknown = true) + public record ExtractedItem( + String type, + String title, + String datetime, + String timezone, + String evidenceText, + String dateStatus, + Double confidence, + Boolean needsUserConfirmation, + String confirmationQuestion) {} +} diff --git a/src/main/java/com/gachi/be/domain/newsletter/pipeline/NewsletterAiAnalyzer.java b/src/main/java/com/gachi/be/domain/newsletter/pipeline/NewsletterAiAnalyzer.java index 50febbd..407ec40 100644 --- a/src/main/java/com/gachi/be/domain/newsletter/pipeline/NewsletterAiAnalyzer.java +++ b/src/main/java/com/gachi/be/domain/newsletter/pipeline/NewsletterAiAnalyzer.java @@ -1,84 +1,35 @@ package com.gachi.be.domain.newsletter.pipeline; -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; import com.gachi.be.domain.checklist.entity.Checklist; import com.gachi.be.domain.checklist.entity.enums.ChecklistType; import com.gachi.be.domain.checklist.repository.ChecklistRepository; import com.gachi.be.domain.newsletter.entity.Newsletter; +import com.gachi.be.domain.newsletter.pipeline.AiNewsletterClient.ExtractedItem; import com.gachi.be.domain.newsletter.repository.NewsletterRepository; -import com.gachi.be.file.config.S3Properties; -import com.gachi.be.global.code.ErrorCode; -import com.gachi.be.global.config.external.OpenAiProperties; -import com.gachi.be.global.exception.ExternalApiException; -import java.io.IOException; -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.time.Duration; import java.time.LocalDate; -import java.util.ArrayList; +import java.time.format.DateTimeParseException; import java.util.List; -import java.util.Map; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; -import software.amazon.awssdk.services.s3.model.GetObjectRequest; -import software.amazon.awssdk.services.s3.presigner.S3Presigner; -import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; -/** - * OpenAI를 이용한 가정통신문 AI 분석 컴포넌트. 모든 분석은 OpenAI Chat Completions API를 호출하여 수행. 모델은 application.yml의 - * app.openai.model 값으로 설정 (기본: gpt-4o-mini). -> TODO: 추후 결과 보고 일반 모델로 변경할 수도 - * - *

프롬프트 설계 원칙: - 시스템 프롬프트: AI의 역할과 출력 형식을 명확히 지정 - 사용자 프롬프트: 실제 가정통신문 텍스트 - temperature=0.3: 낮은 - * 값으로 설정하여 일관된 결과 보장 (0에 가까울수록 결정적, 1에 가까울수록 창의적) - */ @Slf4j @Component @RequiredArgsConstructor public class NewsletterAiAnalyzer { - private final OpenAiProperties openAiProperties; - private final ObjectMapper objectMapper; + private static final String DEFAULT_TITLE = "가정통신문 안내"; + private static final int TITLE_MAX_LENGTH = 80; + private static final int SUMMARY_MAX_LENGTH = 300; + private static final int CHECKLIST_TEXT_MAX_LENGTH = 500; + + private final AiNewsletterClient aiNewsletterClient; private final ChecklistRepository checklistRepository; private final NewsletterRepository newsletterRepository; - private final S3Presigner s3Presigner; - private final S3Properties s3Properties; - // vision 용 presigned URL 유효시간 -> DB에는 S3 key 만 - private static final int VISION_PRESIGNED_URL_MINUTES = 15; - - // Vision 입력을 담는 내부 DTO -> analyze 진입 시 1회만 구성하고 제목 추출에만 전달 - private record VisionInput(String imagePresignedUrl, List pdfPageBase64Images) { - - /** Vision 입력이 있는지 여부. */ - boolean hasVision() { - return imagePresignedUrl != null - || (pdfPageBase64Images != null && !pdfPageBase64Images.isEmpty()); - } - /** Vision 없는 빈 입력 생성 (텍스트 전용 모드용). */ - static VisionInput none() { - return new VisionInput(null, null); - } - } - - /** - * 가정통신문 전체 AI 분석을 수행하고 결과를 DB에 저장. - * - *

분석 기준 텍스트: - 제목/체크리스트/해야할일: originalText (한국어 원문 기준) → 번역 텍스트보다 원문이 날짜, 고유명사 등을 더 정확하게 포함하기 - * 때문 - 요약: language=KO면 originalText, 그 외 translatedText 기준 → 사용자 언어로 읽기 편한 요약을 제공하기 위해 - */ public AiAnalysisResult analyze( - Long newsletterId, - String originalText, - String translatedText, - String language, - List pdfPageBase64Images) { - - log.info("[AiAnalyzer] 분석 시작. newsletterId={}, language={}", newsletterId, language); + Long newsletterId, String originalText, String translatedText, String language) { + log.info("[AiAnalyzer] AI 서버 분석 시작. newsletterId={}, language={}", newsletterId, language); Newsletter newsletter = newsletterRepository @@ -88,436 +39,114 @@ public AiAnalysisResult analyze( new IllegalStateException( "[AiAnalyzer] newsletter를 찾을 수 없습니다. newsletterId=" + newsletterId)); - Long userId = newsletter.getUserId(); - String fileKey = newsletter.getFileKey(); - - boolean isImage = isImageFile(fileKey); - log.debug("[AiAnalyzer] 파일 타입 판단. fileKey={}, isImage={}", fileKey, isImage); - - VisionInput visionInput; - if (isImage) { - String presignedUrl = generatePresignedUrl(fileKey); - visionInput = new VisionInput(presignedUrl, null); - log.debug("[AiAnalyzer] 이미지 Vision 입력 구성 완료."); - } else if (pdfPageBase64Images != null && !pdfPageBase64Images.isEmpty()) { - visionInput = new VisionInput(null, pdfPageBase64Images); - log.debug("[AiAnalyzer] PDF Vision 입력 구성 완료. pages={}", pdfPageBase64Images.size()); - } else { - visionInput = VisionInput.none(); - log.debug("[AiAnalyzer] Vision 입력 없음. 텍스트 전용 모드."); - } + List items = + aiNewsletterClient.extractItems( + originalText, translatedText, language, newsletter.getDateCandidates()); - // 요약 기준 텍스트 결정 - String summarySourceText = - (translatedText != null && !translatedText.isBlank()) ? translatedText : originalText; + saveExtractedItems(newsletterId, newsletter.getUserId(), items); - // 제목 추출 - String title = extractTitle(originalText, visionInput); - log.debug("[AiAnalyzer] 제목 추출 완료. title={}", title); + String title = inferTitle(originalText); + String summary = buildBaselineSummary(translatedText != null ? translatedText : originalText); - // AI 요약 - String summary = generateSummary(summarySourceText, language, VisionInput.none()); - log.debug("[AiAnalyzer] 요약 완료. length={}chars", summary.length()); - - // 체크리스트 추출 + DB 저장 - extractAndSaveChecklist(newsletterId, userId, originalText, VisionInput.none()); - log.debug("[AiAnalyzer] 체크리스트 저장 완료."); - - // 해야 할 일 추출 + DB 저장 - extractAndSaveTodos(newsletterId, userId, originalText, VisionInput.none()); - log.debug("[AiAnalyzer] 해야 할 일 저장 완료."); - - log.info("[AiAnalyzer] 분석 완료. newsletterId={}", newsletterId); + log.info( + "[AiAnalyzer] AI 서버 분석 완료. newsletterId={}, extractedItems={}", newsletterId, items.size()); return new AiAnalysisResult(title, summary); } - private boolean isImageFile(String fileKey) { - if (fileKey == null) return false; - String lower = fileKey.toLowerCase(); - return lower.endsWith(".jpg") || lower.endsWith(".jpeg") || lower.endsWith(".png"); - } - - private String generatePresignedUrl(String fileKey) { - try { - GetObjectRequest getObjectRequest = - GetObjectRequest.builder().bucket(s3Properties.getBucket()).key(fileKey).build(); - - GetObjectPresignRequest presignRequest = - GetObjectPresignRequest.builder() - .signatureDuration(Duration.ofMinutes(VISION_PRESIGNED_URL_MINUTES)) - .getObjectRequest(getObjectRequest) - .build(); - - return s3Presigner.presignGetObject(presignRequest).url().toString(); - } catch (Exception e) { - // Presigned URL 생성 실패 시 Vision 없이 텍스트만으로 분석 진행 (서비스 중단 방지) - log.warn("[AiAnalyzer] Presigned URL 생성 실패. 텍스트만으로 분석 진행. error={}", e.getMessage()); - return null; - } - } - - /** 가정통신문 원문에서 제목을 추출. */ - private String extractTitle(String originalText, VisionInput visionInput) { - // 시스템 프롬프트 (AI 역할 + 출력 형식 지정) - String systemPrompt = - """ - 당신은 한국 초등학교 가정통신문을 분석하는 전문가입니다. - 가정통신문 텍스트에서 공식 제목을 추출하는 것이 당신의 역할입니다. - - 규칙: - - 가정통신문의 공식 제목만 추출하세요. - - 30자 이내로 작성하세요. - - 제목 텍스트만 반환하고, 설명이나 다른 텍스트는 절대 포함하지 마세요. - - 앞뒤에 마크다운 코드블록(```), 따옴표, 설명을 붙이지 마세요. - - 제목을 찾을 수 없으면 "가정통신문 안내"를 반환하세요. - """; - String response = callOpenAi(systemPrompt, originalText, visionInput, 100); - return response.trim(); - } - - /** - * 가정통신문 핵심 내용을 다문화 학부모를 위해 요약 프롬프트 설명: 시스템: 다문화 학부모 친화적 요약을 위한 지침 제공. - 언어 코드에 따라 응답 언어 지정 - 날짜, - * 장소, 준비물, 제출 마감 등 핵심 정보 우선 포함 - 쉬운 표현 사용 (전문 용어 지양) - 3~5문장 제한으로 핵심만 전달 사용자: KO이면 원문, 그 외이면 번역문을 - * 전달 (사용자 모국어 텍스트 기준으로 요약해야 더 자연스러움) - */ - private String generateSummary(String sourceText, String language, VisionInput visionInput) { - String responseLanguage = - switch (language == null ? "KO" : language) { - case "US" -> "영어(English)"; - case "ZH" -> "중국어 간체(简体中文)"; - case "VI" -> "베트남어(Tiếng Việt)"; - default -> "한국어"; - }; - - // 시스템 프롬프트 - String systemPrompt = - String.format( - """ - 당신은 한국 초등학교 가정통신문을 다문화 가정 학부모에게 쉽게 설명해주는 전문가입니다. - 반드시 %s로만 답변하세요. - - 요약 규칙: - - 3~5문장으로 핵심 내용을 요약하세요. - - 날짜, 장소, 준비물, 제출 마감일을 반드시 포함하세요. - - 학부모가 바로 이해하고 행동할 수 있도록 명확하게 작성하세요. - - 어려운 교육 전문 용어는 쉬운 표현으로 바꿔 쓰세요. - - 요약 텍스트만 반환하고, 제목이나 설명은 포함하지 마세요. - - 앞뒤에 마크다운 코드블록(```)을 붙이지 마세요. - """, - responseLanguage); - - String response = callOpenAi(systemPrompt, sourceText, visionInput, 500); - return response.trim(); - } - - /** - * 가정통신문에서 체크리스트 항목을 추출하고 DB에 저장. 프롬프트 설명: 시스템: JSON 배열만 반환하도록 엄격히 지정. - content: 체크리스트 주요 항목 - * (UI에서 굵게 표시) - detail: 한 줄 상세 설명 (UI에서 작게 표시) - 최대 10개로 제한 (너무 많으면 사용자가 압도됨) TODO: 이거 더 얘기해보기 - - * JSON 외 다른 텍스트 절대 금지 (파싱 실패 방지) - */ - private void extractAndSaveChecklist( - Long newsletterId, Long userId, String originalText, VisionInput visionInput) { - // 시스템 프롬프트 - String systemPrompt = - """ - 당신은 한국 초등학교 가정통신문에서 학부모가 해야 할 행동 목록을 추출하는 전문가입니다. - - 추출 규칙: - - 학부모가 직접 해야 할 준비물, 제출물, 확인 사항만 추출하세요. - - 단순 안내 정보(학교 일정, 공지 등)는 제외하세요. - - 최대 10개까지만 추출하세요. - - content: 20자 이내의 핵심 항목명 (예: "현장학습 동의서 제출") - - detail: 30자 이내의 한 줄 상세 설명 (예: "담임 선생님께 원본 직접 제출") - - detail이 없으면 문자열 "null"이 아닌 JSON null로 설정하세요. - - 출력 규칙: - - JSON 배열만 반환하세요. - - 앞뒤에 설명, 제목, 마크다운 코드블록(```json)을 절대 붙이지 마세요. - - 괄호와 따옴표가 올바르게 닫혔는지 출력 전에 확인하세요. - 형식: [{"content": "항목명", "detail": null}] - """; - - String response = callOpenAi(systemPrompt, originalText, visionInput, 800); - List items = parseJsonList(response, new TypeReference<>() {}); - + private void saveExtractedItems(Long newsletterId, Long userId, List items) { if (items == null || items.isEmpty()) { - log.warn("[AiAnalyzer] 체크리스트 추출 결과 없음. newsletterId={}", newsletterId); + log.warn("[AiAnalyzer] AI 서버 항목 추출 결과 없음. newsletterId={}", newsletterId); return; } List entities = items.stream() - .filter(dto -> dto.content() != null && !dto.content().isBlank()) - .map( - dto -> - Checklist.builder() - .newsletterId(newsletterId) - .calendarEventId(null) - .userId(userId) - .type(ChecklistType.CHECKLIST) - .content(dto.content().trim()) - .detail(dto.detail() != null ? dto.detail().trim() : null) - .targetDate(null) - .targetDateLabel(null) - .build()) + .filter(item -> item.title() != null && !item.title().isBlank()) + .map(item -> toChecklist(newsletterId, userId, item)) .toList(); - checklistRepository.saveAll(entities); - log.debug("[AiAnalyzer] 체크리스트 {}개 저장 완료.", entities.size()); - } - - /** - * 가정통신문에서 날짜 기반 해야 할 일을 추출하고 DB에 저장. 프롬프트 설명: 시스템: 날짜 맥락이 중요한 행동 계획 추출. - targetDate: 명확한 날짜가 있으면 - * YYYY-MM-DD, 없으면 null - targetDateLabel: 사용자에게 보여줄 문구 → 날짜 있으면 "5월 15일", 없으면 "지금 바로" / "행사 전날" 등 - * 맥락에 맞게 - 오늘 날짜를 프롬프트에 주입하여 "내일", "이번 주" 등 상대적 표현을 절대 날짜로 변환 - 최대 5개로 제한 (너무 많으면 핵심이 희석됨) TODO: - * 이거 더 얘기해보기 - */ - private void extractAndSaveTodos( - Long newsletterId, Long userId, String originalText, VisionInput visionInput) { - // 오늘 날짜를 프롬프트에 주입 (상대적 날짜 표현 변환을 위해) - String today = LocalDate.now().toString(); // 예: "2026-04-13" - - // 시스템 프롬프트 - String systemPrompt = - String.format( - """ - 당신은 한국 초등학교 가정통신문을 분석하여 학부모의 실행 계획을 짜주는 전문가입니다. - 오늘 날짜는 %s입니다. - - 계획 수립 원칙: - - 가정통신문에서 마감일/행사일을 먼저 파악하세요. - - 마감일을 기준으로 역산하여 언제 무엇을 해야 하는지 계획을 세우세요. - - 예: 현장학습이 5월 21일이면 -> 전날(5월 20일) 준비물 챙기기, 이틀 전 동의서 서명 등 - - 준비물 구매처럼 시간이 필요한 것은 마감일보다 여유 있게 배치하세요. - - 즉시 해야 할 것(동의서 출력 등)은 "지금 바로"로 배치하세요. - - 마감일이 명시되지 않은 준비물은 "지금 바로" 또는 가장 가까운 관련 일정 전날로 배치하세요. - - 추출 규칙: - - 최대 5개까지만 추출하고, 시간 순서대로 정렬하세요. - - content: 40자 이내의 구체적인 행동 (예: "담임 선생님께 동의서 직접 제출") - - targetDate: 명확한 날짜가 있으면 YYYY-MM-DD 형식, 즉시 행동이면 null - - targetDateLabel: 사용자에게 보여줄 날짜 문구 - → 날짜 있으면 "N월 N일", 오늘이면 "오늘", 즉시 해야 하면 "지금 바로", 행사 전날이면 "행사 전날" 등 - - 출력 규칙: - - JSON 배열만 반환하세요. - - 앞뒤에 설명, 제목, 마크다운 코드블록(```json)을 절대 붙이지 마세요. - - 괄호와 따옴표가 올바르게 닫혔는지 출력 전에 확인하세요. - 형식: [{"content": "할 일", "targetDate": "2026-05-15 또는 null", "targetDateLabel": "표시 문구"}] - """, - today); - - String response = callOpenAi(systemPrompt, originalText, visionInput, 800); - - List items = parseJsonList(response, new TypeReference<>() {}); - if (items == null || items.isEmpty()) { - log.warn("[AiAnalyzer] 해야 할 일 추출 결과 없음. newsletterId={}", newsletterId); + if (entities.isEmpty()) { + log.warn("[AiAnalyzer] 저장 가능한 항목 없음. newsletterId={}", newsletterId); return; } - List entities = - items.stream() - .filter(dto -> dto.content() != null && !dto.content().isBlank()) - .map( - dto -> { - // targetDate 문자열 파싱 (null 또는 "null" 문자열 모두 처리) - LocalDate targetDate = null; - if (dto.targetDate() != null - && !dto.targetDate().isBlank() - && !"null".equals(dto.targetDate())) { - try { - targetDate = LocalDate.parse(dto.targetDate()); - } catch (Exception e) { - log.warn("[AiAnalyzer] targetDate 파싱 실패. value={}", dto.targetDate()); - } - } - return Checklist.builder() - .newsletterId(newsletterId) - .calendarEventId(null) - .userId(userId) - .type(ChecklistType.TODO) - .content(dto.content().trim()) - .detail(null) - .targetDate(targetDate) - .targetDateLabel(dto.targetDateLabel()) - .build(); - }) - .toList(); - checklistRepository.saveAll(entities); - log.debug("[AiAnalyzer] 해야 할 일 {}개 저장 완료.", entities.size()); + log.debug("[AiAnalyzer] AI 서버 추출 항목 {}개 저장 완료.", entities.size()); } - /** - * OpenAI Chat Completions API 호출. - * - * @param systemPrompt AI 역할과 출력 형식을 지정하는 시스템 메시지 - * @param userContent 분석할 가정통신문 텍스트 (사용자 메시지) - * @param maxTokens 응답 최대 토큰 수 (작업마다 다르게 설정) - * @return AI 응답 텍스트 - */ - private String callOpenAi( - String systemPrompt, String userContent, VisionInput visionInput, int maxTokens) { - try { - Object userMessageContent; - - if (visionInput.imagePresignedUrl() != null) { - // 이미지 파일: S3 Presigned URL로 Vision 전달 (detail:high) - List> contentParts = new ArrayList<>(); - contentParts.add( - Map.of( - "type", - "image_url", - "image_url", - Map.of("url", visionInput.imagePresignedUrl(), "detail", "high"))); - contentParts.add(Map.of("type", "text", "text", userContent)); - userMessageContent = contentParts; - log.debug("[AiAnalyzer] Vision 모드(이미지, detail:high)로 OpenAI 호출."); - - } else if (visionInput.pdfPageBase64Images() != null - && !visionInput.pdfPageBase64Images().isEmpty()) { - // PDF 파일: Base64 이미지 목록으로 Vision 전달 (detail:high, 다중 페이지) - List> contentParts = new ArrayList<>(); - int pageCount = visionInput.pdfPageBase64Images().size(); - - // 페이지 수 안내 텍스트: GPT가 페이지 구조를 인식하도록 유도 - contentParts.add( - Map.of( - "type", - "text", - "text", - String.format( - "이 가정통신문은 %d페이지로 구성됩니다. 아래 이미지를 순서대로 참고하여 전체 문맥을 이해하세요.", pageCount))); - - // 각 페이지를 Base64 data URL로 변환하여 순서대로 추가 - for (int i = 0; i < pageCount; i++) { - String dataUrl = "data:image/jpeg;base64," + visionInput.pdfPageBase64Images().get(i); - contentParts.add( - Map.of("type", "image_url", "image_url", Map.of("url", dataUrl, "detail", "high"))); - log.debug("[AiAnalyzer] PDF 페이지 이미지 추가. page={}/{}", i + 1, pageCount); - } - - // OCR 텍스트: 이미지로 못 읽은 부분 보완 - contentParts.add(Map.of("type", "text", "text", userContent)); - userMessageContent = contentParts; - log.debug("[AiAnalyzer] Vision 모드(PDF {}페이지, detail:high)로 OpenAI 호출.", pageCount); - - } else { - // 텍스트 전용 모드: Vision 입력 없음 - userMessageContent = userContent; - log.debug("[AiAnalyzer] 텍스트 전용 모드로 OpenAI 호출."); - } - // 요청 본문 구성 - String requestBody = - objectMapper.writeValueAsString( - Map.of( - "model", - openAiProperties.getModel(), - "messages", - List.of( - Map.of("role", "system", "content", systemPrompt), - Map.of("role", "user", "content", userMessageContent)), - "temperature", - 0.3, // 낮은 값 = 일관된 결과 (0: 결정적, 1: 창의적) - "max_tokens", - maxTokens)); - - HttpClient httpClient = - HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build(); - - HttpRequest request = - HttpRequest.newBuilder() - .uri(URI.create(openAiProperties.getApiUrl())) - .header("Content-Type", "application/json") - .header("Authorization", "Bearer " + openAiProperties.getApiKey()) - .POST(HttpRequest.BodyPublishers.ofString(requestBody)) - .timeout(Duration.ofSeconds(120)) - .build(); - - HttpResponse response = - httpClient.send(request, HttpResponse.BodyHandlers.ofString()); - - if (response.statusCode() < 200 || response.statusCode() >= 300) { - log.error( - "[AiAnalyzer] OpenAI API 호출 실패. status={}, body={}", - response.statusCode(), - response.body()); - throw new ExternalApiException( - ErrorCode.EXTERNAL_API_ERROR, "OpenAI API 오류. status=" + response.statusCode()); - } + private Checklist toChecklist(Long newsletterId, Long userId, ExtractedItem item) { + ChecklistType checklistType = + "checklist".equalsIgnoreCase(item.type()) ? ChecklistType.CHECKLIST : ChecklistType.TODO; + + LocalDate targetDate = parseTargetDate(item.datetime()); + String targetDateLabel = + targetDate != null + ? targetDate.getMonthValue() + "월 " + targetDate.getDayOfMonth() + "일" + : null; + + return Checklist.builder() + .newsletterId(newsletterId) + .calendarEventId(null) + .userId(userId) + .type(checklistType) + .content(trimToMax(item.title().trim(), CHECKLIST_TEXT_MAX_LENGTH)) + .detail(trimNullable(item.evidenceText(), CHECKLIST_TEXT_MAX_LENGTH)) + .targetDate(checklistType == ChecklistType.TODO ? targetDate : null) + .targetDateLabel(checklistType == ChecklistType.TODO ? targetDateLabel : null) + .build(); + } - return parseOpenAiResponse(response.body()); + private LocalDate parseTargetDate(String value) { + if (value == null || value.isBlank()) { + return null; + } - } catch (ExternalApiException e) { - throw e; - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new ExternalApiException( - ErrorCode.EXTERNAL_API_ERROR, "OpenAI API 통신 인터럽트: " + e.getMessage()); - } catch (IOException e) { - throw new ExternalApiException( - ErrorCode.EXTERNAL_API_ERROR, "OpenAI API 통신 오류: " + e.getMessage()); + try { + return LocalDate.parse(value.length() >= 10 ? value.substring(0, 10) : value); + } catch (DateTimeParseException e) { + log.warn("[AiAnalyzer] AI 서버 날짜 파싱 실패. value={}", value); + return null; } } - /** OpenAI 응답에서 텍스트 내용을 추출. */ - private String parseOpenAiResponse(String responseBody) { - try { - OpenAiResponse response = objectMapper.readValue(responseBody, OpenAiResponse.class); + private String inferTitle(String originalText) { + if (originalText == null || originalText.isBlank()) { + return DEFAULT_TITLE; + } - if (response.choices() == null || response.choices().isEmpty()) { - throw new ExternalApiException(ErrorCode.EXTERNAL_API_ERROR, "OpenAI 응답에 choices가 없습니다."); + for (String line : originalText.split("\\R")) { + String compacted = compact(line); + if (compacted.length() >= 4) { + return trimToMax(compacted, TITLE_MAX_LENGTH); } + } + return DEFAULT_TITLE; + } - String content = response.choices().get(0).message().content(); - if (content == null || content.isBlank()) { - throw new ExternalApiException(ErrorCode.EXTERNAL_API_ERROR, "OpenAI 응답 content가 비어있습니다."); - } + private String buildBaselineSummary(String sourceText) { + if (sourceText == null || sourceText.isBlank()) { + return ""; + } + return trimToMax(compact(sourceText), SUMMARY_MAX_LENGTH); + } - return content.trim(); + private String compact(String text) { + return text == null ? "" : text.replaceAll("\\s+", " ").trim(); + } - } catch (ExternalApiException e) { - throw e; - } catch (Exception e) { - throw new ExternalApiException( - ErrorCode.EXTERNAL_API_ERROR, "OpenAI 응답 파싱 실패: " + e.getMessage()); + private String trimNullable(String value, int maxLength) { + if (value == null || value.isBlank()) { + return null; } + return trimToMax(compact(value), maxLength); } - /** - * OpenAI가 반환한 JSON 문자열을 List로 파싱 GPT가 간혹 JSON 앞뒤에 ```json ... ``` 마크다운을 붙이는 경우가 있어서 파싱 전에 제거하는 - * 전처리를 수행 - */ - private List parseJsonList(String jsonText, TypeReference> typeRef) { - try { - // 마크다운 코드블록 제거 - String cleaned = jsonText.replaceAll("```json\\s*", "").replaceAll("```\\s*", "").trim(); - - return objectMapper.readValue(cleaned, typeRef); - } catch (Exception e) { - log.error("[AiAnalyzer] JSON 파싱 실패. error={}", e.getMessage()); - return List.of(); + private String trimToMax(String value, int maxLength) { + if (value.length() <= maxLength) { + return value; } + return value.substring(0, maxLength - 3).stripTrailing() + "..."; } - /** AI 분석 결과 (제목 + 요약). 체크리스트/해야할일은 DB에 직접 저장됨. */ public record AiAnalysisResult(String title, String summary) {} - - /** 체크리스트 OpenAI 응답 DTO */ - @JsonIgnoreProperties(ignoreUnknown = true) - record ChecklistItemDto(String content, String detail) {} - - /** 해야 할 일 OpenAI 응답 DTO */ - @JsonIgnoreProperties(ignoreUnknown = true) - record TodoItemDto(String content, String targetDate, String targetDateLabel) {} - - /** OpenAI Chat Completions API 응답 구조 */ - @JsonIgnoreProperties(ignoreUnknown = true) - record OpenAiResponse(List choices) {} - - @JsonIgnoreProperties(ignoreUnknown = true) - record Choice(Message message) {} - - @JsonIgnoreProperties(ignoreUnknown = true) - record Message(String role, String content) {} } diff --git a/src/main/java/com/gachi/be/domain/newsletter/pipeline/NewsletterPipelineService.java b/src/main/java/com/gachi/be/domain/newsletter/pipeline/NewsletterPipelineService.java index 55e6db3..4cd1dc6 100644 --- a/src/main/java/com/gachi/be/domain/newsletter/pipeline/NewsletterPipelineService.java +++ b/src/main/java/com/gachi/be/domain/newsletter/pipeline/NewsletterPipelineService.java @@ -5,18 +5,9 @@ import com.gachi.be.domain.newsletter.pipeline.NewsletterAiAnalyzer.AiAnalysisResult; import com.gachi.be.domain.newsletter.repository.NewsletterRepository; import com.gachi.be.file.config.S3Properties; -import java.awt.image.BufferedImage; -import java.io.ByteArrayOutputStream; -import java.util.ArrayList; -import java.util.Base64; import java.util.List; -import javax.imageio.ImageIO; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.apache.pdfbox.Loader; -import org.apache.pdfbox.pdmodel.PDDocument; -import org.apache.pdfbox.rendering.ImageType; -import org.apache.pdfbox.rendering.PDFRenderer; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Propagation; @@ -28,14 +19,6 @@ import software.amazon.awssdk.services.s3.model.GetObjectResponse; import software.amazon.awssdk.services.s3.model.PutObjectRequest; -/** - * 가정통신문 AI 분석 파이프라인 오케스트레이터. 업로드 완료 직후 @Async로 비동기 실행. 파이프라인 흐름: STEP1. S3에서 파일 다운로드 STEP2. 이미지 전처리 - * (EXIF 회전 보정, PDF는 클로바가 직접 처리) STEP2-PDF. PDF를 페이지별 이미지로 변환 → Base64 인코딩 (메모리 처리) STEP3. 클로바 OCR - * 호출 (PDF/이미지 모두 지원, 여러 페이지도 1회 호출로 처리) STEP4. OCR 결과 파싱 (Y좌표 기준 정렬 후 텍스트 합치기) STEP5. 텍스트 정제 (노이즈 - * 제거) STEP6. 파파고 번역 (KO이면 스킵) STEP7. OpenAI 분석 (제목/요약/체크리스트/해야할일) - 이미지 파일: S3 Presigned URL로 - * Vision 전달 - PDF 파일: STEP2-PDF에서 변환한 Base64 이미지 목록으로 Vision 전달 STEP8. DB 업데이트 (COMPLETED) 예외 발생 시 - * FAILED로 업데이트하고 종료. - */ @Slf4j @Service @RequiredArgsConstructor @@ -50,14 +33,7 @@ public class NewsletterPipelineService { private final PapagoTranslateClient papagoTranslateClient; private final NewsletterAiAnalyzer newsletterAiAnalyzer; private final NewsletterDateCandidateService newsletterDateCandidateService; - // PDF를 이미지로 변환할 때 사용하는 해상도 -> TODO: 현재는 150 DPI인데 더 부족하거나 과하다 싶으면 줄이거나 늘이기: 300DPI는 토큰 4배 증가 위험 - private static final float PDF_RENDER_DPI = 150f; - // PDF 변환시 처리할 최대 페이지 수 - private static final int PDF_MAX_PAGES = 5; - // PDF 이미지 총량 제한 -> 이미지 제한을 안하면 timeout 또는 413 에러 발생 가능 - private static final int PDF_MAX_TOTAL_JPEG_BYTES = 8 * 1024 * 1024; - /** 가정통신문 AI 분석 파이프라인을 비동기로 실행. */ @Async @Transactional public void runPipeline(Long newsletterId) { @@ -69,87 +45,58 @@ public void runPipeline(Long newsletterId) { return; } - // PROCESSING 상태로 전이 markProcessing(newsletterId); - log.debug("[Pipeline] PROCESSING 전이 완료. newsletterId={}", newsletterId); + log.debug("[Pipeline] PROCESSING 전환 완료. newsletterId={}", newsletterId); - // 이미지 전처리용 임시 S3 키-> 이미지 파일에서만 사용할 거고 추후 finally에서 삭제할거임 String tempFileKey = null; try { - // S3에서 파일 다운로드 log.debug("[Pipeline][STEP1] S3 다운로드 시작. fileKey={}", newsletter.getFileKey()); byte[] fileBytes = downloadFromS3(newsletter.getFileKey()); log.debug("[Pipeline][STEP1] 다운로드 완료. size={}bytes", fileBytes.length); - // 이미지 전처리 (이미지만 해당, PDF는 스킵-> clova가 처리함) - // 이미지(jpg/png)만 EXIF 회전 보정을 수행한다. boolean isPdf = newsletter.getFileKey().toLowerCase().endsWith(".pdf"); String ocrTargetKey; - if (!isPdf) { + if (isPdf) { + log.debug("[Pipeline][STEP2] PDF 파일. Clova OCR에 원본 파일을 전달합니다."); + ocrTargetKey = newsletter.getFileKey(); + } else { log.debug("[Pipeline][STEP2] 이미지 EXIF 회전 보정 시작."); byte[] processedBytes = imagePreprocessor.preprocessImage(fileBytes); - log.debug("[Pipeline][STEP2] 전처리 완료. processedSize={}bytes", processedBytes.length); - - // 전처리된 바이트를 임시 S3 키로 업로드 - // 원본 키 + "_processed" 접미사로 임시 키 생성 tempFileKey = newsletter.getFileKey() + "_processed"; uploadBytesToS3(processedBytes, tempFileKey, "image/png"); ocrTargetKey = tempFileKey; log.debug("[Pipeline][STEP2] 전처리 파일 임시 업로드 완료. tempFileKey={}", tempFileKey); - } else { - log.debug("[Pipeline][STEP2] PDF 파일. 전처리 스킵 (클로바가 직접 처리)."); - ocrTargetKey = newsletter.getFileKey(); - } - // PDF-> 이미지 변환: s3에 저장하지 않고 메모리로만 처리. - List pdfPageBase64Images = null; - if (isPdf) { - log.debug("[Pipeline][STEP2-PDF] PDF 페이지 이미지 변환 시작. maxPages={}", PDF_MAX_PAGES); - pdfPageBase64Images = convertPdfToBase64Images(fileBytes); - log.debug( - "[Pipeline][STEP2-PDF] PDF 이미지 변환 완료. convertedPages={}", pdfPageBase64Images.size()); } - // 클로바 OCR 호출 - // PDF: format="pdf" → 클로바가 전 페이지 처리 → 모든 pages fields 합쳐서 반환 - // 이미지: format="jpeg"/"png" → 단일 이미지 처리 - log.debug("[Pipeline][STEP3] 클로바 OCR 호출 시작. ocrTargetKey={}", ocrTargetKey); + + log.debug("[Pipeline][STEP3] Clova OCR 호출 시작. ocrTargetKey={}", ocrTargetKey); List> ocrPageFields = clovaOcrClient.callOcr(s3Properties.getBucket(), ocrTargetKey); log.debug("[Pipeline][STEP3] OCR 완료. totalFieldsCount={}", ocrPageFields.size()); - // OCR 결과 파싱 - log.debug("[Pipeline][STEP4] 텍스트 파싱 시작."); + log.debug("[Pipeline][STEP4] OCR 텍스트 파싱 시작."); String ocrText = ocrTextRefiner.parseFields(ocrPageFields); log.debug("[Pipeline][STEP4] 파싱 완료. length={}chars", ocrText.length()); - // 텍스트 정제 - log.debug("[Pipeline][STEP5] 텍스트 정제 시작."); + log.debug("[Pipeline][STEP5] 텍스트 정제 및 날짜 후보 추출 시작."); String originalText = ocrTextRefiner.refineText(ocrText); - // 날짜 위치는 후속 매칭의 기준점이므로 번역문이 아니라 OCR 정제 원문 기준으로 저장한다. newsletterDateCandidateService.extractAndReplace(newsletterId, originalText); log.debug("[Pipeline][STEP5] 정제 완료. length={}chars", originalText.length()); - // 파파고 번역 - log.debug("[Pipeline][STEP6] 번역 시작. language={}", newsletter.getLanguage()); + log.debug("[Pipeline][STEP6] Papago 번역 시작. language={}", newsletter.getLanguage()); String translatedText = papagoTranslateClient.translate(originalText, newsletter.getLanguage()); log.debug( "[Pipeline][STEP6] 번역 완료. translated={}", translatedText != null ? translatedText.length() + "chars" : "null(KO 스킵)"); - // OpenAI 분석 (제목/요약/체크리스트/해야할일) - log.debug("[Pipeline][STEP7] OpenAI 분석 시작."); + log.debug("[Pipeline][STEP7] AI 서버 분석 시작."); AiAnalysisResult aiResult = newsletterAiAnalyzer.analyze( - newsletterId, - originalText, - translatedText, - newsletter.getLanguage(), - pdfPageBase64Images); - log.debug("[Pipeline][STEP7] 완료. title={}", aiResult.title()); + newsletterId, originalText, translatedText, newsletter.getLanguage()); + log.debug("[Pipeline][STEP7] AI 서버 분석 완료. title={}", aiResult.title()); - // DB 업데이트 (COMPLETED) markCompleted( newsletterId, ocrText, @@ -159,7 +106,6 @@ public void runPipeline(Long newsletterId) { aiResult.summary()); log.info("[Pipeline] 파이프라인 완료. newsletterId={}", newsletterId); - } catch (Exception e) { log.error("[Pipeline] 파이프라인 실패. newsletterId={}, error={}", newsletterId, e.getMessage(), e); markFailed(newsletterId); @@ -169,7 +115,7 @@ public void runPipeline(Long newsletterId) { deleteFromS3(tempFileKey); log.debug("[Pipeline] 임시 파일 삭제 완료. tempFileKey={}", tempFileKey); } catch (Exception ex) { - // 임시 파일 삭제 실패는 파이프라인 결과에 영향 없음 — 로그만 남김 + // 임시 파일 정리는 후처리라서 실패해도 분석 결과는 되돌리지 않는다. log.warn( "[Pipeline] 임시 파일 삭제 실패. tempFileKey={}, error={}", tempFileKey, ex.getMessage()); } @@ -177,67 +123,6 @@ public void runPipeline(Long newsletterId) { } } - private List convertPdfToBase64Images(byte[] pdfBytes) { - List base64Images = new ArrayList<>(); - - // 누적 JPEG 바이트 추적 변수 - int totalJpegBytes = 0; - - // PDFBox로 PDF 파일을 열고 페이지별로 이미지 렌더링 - // try-with-resources: PDDocument는 Closeable이므로 자동으로 닫힘 - try (PDDocument document = Loader.loadPDF(pdfBytes)) { - PDFRenderer renderer = new PDFRenderer(document); - - int totalPages = document.getNumberOfPages(); - // 최대 처리 페이지 수를 PDF_MAX_PAGES로 제한 - // 초과 페이지는 OCR 텍스트(originalText)로 이미 커버되어 있음 - int pagesToProcess = Math.min(totalPages, PDF_MAX_PAGES); - - log.debug("[Pipeline] PDF 페이지 렌더링. totalPages={}, processing={}", totalPages, pagesToProcess); - - for (int pageIndex = 0; pageIndex < pagesToProcess; pageIndex++) { - // DPI 설정으로 페이지를 BufferedImage로 렌더링 - // ImageType.RGB: JPEG는 투명도(ARGB)를 지원하지 않으므로 RGB 사용 - BufferedImage pageImage = - renderer.renderImageWithDPI(pageIndex, PDF_RENDER_DPI, ImageType.RGB); - - // BufferedImage → JPEG 바이트 배열로 변환 - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - boolean written = ImageIO.write(pageImage, "JPEG", baos); - - if (!written) { - // JPEG 포맷 write 실패 시 해당 페이지만 스킵하고 계속 진행 - log.warn("[Pipeline] 페이지 JPEG 변환 실패. pageIndex={}", pageIndex); - continue; - } - - // 이 페이지를 추가했을 때 총량이 제한을 초과하면 중단 - byte[] jpegBytes = baos.toByteArray(); - if (totalJpegBytes + jpegBytes.length > PDF_MAX_TOTAL_JPEG_BYTES) { - log.warn( - "[Pipeline] PDF 이미지 총량 제한 도달. pageIndex={}, totalBytes={}. 이후 페이지는 OCR 텍스트로 커버.", - pageIndex, - totalJpegBytes); - break; - } - - // JPEG 바이트를 Base64 문자열로 인코딩 - String base64 = Base64.getEncoder().encodeToString(jpegBytes); - base64Images.add(base64); - totalJpegBytes += jpegBytes.length; - - log.debug("[Pipeline] 페이지 변환 완료. pageIndex={}, jpegBytes={}", pageIndex, baos.size()); - } - - } catch (Exception e) { - // PDF 변환 실패 시 빈 목록 반환 → OpenAI에 텍스트만 전달하는 fallback으로 동작 - // 파이프라인 전체가 중단되지 않도록 예외를 삼킴 - log.warn("[Pipeline] PDF 이미지 변환 실패. 텍스트만으로 OpenAI 분석 진행. error={}", e.getMessage()); - } - - return base64Images; - } - @Transactional(propagation = Propagation.REQUIRES_NEW) public void markProcessing(Long newsletterId) { newsletterRepository @@ -271,13 +156,12 @@ public void markFailed(Long newsletterId) { newsletterRepository .findById(newsletterId) .ifPresent( - (n -> { + n -> { n.fail(); newsletterRepository.save(n); - })); + }); } - /** S3에서 파일을 바이트 배열로 다운로드. */ private byte[] downloadFromS3(String fileKey) { GetObjectRequest request = GetObjectRequest.builder().bucket(s3Properties.getBucket()).key(fileKey).build(); @@ -286,7 +170,6 @@ private byte[] downloadFromS3(String fileKey) { return responseBytes.asByteArray(); } - /** 전처리된 이미지를 임시 키로 저장할 때 사용. */ private void uploadBytesToS3(byte[] bytes, String key, String contentType) { PutObjectRequest request = PutObjectRequest.builder() @@ -297,7 +180,6 @@ private void uploadBytesToS3(byte[] bytes, String key, String contentType) { s3Client.putObject(request, RequestBody.fromBytes(bytes)); } - /** 전처리 임시 파일 정리에 사용 */ private void deleteFromS3(String fileKey) { s3Client.deleteObject(b -> b.bucket(s3Properties.getBucket()).key(fileKey)); } diff --git a/src/main/java/com/gachi/be/global/config/external/AiServerProperties.java b/src/main/java/com/gachi/be/global/config/external/AiServerProperties.java new file mode 100644 index 0000000..846ce75 --- /dev/null +++ b/src/main/java/com/gachi/be/global/config/external/AiServerProperties.java @@ -0,0 +1,15 @@ +package com.gachi.be.global.config.external; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@Setter +@ConfigurationProperties(prefix = "app.ai-server") +public class AiServerProperties { + + private String baseUrl = "http://localhost:8000"; + private int connectTimeoutSeconds = 10; + private int readTimeoutSeconds = 120; +} diff --git a/src/main/java/com/gachi/be/global/config/external/ExternalApiConfig.java b/src/main/java/com/gachi/be/global/config/external/ExternalApiConfig.java index 883d32c..640808a 100644 --- a/src/main/java/com/gachi/be/global/config/external/ExternalApiConfig.java +++ b/src/main/java/com/gachi/be/global/config/external/ExternalApiConfig.java @@ -11,6 +11,6 @@ @EnableConfigurationProperties({ ClovaOcrProperties.class, PapagoProperties.class, - OpenAiProperties.class + AiServerProperties.class }) public class ExternalApiConfig {} diff --git a/src/main/java/com/gachi/be/global/config/external/OpenAiProperties.java b/src/main/java/com/gachi/be/global/config/external/OpenAiProperties.java deleted file mode 100644 index ac9339a..0000000 --- a/src/main/java/com/gachi/be/global/config/external/OpenAiProperties.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.gachi.be.global.config.external; - -import lombok.Getter; -import lombok.Setter; -import org.springframework.boot.context.properties.ConfigurationProperties; - -@Getter -@Setter -@ConfigurationProperties(prefix = "app.openai") -public class OpenAiProperties { - - private String apiKey; - private String model = "gpt-4o-mini"; - private String apiUrl; - private int maxTokens = 2000; -} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index fa52c95..9641aef 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -65,9 +65,7 @@ app: client-id: ${PAPAGO_CLIENT_ID:} client-secret: ${PAPAGO_CLIENT_SECRET:} api-url: https://papago.apigw.ntruss.com/nmt/v1 - openai: - api-key: ${OPENAI_API_KEY:} - model: ${OPENAI_MODEL:gpt-4.1-mini} - api-url: https://api.openai.com/v1/chat/completions - # 응답 최대 토큰. 체크리스트/해야할일 JSON이 길어질 수 있어 2000으로 설정 - max-tokens: ${OPENAI_MAX_TOKENS:2000} + ai-server: + base-url: ${AI_SERVER_BASE_URL:http://localhost:8000} + connect-timeout-seconds: ${AI_SERVER_CONNECT_TIMEOUT_SECONDS:10} + read-timeout-seconds: ${AI_SERVER_READ_TIMEOUT_SECONDS:120} From 8038afdbe99205cd47a2061816135a497f90fe86 Mon Sep 17 00:00:00 2001 From: minju Date: Tue, 19 May 2026 22:38:54 +0900 Subject: [PATCH 03/33] =?UTF-8?q?refactor:=20AI=20=EC=84=9C=EB=B2=84=20cli?= =?UTF-8?q?ent=20=EC=95=88=EC=A0=95=EC=84=B1=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/docker-compose.yml | 2 ++ .../pipeline/AiNewsletterClient.java | 21 +++++++++++-------- .../exception/ExternalApiException.java | 4 ++++ 3 files changed, 18 insertions(+), 9 deletions(-) diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index ed05e1f..03c78c1 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -106,6 +106,8 @@ services: condition: service_healthy redis: condition: service_healthy + ai: + condition: service_healthy healthcheck: test: ["CMD-SHELL", "curl -fsS http://localhost:8080/actuator/health || exit 1"] interval: 30s diff --git a/src/main/java/com/gachi/be/domain/newsletter/pipeline/AiNewsletterClient.java b/src/main/java/com/gachi/be/domain/newsletter/pipeline/AiNewsletterClient.java index 2b3301b..92bd2a5 100644 --- a/src/main/java/com/gachi/be/domain/newsletter/pipeline/AiNewsletterClient.java +++ b/src/main/java/com/gachi/be/domain/newsletter/pipeline/AiNewsletterClient.java @@ -16,13 +16,11 @@ import java.time.ZoneId; import java.util.ArrayList; import java.util.List; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; @Slf4j @Component -@RequiredArgsConstructor public class AiNewsletterClient { private static final ZoneId DEFAULT_ZONE = ZoneId.of("Asia/Seoul"); @@ -30,6 +28,16 @@ public class AiNewsletterClient { private final AiServerProperties aiServerProperties; private final ObjectMapper objectMapper; + private final HttpClient httpClient; + + public AiNewsletterClient(AiServerProperties aiServerProperties, ObjectMapper objectMapper) { + this.aiServerProperties = aiServerProperties; + this.objectMapper = objectMapper; + this.httpClient = + HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(aiServerProperties.getConnectTimeoutSeconds())) + .build(); + } public List extractItems( String originalText, @@ -55,11 +63,6 @@ public List extractItems( .POST(HttpRequest.BodyPublishers.ofString(requestBody)) .build(); - HttpClient httpClient = - HttpClient.newBuilder() - .connectTimeout(Duration.ofSeconds(aiServerProperties.getConnectTimeoutSeconds())) - .build(); - HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); @@ -80,10 +83,10 @@ public List extractItems( } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new ExternalApiException( - ErrorCode.EXTERNAL_API_ERROR, "AI 서버 통신 인터럽트: " + e.getMessage()); + ErrorCode.EXTERNAL_API_ERROR, "AI 서버 통신 인터럽트: " + e.getMessage(), e); } catch (IOException e) { throw new ExternalApiException( - ErrorCode.EXTERNAL_API_ERROR, "AI 서버 통신 오류: " + e.getMessage()); + ErrorCode.EXTERNAL_API_ERROR, "AI 서버 통신 오류: " + e.getMessage(), e); } } diff --git a/src/main/java/com/gachi/be/global/exception/ExternalApiException.java b/src/main/java/com/gachi/be/global/exception/ExternalApiException.java index 9325c8b..7fba487 100644 --- a/src/main/java/com/gachi/be/global/exception/ExternalApiException.java +++ b/src/main/java/com/gachi/be/global/exception/ExternalApiException.java @@ -10,4 +10,8 @@ public ExternalApiException(ErrorCode errorCode) { public ExternalApiException(ErrorCode errorCode, String detailMessage) { super(errorCode, detailMessage); } + + public ExternalApiException(ErrorCode errorCode, String detailMessage, Throwable cause) { + super(errorCode, detailMessage, cause); + } } From b444225553b60885961cd32411b6b347585df0fc Mon Sep 17 00:00:00 2001 From: minju Date: Wed, 20 May 2026 12:42:41 +0900 Subject: [PATCH 04/33] =?UTF-8?q?refactor:=20AI=20=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=EC=9E=A5=EC=95=A0=20=EC=8B=9C=20=EB=B6=84=EC=84=9D=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=20=EC=A0=95=EC=B1=85=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/newsletter-ai-failure-policy.md | 43 ++++++++++++ .../api/controller/NewsletterController.java | 18 +++++ .../response/NewsletterStatusResponse.java | 27 ++++++-- .../domain/newsletter/entity/Newsletter.java | 52 +++++++++++++- .../pipeline/NewsletterPipelineService.java | 69 ++++++++++++++++--- .../newsletter/service/NewsletterService.java | 3 + .../service/impl/NewsletterServiceImpl.java | 44 +++++++++++- .../com/gachi/be/global/code/ErrorCode.java | 6 ++ .../com/gachi/be/global/code/SuccessCode.java | 1 + .../V11__newsletter_failure_reason.sql | 5 ++ .../NewsletterStatusResponseTest.java | 29 ++++++++ .../newsletter/entity/NewsletterTest.java | 57 +++++++++++++++ 12 files changed, 332 insertions(+), 22 deletions(-) create mode 100644 docs/newsletter-ai-failure-policy.md create mode 100644 src/main/resources/db/migration/V11__newsletter_failure_reason.sql create mode 100644 src/test/java/com/gachi/be/domain/newsletter/dto/response/NewsletterStatusResponseTest.java create mode 100644 src/test/java/com/gachi/be/domain/newsletter/entity/NewsletterTest.java diff --git a/docs/newsletter-ai-failure-policy.md b/docs/newsletter-ai-failure-policy.md new file mode 100644 index 0000000..e1f2739 --- /dev/null +++ b/docs/newsletter-ai-failure-policy.md @@ -0,0 +1,43 @@ +# AI 서버 장애 시 가정통신문 처리 정책 + +이 문서는 API 명세서가 아니라 BE가 장애 상황에서 어떤 상태와 데이터를 남길지 정한 운영 정책이다. +상세 API 명세는 노션을 기준으로 관리한다. + +## 결정 사항 + +- AI 서버 호출 실패 시 newsletter 상태는 `FAILED`로 둔다. +- 별도 부분 성공 상태는 이번 범위에서 추가하지 않는다. +- OCR, 정제 원문, 번역 결과, 날짜 후보는 가능한 범위까지 저장한다. +- AI 분석 결과에서 파생되는 제목, 요약, checklist, calendar event는 생성하지 않는다. +- 실패 원인 추적을 위해 `failureStage`, `failureReason`을 저장한다. +- 상태 조회 응답은 `FAILED`일 때 `canRetry=true`와 실패 단계를 반환한다. +- 사용자는 `POST /api/v1/newsletters/{newsletterId}/analysis/retry`로 같은 문서를 다시 분석할 수 있다. + +## 실패 시 저장 범위 + +AI 서버 단계에서 실패하면 다음 데이터는 남긴다. + +- `status = FAILED` +- `ocrText` +- `originalText` +- `translatedText` +- `dateCandidates` +- `failureStage = AI_SERVER` +- `failureReason` + +다음 데이터는 비워 둔다. + +- `title` +- `summary` +- checklist +- calendar event + +## 재시도 정책 + +재시도는 `FAILED` 상태에서만 허용한다. + +재시도 요청이 들어오면 기존 checklist와 calendar event 파생 데이터를 삭제하고, newsletter 상태를 `PENDING`으로 되돌린 뒤 파이프라인을 다시 실행한다. + +자동 재시도 큐는 이번 범위에서 만들지 않는다. AI 서버 장애와 문서 입력 문제를 자동으로 구분하기 어렵고, 외부 API 비용과 장애 확산 위험이 있기 때문이다. + +추후 필요하면 `failureStage=AI_SERVER`인 건만 백오프 큐에 넣는 방식으로 확장한다. diff --git a/src/main/java/com/gachi/be/domain/newsletter/api/controller/NewsletterController.java b/src/main/java/com/gachi/be/domain/newsletter/api/controller/NewsletterController.java index 7fcf38b..893d179 100644 --- a/src/main/java/com/gachi/be/domain/newsletter/api/controller/NewsletterController.java +++ b/src/main/java/com/gachi/be/domain/newsletter/api/controller/NewsletterController.java @@ -92,6 +92,24 @@ public ApiResponse getStatus( return ApiResponse.success(SuccessCode.NEWSLETTER_STATUS_SUCCESS, response); } + /** 실패한 가정통신문 분석 재시도 API */ + @Operation( + summary = "가정통신문 분석 재시도", + description = + """ + FAILED 상태의 가정통신문 분석을 다시 시작합니다. + AI 서버 장애로 실패한 경우 기존 OCR/번역 결과는 보존되어 있고, 재시도 시 파이프라인이 다시 실행됩니다. + """) + @PostMapping("/{newsletterId}/analysis/retry") + @ResponseStatus(HttpStatus.ACCEPTED) + public ApiResponse retryAnalysis( + @AuthenticationPrincipal Long userId, + @Parameter(description = "가정통신문 ID", required = true) @PathVariable Long newsletterId) { + + NewsletterUploadResponse response = newsletterService.retryAnalysis(userId, newsletterId); + return ApiResponse.success(SuccessCode.NEWSLETTER_RETRY_ACCEPTED, response); + } + /** 번역 결과 조회 API. */ @Operation( summary = "번역 결과 조회", diff --git a/src/main/java/com/gachi/be/domain/newsletter/dto/response/NewsletterStatusResponse.java b/src/main/java/com/gachi/be/domain/newsletter/dto/response/NewsletterStatusResponse.java index a117099..758bf1b 100644 --- a/src/main/java/com/gachi/be/domain/newsletter/dto/response/NewsletterStatusResponse.java +++ b/src/main/java/com/gachi/be/domain/newsletter/dto/response/NewsletterStatusResponse.java @@ -1,6 +1,7 @@ package com.gachi.be.domain.newsletter.dto.response; import com.fasterxml.jackson.annotation.JsonInclude; +import com.gachi.be.domain.newsletter.entity.Newsletter; import com.gachi.be.domain.newsletter.entity.enums.NewsletterStatus; /** @@ -10,19 +11,31 @@ */ @JsonInclude(JsonInclude.Include.NON_NULL) // null인 필드는 JSON 응답에서 제외 public record NewsletterStatusResponse( - NewsletterStatus status, int progressPercent, String progressMessage, String errorMessage) { + NewsletterStatus status, + int progressPercent, + String progressMessage, + String errorMessage, + String failureStage, + boolean canRetry) { /** * 분석 상태에 따라 적절한 진행률과 에러메시지를 자동 계산하는 팩토리 메서드. TODO: 현재는 고정값으로 처리, 추후 AI 서버에서 단계별 진행률을 받아 세분화 예정 */ - public static NewsletterStatusResponse of(NewsletterStatus status) { - String safeErrorMessage = "분석 중 오류가 발생했어요. 잠시 후 다시 시도해 주세요."; + public static NewsletterStatusResponse of(Newsletter newsletter) { + NewsletterStatus status = newsletter.getStatus(); return switch (status) { - case PENDING -> new NewsletterStatusResponse(status, 0, "문서를 준비하고 있어요", null); - case PROCESSING -> new NewsletterStatusResponse(status, 60, "텍스트를 인식하고 번역하고 있어요", null); - case COMPLETED -> new NewsletterStatusResponse(status, 100, "분석이 완료되었어요", null); + case PENDING -> new NewsletterStatusResponse(status, 0, "문서를 준비하고 있어요", null, null, false); + case PROCESSING -> + new NewsletterStatusResponse(status, 60, "텍스트를 인식하고 번역하고 있어요", null, null, false); + case COMPLETED -> new NewsletterStatusResponse(status, 100, "분석이 완료되었어요", null, null, false); case FAILED -> - new NewsletterStatusResponse(status, 0, null, "분석 중 오류가 발생했어요. 잠시 후 다시 시도해 주세요."); + new NewsletterStatusResponse( + status, + 0, + null, + "분석 중 오류가 발생했어요. 다시 분석을 시도할 수 있어요.", + newsletter.getFailureStage(), + true); }; } } diff --git a/src/main/java/com/gachi/be/domain/newsletter/entity/Newsletter.java b/src/main/java/com/gachi/be/domain/newsletter/entity/Newsletter.java index b19132e..fffda45 100644 --- a/src/main/java/com/gachi/be/domain/newsletter/entity/Newsletter.java +++ b/src/main/java/com/gachi/be/domain/newsletter/entity/Newsletter.java @@ -72,6 +72,12 @@ public class Newsletter { @Column(name = "summary", columnDefinition = "TEXT") private String summary; + @Column(name = "failure_stage", length = 50) + private String failureStage; + + @Column(name = "failure_reason", columnDefinition = "TEXT") + private String failureReason; + /** 날짜 후보는 최종 일정이 아니라 후속 AI 매칭을 위한 중간 재료이므로 JSON으로 보관합니다. */ @JdbcTypeCode(SqlTypes.JSON) @Column(name = "date_candidates", columnDefinition = "jsonb") @@ -125,6 +131,8 @@ protected void onUpdate() { /** AI 분석 시작 시 PROCESSING 상태로 전환합니다. */ public void startProcessing() { this.status = NewsletterStatus.PROCESSING; + this.failureStage = null; + this.failureReason = null; } /** AI 분석 결과를 저장하고 COMPLETED 상태로 전환합니다. */ @@ -136,11 +144,37 @@ public void complete( this.title = title; this.summary = summary; this.status = NewsletterStatus.COMPLETED; + this.failureStage = null; + this.failureReason = null; } - /** AI 분석 실패 시 FAILED 상태로 전환합니다. */ - public void fail() { + /** 분석 실패 시 원인 추적을 위해 실패 단계와 사유를 함께 저장합니다. */ + public void fail(String failureStage, String failureReason) { this.status = NewsletterStatus.FAILED; + this.failureStage = normalizeFailureStage(failureStage); + this.failureReason = normalizeFailureReason(failureReason); + } + + /** OCR/번역 이후 AI 서버 장애가 나도 사용자가 원문 결과를 확인할 수 있도록 중간 산출물을 보존합니다. */ + public void failWithSnapshot( + String ocrText, + String originalText, + String translatedText, + String failureStage, + String failureReason) { + this.ocrText = ocrText; + this.originalText = originalText; + this.translatedText = translatedText; + fail(failureStage, failureReason); + } + + /** 실패한 분석을 사용자가 다시 시도할 때 이전 실패 사유를 비우고 대기 상태로 되돌립니다. */ + public void prepareRetry() { + this.status = NewsletterStatus.PENDING; + this.failureStage = null; + this.failureReason = null; + this.title = null; + this.summary = null; } /** 날짜 후보 목록을 교체합니다. 후보가 없으면 빈 목록으로 저장합니다. */ @@ -153,4 +187,18 @@ public void replaceDateCandidates(List dateCandidates) public void updateChildColor(String newColor) { this.childColor = newColor; } + + private String normalizeFailureStage(String failureStage) { + if (failureStage == null || failureStage.isBlank()) { + return "UNKNOWN"; + } + return failureStage.length() <= 50 ? failureStage : failureStage.substring(0, 50); + } + + private String normalizeFailureReason(String failureReason) { + if (failureReason == null || failureReason.isBlank()) { + return null; + } + return failureReason.length() <= 1000 ? failureReason : failureReason.substring(0, 1000); + } } diff --git a/src/main/java/com/gachi/be/domain/newsletter/pipeline/NewsletterPipelineService.java b/src/main/java/com/gachi/be/domain/newsletter/pipeline/NewsletterPipelineService.java index 4cd1dc6..21e7c2a 100644 --- a/src/main/java/com/gachi/be/domain/newsletter/pipeline/NewsletterPipelineService.java +++ b/src/main/java/com/gachi/be/domain/newsletter/pipeline/NewsletterPipelineService.java @@ -5,6 +5,7 @@ import com.gachi.be.domain.newsletter.pipeline.NewsletterAiAnalyzer.AiAnalysisResult; import com.gachi.be.domain.newsletter.repository.NewsletterRepository; import com.gachi.be.file.config.S3Properties; +import com.gachi.be.global.exception.ExternalApiException; import java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -49,8 +50,13 @@ public void runPipeline(Long newsletterId) { log.debug("[Pipeline] PROCESSING 전환 완료. newsletterId={}", newsletterId); String tempFileKey = null; + String failureStage = "PIPELINE_START"; + String ocrText = null; + String originalText = null; + String translatedText = null; try { + failureStage = "S3_DOWNLOAD"; log.debug("[Pipeline][STEP1] S3 다운로드 시작. fileKey={}", newsletter.getFileKey()); byte[] fileBytes = downloadFromS3(newsletter.getFileKey()); log.debug("[Pipeline][STEP1] 다운로드 완료. size={}bytes", fileBytes.length); @@ -58,6 +64,7 @@ public void runPipeline(Long newsletterId) { boolean isPdf = newsletter.getFileKey().toLowerCase().endsWith(".pdf"); String ocrTargetKey; + failureStage = "IMAGE_PREPROCESS"; if (isPdf) { log.debug("[Pipeline][STEP2] PDF 파일. Clova OCR에 원본 파일을 전달합니다."); ocrTargetKey = newsletter.getFileKey(); @@ -70,31 +77,49 @@ public void runPipeline(Long newsletterId) { log.debug("[Pipeline][STEP2] 전처리 파일 임시 업로드 완료. tempFileKey={}", tempFileKey); } + failureStage = "CLOVA_OCR"; log.debug("[Pipeline][STEP3] Clova OCR 호출 시작. ocrTargetKey={}", ocrTargetKey); List> ocrPageFields = clovaOcrClient.callOcr(s3Properties.getBucket(), ocrTargetKey); log.debug("[Pipeline][STEP3] OCR 완료. totalFieldsCount={}", ocrPageFields.size()); + failureStage = "OCR_TEXT_PARSE"; log.debug("[Pipeline][STEP4] OCR 텍스트 파싱 시작."); - String ocrText = ocrTextRefiner.parseFields(ocrPageFields); + ocrText = ocrTextRefiner.parseFields(ocrPageFields); log.debug("[Pipeline][STEP4] 파싱 완료. length={}chars", ocrText.length()); + failureStage = "TEXT_REFINE"; log.debug("[Pipeline][STEP5] 텍스트 정제 및 날짜 후보 추출 시작."); - String originalText = ocrTextRefiner.refineText(ocrText); + originalText = ocrTextRefiner.refineText(ocrText); newsletterDateCandidateService.extractAndReplace(newsletterId, originalText); log.debug("[Pipeline][STEP5] 정제 완료. length={}chars", originalText.length()); + failureStage = "PAPAGO_TRANSLATE"; log.debug("[Pipeline][STEP6] Papago 번역 시작. language={}", newsletter.getLanguage()); - String translatedText = - papagoTranslateClient.translate(originalText, newsletter.getLanguage()); + translatedText = papagoTranslateClient.translate(originalText, newsletter.getLanguage()); log.debug( "[Pipeline][STEP6] 번역 완료. translated={}", translatedText != null ? translatedText.length() + "chars" : "null(KO 스킵)"); + failureStage = "AI_SERVER"; log.debug("[Pipeline][STEP7] AI 서버 분석 시작."); - AiAnalysisResult aiResult = - newsletterAiAnalyzer.analyze( - newsletterId, originalText, translatedText, newsletter.getLanguage()); + AiAnalysisResult aiResult; + try { + aiResult = + newsletterAiAnalyzer.analyze( + newsletterId, originalText, translatedText, newsletter.getLanguage()); + } catch (ExternalApiException e) { + log.error( + "[Pipeline][STEP7] AI 서버 분석 실패. newsletterId={}, stage={}, exceptionType={}, error={}", + newsletterId, + failureStage, + e.getClass().getSimpleName(), + e.getMessage(), + e); + markFailedWithSnapshot( + newsletterId, ocrText, originalText, translatedText, failureStage, failureReason(e)); + return; + } log.debug("[Pipeline][STEP7] AI 서버 분석 완료. title={}", aiResult.title()); markCompleted( @@ -107,8 +132,15 @@ public void runPipeline(Long newsletterId) { log.info("[Pipeline] 파이프라인 완료. newsletterId={}", newsletterId); } catch (Exception e) { - log.error("[Pipeline] 파이프라인 실패. newsletterId={}, error={}", newsletterId, e.getMessage(), e); - markFailed(newsletterId); + log.error( + "[Pipeline] 파이프라인 실패. newsletterId={}, stage={}, exceptionType={}, error={}", + newsletterId, + failureStage, + e.getClass().getSimpleName(), + e.getMessage(), + e); + markFailedWithSnapshot( + newsletterId, ocrText, originalText, translatedText, failureStage, failureReason(e)); } finally { if (tempFileKey != null) { try { @@ -152,16 +184,31 @@ public void markCompleted( } @Transactional(propagation = Propagation.REQUIRES_NEW) - public void markFailed(Long newsletterId) { + public void markFailedWithSnapshot( + Long newsletterId, + String ocrText, + String originalText, + String translatedText, + String failureStage, + String failureReason) { newsletterRepository .findById(newsletterId) .ifPresent( n -> { - n.fail(); + n.failWithSnapshot( + ocrText, originalText, translatedText, failureStage, failureReason); newsletterRepository.save(n); }); } + private String failureReason(Exception e) { + String message = e.getMessage(); + if (message == null || message.isBlank()) { + return e.getClass().getSimpleName(); + } + return e.getClass().getSimpleName() + ": " + message; + } + private byte[] downloadFromS3(String fileKey) { GetObjectRequest request = GetObjectRequest.builder().bucket(s3Properties.getBucket()).key(fileKey).build(); diff --git a/src/main/java/com/gachi/be/domain/newsletter/service/NewsletterService.java b/src/main/java/com/gachi/be/domain/newsletter/service/NewsletterService.java index 70e3a1c..7da5cac 100644 --- a/src/main/java/com/gachi/be/domain/newsletter/service/NewsletterService.java +++ b/src/main/java/com/gachi/be/domain/newsletter/service/NewsletterService.java @@ -26,6 +26,9 @@ NewsletterUploadResponse upload( */ NewsletterStatusResponse getStatus(Long userId, Long newsletterId); + /** 실패한 가정통신문 분석을 다시 대기 상태로 되돌리고 파이프라인을 재실행합니다. */ + NewsletterUploadResponse retryAnalysis(Long userId, Long newsletterId); + /** 번역 결과 조회 */ NewsletterTranslationResponse getTranslation(Long userId, Long newsletterId); diff --git a/src/main/java/com/gachi/be/domain/newsletter/service/impl/NewsletterServiceImpl.java b/src/main/java/com/gachi/be/domain/newsletter/service/impl/NewsletterServiceImpl.java index bc223a0..c361ec8 100644 --- a/src/main/java/com/gachi/be/domain/newsletter/service/impl/NewsletterServiceImpl.java +++ b/src/main/java/com/gachi/be/domain/newsletter/service/impl/NewsletterServiceImpl.java @@ -194,7 +194,35 @@ public void afterCompletion(int status) { public NewsletterStatusResponse getStatus(Long userId, Long newsletterId) { Newsletter newsletter = findNewsletterById(newsletterId); validateOwnership(newsletter, userId); - return NewsletterStatusResponse.of(newsletter.getStatus()); + return NewsletterStatusResponse.of(newsletter); + } + + /** 실패한 분석을 사용자가 다시 시도할 수 있도록 파생 데이터를 비우고 파이프라인을 재실행합니다. */ + @Override + @Transactional + public NewsletterUploadResponse retryAnalysis(Long userId, Long newsletterId) { + Newsletter newsletter = findNewsletterById(newsletterId); + validateOwnership(newsletter, userId); + + if (newsletter.getStatus() != NewsletterStatus.FAILED) { + throw new BusinessException(ErrorCode.NEWSLETTER_RETRY_NOT_ALLOWED); + } + + checklistRepository.deleteByNewsletterId(newsletterId); + calendarEventRepository.deleteByNewsletterIdAndUserId(newsletterId, userId); + newsletter.prepareRetry(); + Newsletter saved = newsletterRepository.save(newsletter); + + TransactionSynchronizationManager.registerSynchronization( + new TransactionSynchronization() { + @Override + public void afterCommit() { + log.info("[Newsletter] 분석 재시도 파이프라인 트리거. newsletterId={}", newsletterId); + newsletterPipelineService.runPipeline(newsletterId); + } + }); + + return new NewsletterUploadResponse(saved.getId(), saved.getStatus()); } /** 번역 결과 조회 */ @@ -203,7 +231,7 @@ public NewsletterStatusResponse getStatus(Long userId, Long newsletterId) { public NewsletterTranslationResponse getTranslation(Long userId, Long newsletterId) { Newsletter newsletter = findNewsletterById(newsletterId); validateOwnership(newsletter, userId); - validateCompleted(newsletter); + validateTextReadable(newsletter); String fileUrl = null; try { @@ -513,4 +541,16 @@ private void validateCompleted(Newsletter newsletter) { throw new BusinessException(ErrorCode.NEWSLETTER_NOT_COMPLETED); } } + + private void validateTextReadable(Newsletter newsletter) { + if (newsletter.getStatus() == NewsletterStatus.COMPLETED) { + return; + } + if (newsletter.getStatus() == NewsletterStatus.FAILED + && newsletter.getOriginalText() != null + && !newsletter.getOriginalText().isBlank()) { + return; + } + throw new BusinessException(ErrorCode.NEWSLETTER_NOT_COMPLETED); + } } diff --git a/src/main/java/com/gachi/be/global/code/ErrorCode.java b/src/main/java/com/gachi/be/global/code/ErrorCode.java index 7c384bb..c063bbf 100644 --- a/src/main/java/com/gachi/be/global/code/ErrorCode.java +++ b/src/main/java/com/gachi/be/global/code/ErrorCode.java @@ -195,6 +195,12 @@ public enum ErrorCode { "아직 분석이 완료되지 않은 가정통신문입니다.", "COMPLETED 상태가 아닌 newsletter 조회 시도", ErrorLogLevel.WARN), + NEWSLETTER_RETRY_NOT_ALLOWED( + HttpStatus.BAD_REQUEST, + "NL4005", + "실패한 가정통신문만 다시 분석할 수 있습니다.", + "FAILED 상태가 아닌 newsletter 분석 재시도 요청", + ErrorLogLevel.WARN), CHECKLIST_NOT_FOUND( HttpStatus.NOT_FOUND, diff --git a/src/main/java/com/gachi/be/global/code/SuccessCode.java b/src/main/java/com/gachi/be/global/code/SuccessCode.java index bf72634..9152e8f 100644 --- a/src/main/java/com/gachi/be/global/code/SuccessCode.java +++ b/src/main/java/com/gachi/be/global/code/SuccessCode.java @@ -20,6 +20,7 @@ public enum SuccessCode { CHILD_CREATE_SUCCESS(HttpStatus.CREATED, "CHILD2011", "자녀 정보 등록에 성공하였습니다."), CHILD_GET_LIST_SUCCESS(HttpStatus.OK, "CHILD2001", "내 자녀 목록 조회에 성공하였습니다."), NEWSLETTER_UPLOAD_SUCCESS(HttpStatus.CREATED, "NL2011", "업로드가 시작되었습니다."), + NEWSLETTER_RETRY_ACCEPTED(HttpStatus.ACCEPTED, "NL2021", "가정통신문 분석 재시도가 시작되었습니다."), NEWSLETTER_STATUS_SUCCESS(HttpStatus.OK, "NL2001", "요청에 성공하였습니다."), NEWSLETTER_TRANSLATION_SUCCESS(HttpStatus.OK, "NL2002", "번역 결과 조회에 성공하였습니다."), NEWSLETTER_SUMMARY_SUCCESS(HttpStatus.OK, "NL2003", "요약 결과 조회에 성공하였습니다."), diff --git a/src/main/resources/db/migration/V11__newsletter_failure_reason.sql b/src/main/resources/db/migration/V11__newsletter_failure_reason.sql new file mode 100644 index 0000000..d53fbcc --- /dev/null +++ b/src/main/resources/db/migration/V11__newsletter_failure_reason.sql @@ -0,0 +1,5 @@ +ALTER TABLE newsletter + ADD COLUMN IF NOT EXISTS failure_stage VARCHAR(50) NULL; + +ALTER TABLE newsletter + ADD COLUMN IF NOT EXISTS failure_reason TEXT NULL; diff --git a/src/test/java/com/gachi/be/domain/newsletter/dto/response/NewsletterStatusResponseTest.java b/src/test/java/com/gachi/be/domain/newsletter/dto/response/NewsletterStatusResponseTest.java new file mode 100644 index 0000000..1986c45 --- /dev/null +++ b/src/test/java/com/gachi/be/domain/newsletter/dto/response/NewsletterStatusResponseTest.java @@ -0,0 +1,29 @@ +package com.gachi.be.domain.newsletter.dto.response; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.gachi.be.domain.newsletter.entity.Newsletter; +import com.gachi.be.domain.newsletter.entity.enums.NewsletterStatus; +import org.junit.jupiter.api.Test; + +class NewsletterStatusResponseTest { + + @Test + void failedStatusIsRetryableAndContainsFailureStage() { + Newsletter newsletter = + Newsletter.builder() + .userId(1L) + .fileKey("newsletters/sample.png") + .fileHash("hash") + .status(NewsletterStatus.PROCESSING) + .language("KO") + .build(); + newsletter.fail("AI_SERVER", "timeout"); + + NewsletterStatusResponse response = NewsletterStatusResponse.of(newsletter); + + assertThat(response.status()).isEqualTo(NewsletterStatus.FAILED); + assertThat(response.canRetry()).isTrue(); + assertThat(response.failureStage()).isEqualTo("AI_SERVER"); + } +} diff --git a/src/test/java/com/gachi/be/domain/newsletter/entity/NewsletterTest.java b/src/test/java/com/gachi/be/domain/newsletter/entity/NewsletterTest.java new file mode 100644 index 0000000..82faaa0 --- /dev/null +++ b/src/test/java/com/gachi/be/domain/newsletter/entity/NewsletterTest.java @@ -0,0 +1,57 @@ +package com.gachi.be.domain.newsletter.entity; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.gachi.be.domain.newsletter.entity.enums.NewsletterStatus; +import org.junit.jupiter.api.Test; + +class NewsletterTest { + + @Test + void failWithSnapshotPreservesTextExtractionResult() { + Newsletter newsletter = + Newsletter.builder() + .userId(1L) + .fileKey("newsletters/sample.png") + .fileHash("hash") + .status(NewsletterStatus.PROCESSING) + .language("KO") + .build(); + + newsletter.failWithSnapshot( + "ocr text", + "original text", + "translated text", + "AI_SERVER", + "ExternalApiException: timeout"); + + assertThat(newsletter.getStatus()).isEqualTo(NewsletterStatus.FAILED); + assertThat(newsletter.getOcrText()).isEqualTo("ocr text"); + assertThat(newsletter.getOriginalText()).isEqualTo("original text"); + assertThat(newsletter.getTranslatedText()).isEqualTo("translated text"); + assertThat(newsletter.getFailureStage()).isEqualTo("AI_SERVER"); + assertThat(newsletter.getFailureReason()).contains("timeout"); + } + + @Test + void prepareRetryClearsFailureFieldsAndDerivedSummary() { + Newsletter newsletter = + Newsletter.builder() + .userId(1L) + .fileKey("newsletters/sample.png") + .fileHash("hash") + .status(NewsletterStatus.PROCESSING) + .language("KO") + .build(); + newsletter.complete("ocr text", "original text", null, "title", "summary"); + newsletter.fail("AI_SERVER", "timeout"); + + newsletter.prepareRetry(); + + assertThat(newsletter.getStatus()).isEqualTo(NewsletterStatus.PENDING); + assertThat(newsletter.getFailureStage()).isNull(); + assertThat(newsletter.getFailureReason()).isNull(); + assertThat(newsletter.getTitle()).isNull(); + assertThat(newsletter.getSummary()).isNull(); + } +} From f7f37b23db4e68e77801615bf3c8acb5e0fe476f Mon Sep 17 00:00:00 2001 From: minju Date: Wed, 20 May 2026 16:50:16 +0900 Subject: [PATCH 05/33] =?UTF-8?q?fix:=20=EA=B0=80=EC=A0=95=ED=86=B5?= =?UTF-8?q?=EC=8B=A0=EB=AC=B8=20=EB=B6=84=EC=84=9D=20=EC=9E=AC=EC=8B=9C?= =?UTF-8?q?=EB=8F=84=20=EC=A4=91=EB=B3=B5=20=EC=8B=A4=ED=96=89=20=EB=B0=A9?= =?UTF-8?q?=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/error-code.md | 2 +- .../repository/NewsletterRepository.java | 21 +++++++++++++++++++ .../service/impl/NewsletterServiceImpl.java | 6 +++--- 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/docs/error-code.md b/docs/error-code.md index 1471203..890315f 100644 --- a/docs/error-code.md +++ b/docs/error-code.md @@ -2,7 +2,7 @@ 상세 에러 코드는 아래 Google Sheets를 단일 원본으로 관리합니다. -- Google Sheets: [Error Code Single Source](https://docs.google.com/spreadsheets/d/1sUG_JJsVl6a8nR6k8jO_b7UrIEZzuvQdPB6S2S6fqgo/edit?gid=2097363290#gid=2097363290) +- Google Sheets: [Error Code Single Source](https://docs.google.com/spreadsheets/d/1sUG_JJsVl6a8nR6k8jO_b7UrIEZzuvQdPB6S2S6fqgo/edit?gid=1519705696#gid=1519705696) ## 운영 원칙 diff --git a/src/main/java/com/gachi/be/domain/newsletter/repository/NewsletterRepository.java b/src/main/java/com/gachi/be/domain/newsletter/repository/NewsletterRepository.java index c99c0cc..d751a69 100644 --- a/src/main/java/com/gachi/be/domain/newsletter/repository/NewsletterRepository.java +++ b/src/main/java/com/gachi/be/domain/newsletter/repository/NewsletterRepository.java @@ -34,6 +34,27 @@ void updateChildColorByUserIdAndChildName( @Param("childName") String childName, @Param("newColor") String newColor); + /** + * FAILED 상태인 가정통신문만 PENDING으로 원자적으로 전환합니다. + * + *

동시 재시도 요청이 들어와도 첫 요청만 update count 1을 받고, 나머지는 0을 받아 중복 파이프라인 실행을 막습니다. + */ + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query( + """ + UPDATE Newsletter n + SET n.status = com.gachi.be.domain.newsletter.entity.enums.NewsletterStatus.PENDING, + n.failureStage = null, + n.failureReason = null, + n.title = null, + n.summary = null + WHERE n.id = :newsletterId + AND n.userId = :userId + AND n.status = com.gachi.be.domain.newsletter.entity.enums.NewsletterStatus.FAILED + """) + int markRetryPendingIfFailed( + @Param("newsletterId") Long newsletterId, @Param("userId") Long userId); + /** 가정통신문 목록 조회 (자녀 필터 + 제목 검색 + 페이지네이션). */ @Query( value = diff --git a/src/main/java/com/gachi/be/domain/newsletter/service/impl/NewsletterServiceImpl.java b/src/main/java/com/gachi/be/domain/newsletter/service/impl/NewsletterServiceImpl.java index c361ec8..b773376 100644 --- a/src/main/java/com/gachi/be/domain/newsletter/service/impl/NewsletterServiceImpl.java +++ b/src/main/java/com/gachi/be/domain/newsletter/service/impl/NewsletterServiceImpl.java @@ -204,14 +204,14 @@ public NewsletterUploadResponse retryAnalysis(Long userId, Long newsletterId) { Newsletter newsletter = findNewsletterById(newsletterId); validateOwnership(newsletter, userId); - if (newsletter.getStatus() != NewsletterStatus.FAILED) { + int updated = newsletterRepository.markRetryPendingIfFailed(newsletterId, userId); + if (updated == 0) { throw new BusinessException(ErrorCode.NEWSLETTER_RETRY_NOT_ALLOWED); } checklistRepository.deleteByNewsletterId(newsletterId); calendarEventRepository.deleteByNewsletterIdAndUserId(newsletterId, userId); - newsletter.prepareRetry(); - Newsletter saved = newsletterRepository.save(newsletter); + Newsletter saved = findNewsletterById(newsletterId); TransactionSynchronizationManager.registerSynchronization( new TransactionSynchronization() { From 1d0f578e3312084ec3a278e4ad2f7ff4be1baa9c Mon Sep 17 00:00:00 2001 From: minkyung Date: Thu, 21 May 2026 20:04:55 +0900 Subject: [PATCH 06/33] =?UTF-8?q?feat:=20migration=20v11=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/db/migration/V11__user_add_language_code.sql | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 src/main/resources/db/migration/V11__user_add_language_code.sql diff --git a/src/main/resources/db/migration/V11__user_add_language_code.sql b/src/main/resources/db/migration/V11__user_add_language_code.sql new file mode 100644 index 0000000..e32c42a --- /dev/null +++ b/src/main/resources/db/migration/V11__user_add_language_code.sql @@ -0,0 +1,2 @@ +ALTER TABLE users + ADD COLUMN IF NOT EXISTS language_code VARCHAR(10) NOT NULL DEFAULT 'KO'; From 13d7dbf2a538b04601d8313f4c88a3bca5f0f877 Mon Sep 17 00:00:00 2001 From: minkyung Date: Thu, 21 May 2026 20:32:43 +0900 Subject: [PATCH 07/33] =?UTF-8?q?feat:=20user=EC=97=90=20update=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/service/AuthenticatedUserResolver.java | 2 +- .../domain/auth/service/impl/AuthServiceImpl.java | 2 +- .../java/com/gachi/be/domain/user/entity/User.java | 13 +++++++++++++ .../domain/user/entity/{ => enums}/UserStatus.java | 2 +- .../be/domain/user/repository/UserRepository.java | 2 +- .../be/global/security/JwtAuthenticationFilter.java | 2 +- .../controller/AuthRateLimitIntegrationTest.java | 2 +- .../controller/ChildControllerIntegrationTest.java | 2 +- 8 files changed, 20 insertions(+), 7 deletions(-) rename src/main/java/com/gachi/be/domain/user/entity/{ => enums}/UserStatus.java (51%) diff --git a/src/main/java/com/gachi/be/domain/auth/service/AuthenticatedUserResolver.java b/src/main/java/com/gachi/be/domain/auth/service/AuthenticatedUserResolver.java index cea4fe0..c44f98a 100644 --- a/src/main/java/com/gachi/be/domain/auth/service/AuthenticatedUserResolver.java +++ b/src/main/java/com/gachi/be/domain/auth/service/AuthenticatedUserResolver.java @@ -1,7 +1,7 @@ package com.gachi.be.domain.auth.service; import com.gachi.be.domain.user.entity.User; -import com.gachi.be.domain.user.entity.UserStatus; +import com.gachi.be.domain.user.entity.enums.UserStatus; import com.gachi.be.domain.user.repository.UserRepository; import com.gachi.be.global.code.ErrorCode; import com.gachi.be.global.exception.BusinessException; diff --git a/src/main/java/com/gachi/be/domain/auth/service/impl/AuthServiceImpl.java b/src/main/java/com/gachi/be/domain/auth/service/impl/AuthServiceImpl.java index 5fb22d5..9055cb5 100644 --- a/src/main/java/com/gachi/be/domain/auth/service/impl/AuthServiceImpl.java +++ b/src/main/java/com/gachi/be/domain/auth/service/impl/AuthServiceImpl.java @@ -22,7 +22,7 @@ import com.gachi.be.domain.auth.service.TokenHashService; import com.gachi.be.domain.auth.service.password.PasswordStrengthEvaluator; import com.gachi.be.domain.user.entity.User; -import com.gachi.be.domain.user.entity.UserStatus; +import com.gachi.be.domain.user.entity.enums.UserStatus; import com.gachi.be.domain.user.repository.UserRepository; import com.gachi.be.global.code.ErrorCode; import com.gachi.be.global.exception.AppException; diff --git a/src/main/java/com/gachi/be/domain/user/entity/User.java b/src/main/java/com/gachi/be/domain/user/entity/User.java index 5614a34..00285fb 100644 --- a/src/main/java/com/gachi/be/domain/user/entity/User.java +++ b/src/main/java/com/gachi/be/domain/user/entity/User.java @@ -1,5 +1,6 @@ package com.gachi.be.domain.user.entity; +import com.gachi.be.domain.user.entity.enums.UserStatus; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; @@ -47,6 +48,9 @@ public class User { @Column(nullable = false, length = 20) private UserStatus status; + @Column(name = "language_code", nullable = false, length = 10) + private String languageCode; + @Column(name = "deleted_at") private OffsetDateTime deletedAt; @@ -79,6 +83,7 @@ public User( String name, String phoneNumber, UserStatus status, + String languageCode, OffsetDateTime emailVerifiedAt, OffsetDateTime consentAgreedAt, String consentVersion, @@ -90,6 +95,7 @@ public User( this.name = name; this.phoneNumber = phoneNumber; this.status = status; + this.languageCode = languageCode != null ? languageCode : "KO"; this.emailVerifiedAt = emailVerifiedAt; this.consentAgreedAt = consentAgreedAt; this.consentVersion = consentVersion; @@ -102,6 +108,10 @@ public boolean isActive() { return status == UserStatus.ACTIVE; } + public void updateLanguage(String languageCode) { + this.languageCode = languageCode; + } + @PrePersist protected void onCreate() { LocalDateTime now = LocalDateTime.now(); @@ -112,6 +122,9 @@ protected void onCreate() { if (status == null) { status = UserStatus.ACTIVE; } + if (languageCode == null) { + languageCode = "KO"; + } } @PreUpdate diff --git a/src/main/java/com/gachi/be/domain/user/entity/UserStatus.java b/src/main/java/com/gachi/be/domain/user/entity/enums/UserStatus.java similarity index 51% rename from src/main/java/com/gachi/be/domain/user/entity/UserStatus.java rename to src/main/java/com/gachi/be/domain/user/entity/enums/UserStatus.java index ab36c8b..5d753c1 100644 --- a/src/main/java/com/gachi/be/domain/user/entity/UserStatus.java +++ b/src/main/java/com/gachi/be/domain/user/entity/enums/UserStatus.java @@ -1,4 +1,4 @@ -package com.gachi.be.domain.user.entity; +package com.gachi.be.domain.user.entity.enums; public enum UserStatus { ACTIVE, diff --git a/src/main/java/com/gachi/be/domain/user/repository/UserRepository.java b/src/main/java/com/gachi/be/domain/user/repository/UserRepository.java index 1b5eb7b..29de4b6 100644 --- a/src/main/java/com/gachi/be/domain/user/repository/UserRepository.java +++ b/src/main/java/com/gachi/be/domain/user/repository/UserRepository.java @@ -1,7 +1,7 @@ package com.gachi.be.domain.user.repository; import com.gachi.be.domain.user.entity.User; -import com.gachi.be.domain.user.entity.UserStatus; +import com.gachi.be.domain.user.entity.enums.UserStatus; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; diff --git a/src/main/java/com/gachi/be/global/security/JwtAuthenticationFilter.java b/src/main/java/com/gachi/be/global/security/JwtAuthenticationFilter.java index c5a777e..68374c6 100644 --- a/src/main/java/com/gachi/be/global/security/JwtAuthenticationFilter.java +++ b/src/main/java/com/gachi/be/global/security/JwtAuthenticationFilter.java @@ -2,7 +2,7 @@ import com.gachi.be.domain.auth.service.JwtTokenProvider; import com.gachi.be.domain.user.entity.User; -import com.gachi.be.domain.user.entity.UserStatus; +import com.gachi.be.domain.user.entity.enums.UserStatus; import com.gachi.be.domain.user.repository.UserRepository; import com.gachi.be.global.code.ErrorCode; import com.gachi.be.global.exception.BusinessException; diff --git a/src/test/java/com/gachi/be/domain/auth/api/controller/AuthRateLimitIntegrationTest.java b/src/test/java/com/gachi/be/domain/auth/api/controller/AuthRateLimitIntegrationTest.java index 2b43b6c..b90e613 100644 --- a/src/test/java/com/gachi/be/domain/auth/api/controller/AuthRateLimitIntegrationTest.java +++ b/src/test/java/com/gachi/be/domain/auth/api/controller/AuthRateLimitIntegrationTest.java @@ -6,7 +6,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.gachi.be.domain.user.entity.User; -import com.gachi.be.domain.user.entity.UserStatus; +import com.gachi.be.domain.user.entity.enums.UserStatus; import com.gachi.be.domain.user.repository.UserRepository; import java.time.OffsetDateTime; import java.util.Map; diff --git a/src/test/java/com/gachi/be/domain/child/api/controller/ChildControllerIntegrationTest.java b/src/test/java/com/gachi/be/domain/child/api/controller/ChildControllerIntegrationTest.java index 748e0fc..0b67ed0 100644 --- a/src/test/java/com/gachi/be/domain/child/api/controller/ChildControllerIntegrationTest.java +++ b/src/test/java/com/gachi/be/domain/child/api/controller/ChildControllerIntegrationTest.java @@ -10,7 +10,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.gachi.be.domain.auth.service.JwtTokenProvider; import com.gachi.be.domain.user.entity.User; -import com.gachi.be.domain.user.entity.UserStatus; +import com.gachi.be.domain.user.entity.enums.UserStatus; import com.gachi.be.domain.user.repository.UserRepository; import java.time.OffsetDateTime; import java.util.Map; From 225f5753ad1a2611db2a319a58a956ec4f48f581 Mon Sep 17 00:00:00 2001 From: minju Date: Sun, 24 May 2026 20:45:10 +0900 Subject: [PATCH 08/33] =?UTF-8?q?feat:=20AI=20=EA=B0=80=EC=A0=95=ED=86=B5?= =?UTF-8?q?=EC=8B=A0=EB=AC=B8=20=EC=A0=84=EC=B2=B4=20=EB=B6=84=EC=84=9D=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=20=EC=A0=80=EC=9E=A5=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/newsletter-ai-analyze-integration.md | 26 +++++ .../pipeline/AiNewsletterClient.java | 34 ++++-- .../pipeline/NewsletterAiAnalyzer.java | 29 ++++- .../pipeline/AiNewsletterClientTest.java | 85 ++++++++++++++ .../pipeline/NewsletterAiAnalyzerTest.java | 104 ++++++++++++++++++ 5 files changed, 262 insertions(+), 16 deletions(-) create mode 100644 docs/newsletter-ai-analyze-integration.md create mode 100644 src/test/java/com/gachi/be/domain/newsletter/pipeline/AiNewsletterClientTest.java create mode 100644 src/test/java/com/gachi/be/domain/newsletter/pipeline/NewsletterAiAnalyzerTest.java diff --git a/docs/newsletter-ai-analyze-integration.md b/docs/newsletter-ai-analyze-integration.md new file mode 100644 index 0000000..45ff266 --- /dev/null +++ b/docs/newsletter-ai-analyze-integration.md @@ -0,0 +1,26 @@ +# 가정통신문 AI 전체 분석 응답 저장 연동 + +이 문서는 BE가 AI 서버의 가정통신문 전체 분석 응답을 저장 모델에 반영하는 구현 결정을 정리한다. 상세 API 명세는 노션을 기준으로 관리한다. + +## 결정 사항 + +- BE는 가정통신문 파이프라인의 AI 분석 단계에서 `POST /ai/newsletters/analyze`를 호출한다. +- AI 서버 응답의 top-level `title`, `summary`를 `newsletter.title`, `newsletter.summary`에 저장한다. +- AI 서버 응답의 `items`는 기존 checklist 저장 흐름에 연결한다. +- `dateCandidates` 요청 형식과 `items` 응답 형식은 기존 `extract-items` 계약을 유지한다. +- `meta`는 운영 보조 정보로 받고, 현재 BE 저장 모델에는 직접 저장하지 않는다. +- AI 서버 호출 실패 정책은 `docs/newsletter-ai-failure-policy.md`를 따른다. + +## 저장 정책 + +- `title`: AI 응답 제목을 우선 저장한다. 빈 값이면 기존 BE fallback 제목을 사용한다. +- `summary`: AI 응답 요약을 우선 저장한다. 빈 값이면 기존 BE fallback 요약을 사용한다. +- `items`: `title`이 비어 있지 않은 항목만 checklist로 저장한다. +- `datetime`: TODO 저장 시 `targetDate`, `targetDateLabel` 생성에 사용한다. 파싱 실패 시 날짜 없이 저장한다. +- `dateStatus`: 현재 저장 모델에는 별도 컬럼이 없으므로 직접 저장하지 않는다. 날짜 확정 여부가 필요한 후속 정책은 calendar preview 저장 구조와 함께 별도 이슈에서 다룬다. + +## 호환 정책 + +- AI 서버의 `extract-items` baseline API는 유지하지만, BE 파이프라인은 `analyze`를 우선 사용한다. +- AI 서버가 `items`를 빈 배열로 반환해도 `title`, `summary`는 저장할 수 있다. +- AI 서버 장애 시에는 OCR/번역/dateCandidates 스냅샷을 보존하고 newsletter 상태를 `FAILED`로 둔다. diff --git a/src/main/java/com/gachi/be/domain/newsletter/pipeline/AiNewsletterClient.java b/src/main/java/com/gachi/be/domain/newsletter/pipeline/AiNewsletterClient.java index 92bd2a5..89f791b 100644 --- a/src/main/java/com/gachi/be/domain/newsletter/pipeline/AiNewsletterClient.java +++ b/src/main/java/com/gachi/be/domain/newsletter/pipeline/AiNewsletterClient.java @@ -16,6 +16,7 @@ import java.time.ZoneId; import java.util.ArrayList; import java.util.List; +import java.util.Map; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; @@ -24,7 +25,7 @@ public class AiNewsletterClient { private static final ZoneId DEFAULT_ZONE = ZoneId.of("Asia/Seoul"); - private static final String EXTRACT_ITEMS_PATH = "/ai/newsletters/extract-items"; + private static final String ANALYZE_PATH = "/ai/newsletters/analyze"; private final AiServerProperties aiServerProperties; private final ObjectMapper objectMapper; @@ -39,7 +40,7 @@ public AiNewsletterClient(AiServerProperties aiServerProperties, ObjectMapper ob .build(); } - public List extractItems( + public AnalysisResponse analyze( String originalText, String translatedText, String language, @@ -47,7 +48,7 @@ public List extractItems( try { String requestBody = objectMapper.writeValueAsString( - new ExtractionRequest( + new AnalysisRequest( originalText, translatedText, language != null ? language : "KO", @@ -57,7 +58,7 @@ public List extractItems( HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create(normalizedBaseUrl() + EXTRACT_ITEMS_PATH)) + .uri(URI.create(normalizedBaseUrl() + ANALYZE_PATH)) .header("Content-Type", "application/json") .timeout(Duration.ofSeconds(aiServerProperties.getReadTimeoutSeconds())) .POST(HttpRequest.BodyPublishers.ofString(requestBody)) @@ -68,16 +69,14 @@ public List extractItems( if (response.statusCode() < 200 || response.statusCode() >= 300) { log.error( - "[AiNewsletterClient] AI 서버 항목 추출 실패. status={}, body={}", + "[AiNewsletterClient] AI 서버 분석 실패. status={}, bodyLength={}", response.statusCode(), - response.body()); + response.body() != null ? response.body().length() : 0); throw new ExternalApiException( - ErrorCode.EXTERNAL_API_ERROR, "AI 서버 항목 추출 실패. status=" + response.statusCode()); + ErrorCode.EXTERNAL_API_ERROR, "AI 서버 분석 실패. status=" + response.statusCode()); } - ExtractionResponse extractionResponse = - objectMapper.readValue(response.body(), ExtractionResponse.class); - return extractionResponse.items() != null ? extractionResponse.items() : List.of(); + return objectMapper.readValue(response.body(), AnalysisResponse.class).normalized(); } catch (ExternalApiException e) { throw e; } catch (InterruptedException e) { @@ -119,7 +118,7 @@ private List toDateCandidateRequests( return requests; } - record ExtractionRequest( + record AnalysisRequest( String originalText, String translatedText, String language, @@ -136,12 +135,23 @@ record DateCandidateRequest( String extractionType) {} @JsonIgnoreProperties(ignoreUnknown = true) - record ExtractionResponse(List items) {} + public record AnalysisResponse( + String title, String summary, List items, Map meta) { + + AnalysisResponse normalized() { + return new AnalysisResponse(title, summary, items != null ? items : List.of(), meta); + } + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record SelectedDateCandidate( + Integer index, String candidateId, String originalText, LocalDate normalizedDate) {} @JsonIgnoreProperties(ignoreUnknown = true) public record ExtractedItem( String type, String title, + SelectedDateCandidate selectedDateCandidate, String datetime, String timezone, String evidenceText, diff --git a/src/main/java/com/gachi/be/domain/newsletter/pipeline/NewsletterAiAnalyzer.java b/src/main/java/com/gachi/be/domain/newsletter/pipeline/NewsletterAiAnalyzer.java index 407ec40..e2b78c6 100644 --- a/src/main/java/com/gachi/be/domain/newsletter/pipeline/NewsletterAiAnalyzer.java +++ b/src/main/java/com/gachi/be/domain/newsletter/pipeline/NewsletterAiAnalyzer.java @@ -4,6 +4,7 @@ import com.gachi.be.domain.checklist.entity.enums.ChecklistType; import com.gachi.be.domain.checklist.repository.ChecklistRepository; import com.gachi.be.domain.newsletter.entity.Newsletter; +import com.gachi.be.domain.newsletter.pipeline.AiNewsletterClient.AnalysisResponse; import com.gachi.be.domain.newsletter.pipeline.AiNewsletterClient.ExtractedItem; import com.gachi.be.domain.newsletter.repository.NewsletterRepository; import java.time.LocalDate; @@ -39,14 +40,16 @@ public AiAnalysisResult analyze( new IllegalStateException( "[AiAnalyzer] newsletter를 찾을 수 없습니다. newsletterId=" + newsletterId)); - List items = - aiNewsletterClient.extractItems( + AnalysisResponse analysisResponse = + aiNewsletterClient.analyze( originalText, translatedText, language, newsletter.getDateCandidates()); + List items = + analysisResponse.items() != null ? analysisResponse.items() : List.of(); saveExtractedItems(newsletterId, newsletter.getUserId(), items); - String title = inferTitle(originalText); - String summary = buildBaselineSummary(translatedText != null ? translatedText : originalText); + String title = normalizeTitle(analysisResponse.title(), originalText); + String summary = normalizeSummary(analysisResponse.summary(), translatedText, originalText); log.info( "[AiAnalyzer] AI 서버 분석 완료. newsletterId={}, extractedItems={}", newsletterId, items.size()); @@ -109,6 +112,20 @@ private LocalDate parseTargetDate(String value) { } } + private String normalizeTitle(String aiTitle, String originalText) { + if (aiTitle != null && !aiTitle.isBlank()) { + return trimToMax(compact(aiTitle), TITLE_MAX_LENGTH); + } + return inferTitle(originalText); + } + + private String normalizeSummary(String aiSummary, String translatedText, String originalText) { + if (aiSummary != null && !aiSummary.isBlank()) { + return trimToMax(compact(aiSummary), SUMMARY_MAX_LENGTH); + } + return buildBaselineSummary(firstNonBlank(translatedText, originalText)); + } + private String inferTitle(String originalText) { if (originalText == null || originalText.isBlank()) { return DEFAULT_TITLE; @@ -134,6 +151,10 @@ private String compact(String text) { return text == null ? "" : text.replaceAll("\\s+", " ").trim(); } + private String firstNonBlank(String primary, String fallback) { + return primary != null && !primary.isBlank() ? primary : fallback; + } + private String trimNullable(String value, int maxLength) { if (value == null || value.isBlank()) { return null; diff --git a/src/test/java/com/gachi/be/domain/newsletter/pipeline/AiNewsletterClientTest.java b/src/test/java/com/gachi/be/domain/newsletter/pipeline/AiNewsletterClientTest.java new file mode 100644 index 0000000..e606892 --- /dev/null +++ b/src/test/java/com/gachi/be/domain/newsletter/pipeline/AiNewsletterClientTest.java @@ -0,0 +1,85 @@ +package com.gachi.be.domain.newsletter.pipeline; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.gachi.be.domain.newsletter.pipeline.AiNewsletterClient.AnalysisResponse; +import com.gachi.be.global.config.external.AiServerProperties; +import com.sun.net.httpserver.HttpServer; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; + +class AiNewsletterClientTest { + + @Test + void analyzeCallsAnalyzeEndpointAndParsesTitleSummaryItems() throws IOException { + AtomicReference requestPath = new AtomicReference<>(); + AtomicReference requestBody = new AtomicReference<>(); + HttpServer server = HttpServer.create(new InetSocketAddress("localhost", 0), 0); + ExecutorService executor = Executors.newSingleThreadExecutor(); + server.createContext( + "/ai/newsletters/analyze", + exchange -> { + requestPath.set(exchange.getRequestURI().getPath()); + requestBody.set( + new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)); + + byte[] response = + """ + { + "title": "AI 제목", + "summary": "AI 요약", + "items": [ + { + "type": "checklist", + "title": "동의서 제출", + "selectedDateCandidate": null, + "datetime": "2026-05-25", + "timezone": "Asia/Seoul", + "evidenceText": "5월 25일까지 동의서를 제출해 주세요.", + "dateStatus": "confirmed", + "confidence": 0.9, + "needsUserConfirmation": false, + "confirmationQuestion": null + } + ], + "meta": {"mode": "test"} + } + """ + .getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, response.length); + exchange.getResponseBody().write(response); + exchange.close(); + }); + server.setExecutor(executor); + server.start(); + + try { + AiServerProperties properties = new AiServerProperties(); + properties.setBaseUrl("http://localhost:" + server.getAddress().getPort()); + properties.setConnectTimeoutSeconds(3); + properties.setReadTimeoutSeconds(3); + AiNewsletterClient client = + new AiNewsletterClient(properties, new ObjectMapper().findAndRegisterModules()); + + AnalysisResponse response = client.analyze("원문", "번역문", "KO", List.of()); + + assertThat(requestPath.get()).isEqualTo("/ai/newsletters/analyze"); + assertThat(requestBody.get()).contains("\"originalText\":\"원문\""); + assertThat(response.title()).isEqualTo("AI 제목"); + assertThat(response.summary()).isEqualTo("AI 요약"); + assertThat(response.items()).hasSize(1); + assertThat(response.items().get(0).title()).isEqualTo("동의서 제출"); + } finally { + server.stop(0); + executor.shutdownNow(); + } + } +} diff --git a/src/test/java/com/gachi/be/domain/newsletter/pipeline/NewsletterAiAnalyzerTest.java b/src/test/java/com/gachi/be/domain/newsletter/pipeline/NewsletterAiAnalyzerTest.java new file mode 100644 index 0000000..e0ef31f --- /dev/null +++ b/src/test/java/com/gachi/be/domain/newsletter/pipeline/NewsletterAiAnalyzerTest.java @@ -0,0 +1,104 @@ +package com.gachi.be.domain.newsletter.pipeline; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.gachi.be.domain.checklist.entity.Checklist; +import com.gachi.be.domain.checklist.repository.ChecklistRepository; +import com.gachi.be.domain.newsletter.entity.Newsletter; +import com.gachi.be.domain.newsletter.entity.enums.NewsletterStatus; +import com.gachi.be.domain.newsletter.pipeline.AiNewsletterClient.AnalysisResponse; +import com.gachi.be.domain.newsletter.pipeline.AiNewsletterClient.ExtractedItem; +import com.gachi.be.domain.newsletter.pipeline.NewsletterAiAnalyzer.AiAnalysisResult; +import com.gachi.be.domain.newsletter.repository.NewsletterRepository; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class NewsletterAiAnalyzerTest { + + @Mock private AiNewsletterClient aiNewsletterClient; + @Mock private ChecklistRepository checklistRepository; + @Mock private NewsletterRepository newsletterRepository; + + @InjectMocks private NewsletterAiAnalyzer newsletterAiAnalyzer; + + @Test + void analyzeUsesAiTitleSummaryAndSavesItems() { + Long newsletterId = 10L; + Newsletter newsletter = + Newsletter.builder() + .userId(20L) + .fileKey("newsletter.pdf") + .fileHash("hash") + .status(NewsletterStatus.PROCESSING) + .language("KO") + .build(); + + ExtractedItem item = + new ExtractedItem( + "reminder", + "준비물 확인", + null, + "2026-05-25", + "Asia/Seoul", + "체육복을 준비해 주세요.", + "confirmed", + 0.91, + false, + null); + + when(newsletterRepository.findById(newsletterId)).thenReturn(Optional.of(newsletter)); + when(aiNewsletterClient.analyze("원문", "번역문", "KO", List.of())) + .thenReturn(new AnalysisResponse("AI 제목", "AI 요약", List.of(item), Map.of())); + + AiAnalysisResult result = newsletterAiAnalyzer.analyze(newsletterId, "원문", "번역문", "KO"); + + assertThat(result.title()).isEqualTo("AI 제목"); + assertThat(result.summary()).isEqualTo("AI 요약"); + + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(checklistRepository).saveAll(captor.capture()); + + List savedItems = captor.getValue(); + assertThat(savedItems).hasSize(1); + assertThat(savedItems.get(0).getNewsletterId()).isEqualTo(newsletterId); + assertThat(savedItems.get(0).getUserId()).isEqualTo(20L); + assertThat(savedItems.get(0).getContent()).isEqualTo("준비물 확인"); + assertThat(savedItems.get(0).getDetail()).isEqualTo("체육복을 준비해 주세요."); + } + + @Test + void analyzeFallsBackWhenAiTitleSummaryAreBlank() { + Long newsletterId = 11L; + Newsletter newsletter = + Newsletter.builder() + .userId(21L) + .fileKey("newsletter.pdf") + .fileHash("hash") + .status(NewsletterStatus.PROCESSING) + .language("KO") + .build(); + + when(newsletterRepository.findById(newsletterId)).thenReturn(Optional.of(newsletter)); + when(aiNewsletterClient.analyze("가정통신문 제목\n본문입니다.", "번역 요약 대상", "KO", List.of())) + .thenReturn(new AnalysisResponse(" ", "", List.of(), Map.of())); + + AiAnalysisResult result = + newsletterAiAnalyzer.analyze(newsletterId, "가정통신문 제목\n본문입니다.", "번역 요약 대상", "KO"); + + assertThat(result.title()).isEqualTo("가정통신문 제목"); + assertThat(result.summary()).isEqualTo("번역 요약 대상"); + verify(checklistRepository, org.mockito.Mockito.never()).saveAll(anyList()); + } +} From f0447a84629d656ce9022ad5ddec94a801361aec Mon Sep 17 00:00:00 2001 From: minju Date: Sun, 24 May 2026 21:18:30 +0900 Subject: [PATCH 09/33] =?UTF-8?q?refactor:=20AI=20=EB=B6=84=EC=84=9D=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=20=EC=A0=80=EC=9E=A5=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pipeline/NewsletterAiAnalyzer.java | 3 +- .../pipeline/AiNewsletterClientTest.java | 128 ++++++++++++++---- .../pipeline/NewsletterAiAnalyzerTest.java | 62 ++++++++- 3 files changed, 160 insertions(+), 33 deletions(-) diff --git a/src/main/java/com/gachi/be/domain/newsletter/pipeline/NewsletterAiAnalyzer.java b/src/main/java/com/gachi/be/domain/newsletter/pipeline/NewsletterAiAnalyzer.java index e2b78c6..f8349fd 100644 --- a/src/main/java/com/gachi/be/domain/newsletter/pipeline/NewsletterAiAnalyzer.java +++ b/src/main/java/com/gachi/be/domain/newsletter/pipeline/NewsletterAiAnalyzer.java @@ -43,8 +43,7 @@ public AiAnalysisResult analyze( AnalysisResponse analysisResponse = aiNewsletterClient.analyze( originalText, translatedText, language, newsletter.getDateCandidates()); - List items = - analysisResponse.items() != null ? analysisResponse.items() : List.of(); + List items = analysisResponse.items(); saveExtractedItems(newsletterId, newsletter.getUserId(), items); diff --git a/src/test/java/com/gachi/be/domain/newsletter/pipeline/AiNewsletterClientTest.java b/src/test/java/com/gachi/be/domain/newsletter/pipeline/AiNewsletterClientTest.java index e606892..05d63c2 100644 --- a/src/test/java/com/gachi/be/domain/newsletter/pipeline/AiNewsletterClientTest.java +++ b/src/test/java/com/gachi/be/domain/newsletter/pipeline/AiNewsletterClientTest.java @@ -1,10 +1,13 @@ package com.gachi.be.domain.newsletter.pipeline; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import com.fasterxml.jackson.databind.ObjectMapper; import com.gachi.be.domain.newsletter.pipeline.AiNewsletterClient.AnalysisResponse; +import com.gachi.be.global.code.ErrorCode; import com.gachi.be.global.config.external.AiServerProperties; +import com.gachi.be.global.exception.ExternalApiException; import com.sun.net.httpserver.HttpServer; import java.io.IOException; import java.net.InetSocketAddress; @@ -13,16 +16,29 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; class AiNewsletterClientTest { + private HttpServer server; + private ExecutorService executor; + + @AfterEach + void tearDown() { + if (server != null) { + server.stop(0); + } + if (executor != null) { + executor.shutdownNow(); + } + } + @Test void analyzeCallsAnalyzeEndpointAndParsesTitleSummaryItems() throws IOException { AtomicReference requestPath = new AtomicReference<>(); AtomicReference requestBody = new AtomicReference<>(); - HttpServer server = HttpServer.create(new InetSocketAddress("localhost", 0), 0); - ExecutorService executor = Executors.newSingleThreadExecutor(); + startServer(); server.createContext( "/ai/newsletters/analyze", exchange -> { @@ -53,33 +69,93 @@ void analyzeCallsAnalyzeEndpointAndParsesTitleSummaryItems() throws IOException } """ .getBytes(StandardCharsets.UTF_8); - exchange.getResponseHeaders().add("Content-Type", "application/json"); - exchange.sendResponseHeaders(200, response.length); - exchange.getResponseBody().write(response); - exchange.close(); + sendResponse(exchange, 200, response); }); + + AiNewsletterClient client = newClient(3); + + AnalysisResponse response = client.analyze("원문", "번역문", "KO", List.of()); + + assertThat(requestPath.get()).isEqualTo("/ai/newsletters/analyze"); + assertThat(requestBody.get()).contains("\"originalText\":\"원문\""); + assertThat(response.title()).isEqualTo("AI 제목"); + assertThat(response.summary()).isEqualTo("AI 요약"); + assertThat(response.items()).hasSize(1); + assertThat(response.items().get(0).title()).isEqualTo("동의서 제출"); + } + + @Test + void analyzeThrowsExternalApiExceptionWhenAiServerReturnsError() throws IOException { + startServer(); + server.createContext( + "/ai/newsletters/analyze", + exchange -> sendResponse(exchange, 500, "{\"detail\":\"server error\"}".getBytes())); + + AiNewsletterClient client = newClient(3); + + assertThatThrownBy(() -> client.analyze("원문", null, "KO", List.of())) + .isInstanceOf(ExternalApiException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.EXTERNAL_API_ERROR); + } + + @Test + void analyzeThrowsExternalApiExceptionWhenAiServerResponseTimesOut() throws IOException { + startServer(); + server.createContext( + "/ai/newsletters/analyze", + exchange -> { + try { + Thread.sleep(1500); + sendResponse(exchange, 200, "{}".getBytes(StandardCharsets.UTF_8)); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }); + + AiNewsletterClient client = newClient(1); + + assertThatThrownBy(() -> client.analyze("원문", null, "KO", List.of())) + .isInstanceOf(ExternalApiException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.EXTERNAL_API_ERROR); + } + + @Test + void analyzeThrowsExternalApiExceptionWhenAiServerReturnsMalformedJson() throws IOException { + startServer(); + server.createContext( + "/ai/newsletters/analyze", + exchange -> sendResponse(exchange, 200, "{".getBytes(StandardCharsets.UTF_8))); + + AiNewsletterClient client = newClient(3); + + assertThatThrownBy(() -> client.analyze("원문", null, "KO", List.of())) + .isInstanceOf(ExternalApiException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.EXTERNAL_API_ERROR); + } + + private void startServer() throws IOException { + server = HttpServer.create(new InetSocketAddress("localhost", 0), 0); + executor = Executors.newSingleThreadExecutor(); server.setExecutor(executor); server.start(); + } - try { - AiServerProperties properties = new AiServerProperties(); - properties.setBaseUrl("http://localhost:" + server.getAddress().getPort()); - properties.setConnectTimeoutSeconds(3); - properties.setReadTimeoutSeconds(3); - AiNewsletterClient client = - new AiNewsletterClient(properties, new ObjectMapper().findAndRegisterModules()); - - AnalysisResponse response = client.analyze("원문", "번역문", "KO", List.of()); - - assertThat(requestPath.get()).isEqualTo("/ai/newsletters/analyze"); - assertThat(requestBody.get()).contains("\"originalText\":\"원문\""); - assertThat(response.title()).isEqualTo("AI 제목"); - assertThat(response.summary()).isEqualTo("AI 요약"); - assertThat(response.items()).hasSize(1); - assertThat(response.items().get(0).title()).isEqualTo("동의서 제출"); - } finally { - server.stop(0); - executor.shutdownNow(); - } + private AiNewsletterClient newClient(int readTimeoutSeconds) { + AiServerProperties properties = new AiServerProperties(); + properties.setBaseUrl("http://localhost:" + server.getAddress().getPort()); + properties.setConnectTimeoutSeconds(3); + properties.setReadTimeoutSeconds(readTimeoutSeconds); + return new AiNewsletterClient(properties, new ObjectMapper().findAndRegisterModules()); + } + + private void sendResponse(com.sun.net.httpserver.HttpExchange exchange, int status, byte[] body) + throws IOException { + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(status, body.length); + exchange.getResponseBody().write(body); + exchange.close(); } } diff --git a/src/test/java/com/gachi/be/domain/newsletter/pipeline/NewsletterAiAnalyzerTest.java b/src/test/java/com/gachi/be/domain/newsletter/pipeline/NewsletterAiAnalyzerTest.java index e0ef31f..c1a150c 100644 --- a/src/test/java/com/gachi/be/domain/newsletter/pipeline/NewsletterAiAnalyzerTest.java +++ b/src/test/java/com/gachi/be/domain/newsletter/pipeline/NewsletterAiAnalyzerTest.java @@ -13,12 +13,14 @@ import com.gachi.be.domain.newsletter.pipeline.AiNewsletterClient.ExtractedItem; import com.gachi.be.domain.newsletter.pipeline.NewsletterAiAnalyzer.AiAnalysisResult; import com.gachi.be.domain.newsletter.repository.NewsletterRepository; +import java.time.LocalDate; import java.util.List; import java.util.Map; import java.util.Optional; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; +import org.mockito.Captor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -30,6 +32,8 @@ class NewsletterAiAnalyzerTest { @Mock private ChecklistRepository checklistRepository; @Mock private NewsletterRepository newsletterRepository; + @Captor private ArgumentCaptor> checklistsCaptor; + @InjectMocks private NewsletterAiAnalyzer newsletterAiAnalyzer; @Test @@ -66,16 +70,16 @@ void analyzeUsesAiTitleSummaryAndSavesItems() { assertThat(result.title()).isEqualTo("AI 제목"); assertThat(result.summary()).isEqualTo("AI 요약"); - @SuppressWarnings("unchecked") - ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); - verify(checklistRepository).saveAll(captor.capture()); + verify(checklistRepository).saveAll(checklistsCaptor.capture()); - List savedItems = captor.getValue(); + List savedItems = checklistsCaptor.getValue(); assertThat(savedItems).hasSize(1); assertThat(savedItems.get(0).getNewsletterId()).isEqualTo(newsletterId); assertThat(savedItems.get(0).getUserId()).isEqualTo(20L); assertThat(savedItems.get(0).getContent()).isEqualTo("준비물 확인"); assertThat(savedItems.get(0).getDetail()).isEqualTo("체육복을 준비해 주세요."); + assertThat(savedItems.get(0).getTargetDate()).isEqualTo(LocalDate.parse("2026-05-25")); + assertThat(savedItems.get(0).getTargetDateLabel()).isEqualTo("5월 25일"); } @Test @@ -92,7 +96,7 @@ void analyzeFallsBackWhenAiTitleSummaryAreBlank() { when(newsletterRepository.findById(newsletterId)).thenReturn(Optional.of(newsletter)); when(aiNewsletterClient.analyze("가정통신문 제목\n본문입니다.", "번역 요약 대상", "KO", List.of())) - .thenReturn(new AnalysisResponse(" ", "", List.of(), Map.of())); + .thenReturn(new AnalysisResponse(" ", " ", List.of(), Map.of())); AiAnalysisResult result = newsletterAiAnalyzer.analyze(newsletterId, "가정통신문 제목\n본문입니다.", "번역 요약 대상", "KO"); @@ -101,4 +105,52 @@ void analyzeFallsBackWhenAiTitleSummaryAreBlank() { assertThat(result.summary()).isEqualTo("번역 요약 대상"); verify(checklistRepository, org.mockito.Mockito.never()).saveAll(anyList()); } + + @Test + void analyzeFallsBackOnlySummaryWhenAiSummaryIsBlank() { + Long newsletterId = 12L; + Newsletter newsletter = + Newsletter.builder() + .userId(22L) + .fileKey("newsletter.pdf") + .fileHash("hash") + .status(NewsletterStatus.PROCESSING) + .language("KO") + .build(); + + when(newsletterRepository.findById(newsletterId)).thenReturn(Optional.of(newsletter)); + when(aiNewsletterClient.analyze("원문 제목\n본문입니다.", "번역 요약 대상", "KO", List.of())) + .thenReturn(new AnalysisResponse("AI 제목", " ", List.of(), Map.of())); + + AiAnalysisResult result = + newsletterAiAnalyzer.analyze(newsletterId, "원문 제목\n본문입니다.", "번역 요약 대상", "KO"); + + assertThat(result.title()).isEqualTo("AI 제목"); + assertThat(result.summary()).isEqualTo("번역 요약 대상"); + verify(checklistRepository, org.mockito.Mockito.never()).saveAll(anyList()); + } + + @Test + void analyzeFallsBackOnlyTitleWhenAiTitleIsBlank() { + Long newsletterId = 13L; + Newsletter newsletter = + Newsletter.builder() + .userId(23L) + .fileKey("newsletter.pdf") + .fileHash("hash") + .status(NewsletterStatus.PROCESSING) + .language("KO") + .build(); + + when(newsletterRepository.findById(newsletterId)).thenReturn(Optional.of(newsletter)); + when(aiNewsletterClient.analyze("원문 제목\n본문입니다.", "번역 요약 대상", "KO", List.of())) + .thenReturn(new AnalysisResponse("", "AI 요약", List.of(), Map.of())); + + AiAnalysisResult result = + newsletterAiAnalyzer.analyze(newsletterId, "원문 제목\n본문입니다.", "번역 요약 대상", "KO"); + + assertThat(result.title()).isEqualTo("원문 제목"); + assertThat(result.summary()).isEqualTo("AI 요약"); + verify(checklistRepository, org.mockito.Mockito.never()).saveAll(anyList()); + } } From aefbcdeb9c280dd5316e5e153982bc37d4c12b2b Mon Sep 17 00:00:00 2001 From: minju Date: Sun, 24 May 2026 21:50:27 +0900 Subject: [PATCH 10/33] =?UTF-8?q?chore:=20AI=20=EC=BB=A8=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=84=88=20OpenAI=20=ED=99=98=EA=B2=BD=EB=B3=80=EC=88=98=20?= =?UTF-8?q?=EC=A0=84=EB=8B=AC=20=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/.env.example | 4 ++++ deploy/docker-compose.yml | 6 +++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/deploy/.env.example b/deploy/.env.example index ac31d36..2c563b7 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -32,7 +32,11 @@ SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_ENABLE=true SPRING_MAIL_USERNAME= AI_SERVER_BASE_URL=http://ai:8000 +OPENAI_ENABLED=false OPENAI_API_KEY= +OPENAI_MODEL=gpt-4o-mini +OPENAI_BASE_URL=https://api.openai.com/v1 +OPENAI_TIMEOUT_SECONDS=60 AUTH_EMAIL_FROM_ADDRESS= AUTH_EMAIL_NOOP_ALLOWED=false diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 03c78c1..ac9d5b5 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -120,7 +120,11 @@ services: image: ${AI_IMAGE:-gachi-ai:latest} container_name: gachi-ai environment: - OPENAI_API_KEY: ${OPENAI_API_KEY} + OPENAI_ENABLED: ${OPENAI_ENABLED:-false} + OPENAI_API_KEY: ${OPENAI_API_KEY:-} + OPENAI_MODEL: ${OPENAI_MODEL:-gpt-4o-mini} + OPENAI_BASE_URL: ${OPENAI_BASE_URL:-https://api.openai.com/v1} + OPENAI_TIMEOUT_SECONDS: ${OPENAI_TIMEOUT_SECONDS:-60} healthcheck: test: ["CMD-SHELL", "curl -fsS http://localhost:8000/ai/health || exit 1"] interval: 30s From cdc7f61591ce08ae6f1bc18682cdec9855540c1e Mon Sep 17 00:00:00 2001 From: minkyung Date: Mon, 25 May 2026 08:34:43 +0900 Subject: [PATCH 11/33] =?UTF-8?q?feat:=20repository=20=EC=BD=94=EB=93=9C?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/NewsletterRepository.java | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/main/java/com/gachi/be/domain/newsletter/repository/NewsletterRepository.java b/src/main/java/com/gachi/be/domain/newsletter/repository/NewsletterRepository.java index c99c0cc..87be618 100644 --- a/src/main/java/com/gachi/be/domain/newsletter/repository/NewsletterRepository.java +++ b/src/main/java/com/gachi/be/domain/newsletter/repository/NewsletterRepository.java @@ -4,6 +4,8 @@ import java.time.OffsetDateTime; import java.util.List; import java.util.Optional; + +import com.gachi.be.domain.newsletter.entity.enums.NewsletterStatus; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; @@ -74,4 +76,20 @@ List findRecentByUserId( @Param("userId") Long userId, @Param("rangeStart") OffsetDateTime rangeStart, @Param("rangeEnd") OffsetDateTime rangeEnd); + + + /** 언어 변경 시 진행중인 파이프라인 중단 처리용 쿼리*/ + @Modifying + @Query( + """ + UPDATE Newsletter n + SET n.status = :failedStatus + WHERE n.userId = :userId + AND n.status IN :targetStatuses + """) + int cancelInProgressByUserId( + @Param("userId") Long userId, + @Param("targetStatuses") List targetStatuses, + @Param("failedStatus") NewsletterStatus failedStatus); + } From 24aabaeb7c82abe472a79ceeeeba30fc83628a8c Mon Sep 17 00:00:00 2001 From: minkyung Date: Mon, 25 May 2026 08:36:15 +0900 Subject: [PATCH 12/33] =?UTF-8?q?feat:=20request=20dto=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/dto/request/ChangeLanguageRequest.java | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 src/main/java/com/gachi/be/domain/user/dto/request/ChangeLanguageRequest.java diff --git a/src/main/java/com/gachi/be/domain/user/dto/request/ChangeLanguageRequest.java b/src/main/java/com/gachi/be/domain/user/dto/request/ChangeLanguageRequest.java new file mode 100644 index 0000000..fa83e1a --- /dev/null +++ b/src/main/java/com/gachi/be/domain/user/dto/request/ChangeLanguageRequest.java @@ -0,0 +1,12 @@ +package com.gachi.be.domain.user.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +/**언어 설정 변경 요청 DTO.*/ +public record ChangeLanguageRequest( + @NotBlank(message = "언어 코드는 필수입니다.") + @Pattern( + regexp = "^(KO|US|ZH|VI)$", + message = "지원하지 않는 언어 코드입니다. KO, US, ZH, VI 중 하나여야 합니다.") + String languageCode) {} From 0b486e9d100f209047f15d43ebf8c5a6b198463a Mon Sep 17 00:00:00 2001 From: minkyung Date: Mon, 25 May 2026 08:40:01 +0900 Subject: [PATCH 13/33] =?UTF-8?q?feat:=20controller=20=EC=BD=94=EB=93=9C?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20successcode=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/api/controller/UserController.java | 49 +++++++++++++++++-- .../com/gachi/be/global/code/SuccessCode.java | 3 +- 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/gachi/be/domain/user/api/controller/UserController.java b/src/main/java/com/gachi/be/domain/user/api/controller/UserController.java index 42b2a6a..b4b5d93 100644 --- a/src/main/java/com/gachi/be/domain/user/api/controller/UserController.java +++ b/src/main/java/com/gachi/be/domain/user/api/controller/UserController.java @@ -1,22 +1,31 @@ package com.gachi.be.domain.user.api.controller; import com.gachi.be.domain.auth.service.AuthenticatedUserResolver; +import com.gachi.be.domain.newsletter.entity.enums.NewsletterStatus; +import com.gachi.be.domain.newsletter.repository.NewsletterRepository; +import com.gachi.be.domain.user.dto.request.ChangeLanguageRequest; import com.gachi.be.domain.user.dto.response.UserMeResponse; import com.gachi.be.domain.user.entity.User; +import com.gachi.be.domain.user.repository.UserRepository; import com.gachi.be.global.api.ApiResponse; import com.gachi.be.global.code.SuccessCode; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestHeader; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import lombok.extern.slf4j.Slf4j; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.*; + +import java.util.List; /** 로그인 사용자 기준 내 정보 조회 API를 제공한다. */ +@Slf4j @RestController @RequiredArgsConstructor @RequestMapping("/api/v1/users") public class UserController { private final AuthenticatedUserResolver authenticatedUserResolver; + private final UserRepository userRepository; + private final NewsletterRepository newsletterRepository; @GetMapping("/me") public ApiResponse getMyInfo( @@ -31,4 +40,36 @@ public ApiResponse getMyInfo( user.getName(), user.getPhoneNumber())); } + + /** 언어 설정 변경 API */ + @PatchMapping("/me/language") + @Transactional + public ApiResponse changeLanguage( + @RequestHeader(value = "Authorization", required = false) String authorizationHeader, + @RequestBody @Valid ChangeLanguageRequest request) { + + User user = authenticatedUserResolver.resolveActiveUser(authorizationHeader); + + String previousLanguage = user.getLanguageCode(); + String newLanguage = request.languageCode(); + + user.updateLanguage(newLanguage); + userRepository.save(user); + + // 진행 중인 파이프라인 FAILED 처리 + int cancelledCount = + newsletterRepository.cancelInProgressByUserId( + user.getId(), + List.of(NewsletterStatus.PENDING, NewsletterStatus.PROCESSING), + NewsletterStatus.FAILED); + + log.info( + "[Language] 언어 설정 변경. userId={}, {} -> {}, cancelledPipelines={}", + user.getId(), + previousLanguage, + newLanguage, + cancelledCount); + + return ApiResponse.success(SuccessCode.USER_LANGUAGE_UPDATED, null); + } } diff --git a/src/main/java/com/gachi/be/global/code/SuccessCode.java b/src/main/java/com/gachi/be/global/code/SuccessCode.java index bf72634..9ed744a 100644 --- a/src/main/java/com/gachi/be/global/code/SuccessCode.java +++ b/src/main/java/com/gachi/be/global/code/SuccessCode.java @@ -36,7 +36,8 @@ public enum SuccessCode { CALENDAR_DAILY_SUCCESS(HttpStatus.OK, "CAL2005", "날짜별 캘린더 조회에 성공하였습니다."), CHECKLIST_TODAY_SUCCESS(HttpStatus.OK, "CL2001", "오늘 마감 체크리스트 조회에 성공하였습니다."), CHECKLIST_COMPLETE_SUCCESS(HttpStatus.OK, "CL2002", "체크리스트 완료 처리에 성공하였습니다."), - CHECKLIST_DELETED(HttpStatus.OK, "CL2003", "체크리스트 삭제에 성공하였습니다."); + CHECKLIST_DELETED(HttpStatus.OK, "CL2003", "체크리스트 삭제에 성공하였습니다."), + USER_LANGUAGE_UPDATED(HttpStatus.OK, "USER2001", "언어 설정이 변경되었습니다."); private final HttpStatus httpStatus; private final String code; From 9273a8d687e02fe238b7bb522517a59dadb03811 Mon Sep 17 00:00:00 2001 From: minkyung Date: Mon, 25 May 2026 08:41:07 +0900 Subject: [PATCH 14/33] =?UTF-8?q?feat:=20errorcode=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/gachi/be/global/code/ErrorCode.java | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/main/java/com/gachi/be/global/code/ErrorCode.java b/src/main/java/com/gachi/be/global/code/ErrorCode.java index 7c384bb..4c36b17 100644 --- a/src/main/java/com/gachi/be/global/code/ErrorCode.java +++ b/src/main/java/com/gachi/be/global/code/ErrorCode.java @@ -216,6 +216,13 @@ public enum ErrorCode { "Redis에 newsletter:preview:{id} 키 없음 또는 TTL 만료", ErrorLogLevel.WARN), + USER_LANGUAGE_CODE_INVALID( + HttpStatus.BAD_REQUEST, + "USER4001", + "지원하지 않는 언어 코드입니다. (KO, US, ZH, VI 중 하나여야 합니다.)", + "언어 코드 유효성 검사 실패", + ErrorLogLevel.WARN), + EXTERNAL_API_ERROR( HttpStatus.BAD_GATEWAY, "EXT5021", From 410b5a866de1cfbd7c950fb91e51429eaa276160 Mon Sep 17 00:00:00 2001 From: minkyung Date: Mon, 25 May 2026 08:43:06 +0900 Subject: [PATCH 15/33] =?UTF-8?q?feat:=20auth=20dto=EC=97=90=20=ED=9A=8C?= =?UTF-8?q?=EC=9B=90=EA=B0=80=EC=9E=85=20=EC=96=B8=EC=96=B4=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/gachi/be/domain/auth/dto/request/SignupRequest.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/gachi/be/domain/auth/dto/request/SignupRequest.java b/src/main/java/com/gachi/be/domain/auth/dto/request/SignupRequest.java index f66f39c..2a2ac8a 100644 --- a/src/main/java/com/gachi/be/domain/auth/dto/request/SignupRequest.java +++ b/src/main/java/com/gachi/be/domain/auth/dto/request/SignupRequest.java @@ -17,4 +17,8 @@ public record SignupRequest( @NotBlank @Pattern(regexp = PhoneNumberValidation.REGEXP, message = PhoneNumberValidation.MESSAGE) String phoneNumber, - @NotNull Boolean consentAgreed) {} + @NotNull Boolean consentAgreed, + @Pattern( + regexp = "^(KO|US|ZH|VI)$", + message = "지원하지 않는 언어 코드입니다. KO, US, ZH, VI 중 하나여야 합니다.") + String languageCode) {} From 91260a80ef03ddb682df67f4da838c83e3a7a420 Mon Sep 17 00:00:00 2001 From: minkyung Date: Mon, 25 May 2026 08:45:11 +0900 Subject: [PATCH 16/33] =?UTF-8?q?feat:=20auth=20service=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=EC=97=90=20=EC=9E=90=EB=85=80=20=EC=83=89=EC=83=81?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/gachi/be/domain/auth/service/impl/AuthServiceImpl.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/gachi/be/domain/auth/service/impl/AuthServiceImpl.java b/src/main/java/com/gachi/be/domain/auth/service/impl/AuthServiceImpl.java index 9055cb5..21c9b9b 100644 --- a/src/main/java/com/gachi/be/domain/auth/service/impl/AuthServiceImpl.java +++ b/src/main/java/com/gachi/be/domain/auth/service/impl/AuthServiceImpl.java @@ -138,6 +138,7 @@ public SignupResponse signup(SignupRequest request) { .name(name) .phoneNumber(phoneNumber) .status(UserStatus.ACTIVE) + .languageCode(request.languageCode()) .emailVerifiedAt(now) .consentAgreedAt(now) .consentVersion(authProperties.getConsentVersion()) From 5c3cb36f5302641e1392e07aabe52d46fc66fd71 Mon Sep 17 00:00:00 2001 From: minkyung Date: Mon, 25 May 2026 09:12:49 +0900 Subject: [PATCH 17/33] =?UTF-8?q?feat:=20service=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80-=20=EC=82=AC=EC=9A=A9=EC=9E=90=EC=96=B8?= =?UTF-8?q?=EC=96=B4=EC=84=A4=EC=A0=95=EC=9C=BC=EB=A1=9C=20=EB=B0=9B?= =?UTF-8?q?=EC=95=84=EC=99=80=EC=84=9C=20=EB=B2=88=EC=97=AD=EC=B2=98?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../newsletter/service/NewsletterService.java | 3 +-- .../service/impl/NewsletterServiceImpl.java | 27 ++++++++++++++++--- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/gachi/be/domain/newsletter/service/NewsletterService.java b/src/main/java/com/gachi/be/domain/newsletter/service/NewsletterService.java index 7da5cac..dc16910 100644 --- a/src/main/java/com/gachi/be/domain/newsletter/service/NewsletterService.java +++ b/src/main/java/com/gachi/be/domain/newsletter/service/NewsletterService.java @@ -11,11 +11,10 @@ public interface NewsletterService { * @param userId 현재 로그인한 사용자 ID * @param file 업로드할 파일 (jpg/png/pdf, 최대 10MB) * @param childId 연결할 자녀 ID (미선택 시 null) - * @param userLanguage 사용자 언어 코드 (KO/US/ZH/VI) * @return newsletterId + status(PENDING) */ NewsletterUploadResponse upload( - Long userId, MultipartFile file, Long childId, String userLanguage); + Long userId, MultipartFile file, Long childId); /** * 가정통신문의 현재 분석 상태와 진행률을 조회. diff --git a/src/main/java/com/gachi/be/domain/newsletter/service/impl/NewsletterServiceImpl.java b/src/main/java/com/gachi/be/domain/newsletter/service/impl/NewsletterServiceImpl.java index b773376..d7fb697 100644 --- a/src/main/java/com/gachi/be/domain/newsletter/service/impl/NewsletterServiceImpl.java +++ b/src/main/java/com/gachi/be/domain/newsletter/service/impl/NewsletterServiceImpl.java @@ -17,6 +17,8 @@ import com.gachi.be.domain.newsletter.pipeline.NewsletterPipelineService; import com.gachi.be.domain.newsletter.repository.NewsletterRepository; import com.gachi.be.domain.newsletter.service.NewsletterService; +import com.gachi.be.domain.user.entity.User; +import com.gachi.be.domain.user.repository.UserRepository; import com.gachi.be.file.service.S3FileService; import com.gachi.be.global.code.ErrorCode; import com.gachi.be.global.exception.BusinessException; @@ -60,20 +62,21 @@ public class NewsletterServiceImpl implements NewsletterService { private final CalendarEventRepository calendarEventRepository; private final ChecklistRepository checklistRepository; private final NewsletterPipelineService newsletterPipelineService; + private final UserRepository userRepository; private static final ZoneId KST = ZoneId.of("Asia/Seoul"); private static final int PAGE_SIZE = 20; /** * 가정통신문 파일을 S3에 업로드하고 newsletter 레코드를 PENDING 상태로 생성한다. * - *

처리 순서: 파일 유효성 검사 (형식: jpg/png/pdf, 크기: 최대 10MB) SHA-256 해시 계산 (중복 방지용) 중복 파일 확인 S3 업로드 → + * 처리 순서: 파일 유효성 검사 (형식: jpg/png/pdf, 크기: 최대 10MB) SHA-256 해시 계산 (중복 방지용) 중복 파일 확인 S3 업로드 → * file_key 획득 childId가 있으면 children 테이블에서 자녀 정보 조회 (스냅샷용) newsletter 레코드 DB 저장 (status=PENDING 으로 * 변경) AI 분석 파이프라인 비동기 트리거 -> Asyncㅏ로 별도 스레드에서 실행하게 함. */ @Override @Transactional public NewsletterUploadResponse upload( - Long userId, MultipartFile file, Long childId, String userLanguage) { + Long userId, MultipartFile file, Long childId) { // 파일 유효성 검사 validateFile(file); @@ -110,6 +113,10 @@ public NewsletterUploadResponse upload( // 자녀 미선택 시: (user_id + file_hash) 조합으로 확인 checkDuplicate(userId, childName, fileHash); + // 프론트 언어처리로만 이루어지지 않고 설정한 언어를 자동으로 사용하게 변경 + String userLanguage = resolveUserLanguage(userId); + log.debug("[Newsletter] 사용자 언어 설정 조회 완료. userId={}, language={}", userId, userLanguage); + // S3 업로드 - 가정통신문 전용 경로에 저장 + 디버깅 로그 추가해서 체크 String fileKey = s3FileService.uploadNewsletter(file).key(); log.debug("[Newsletter] S3 업로드 완료. userId={}, fileKey={}", userId, fileKey); @@ -118,6 +125,20 @@ public NewsletterUploadResponse upload( userId, childName, childGrade, childColor, fileKey, fileHash, userLanguage); } + // userId로 users 테이블에서 language_code를 조회하는 내부 메서드. + // 사용자를 찾지 못하면 기본값 'KO'를 반환한다 (방어 코드). + private String resolveUserLanguage(Long userId) { + return userRepository + .findById(userId) + .map(User::getLanguageCode) + .filter(lang -> lang != null && !lang.isBlank()) + .orElseGet( + () -> { + log.warn("[Newsletter] 사용자 언어 조회 실패. userId={}. 기본값 KO 사용.", userId); + return "KO"; + }); + } + @Transactional protected NewsletterUploadResponse saveAndTriggerPipeline( Long userId, @@ -451,7 +472,7 @@ private Set getRegisteredNewsletterIds(Long userId, List newsl /** * 파일 유효성 검사. * - *

TODO: 허용방식은 일단 이렇게만 지정해두고 테스트 해보면서 추가할 지 고려. 허용 형식: image/jpeg, image/png, application/pdf + * TODO: 허용방식은 일단 이렇게만 지정해두고 테스트 해보면서 추가할 지 고려. 허용 형식: image/jpeg, image/png, application/pdf * 최대 크기: 10MB */ private void validateFile(MultipartFile file) { From bc93db655815c6d001c872e013635e32b588ca66 Mon Sep 17 00:00:00 2001 From: minkyung Date: Mon, 25 May 2026 09:13:32 +0900 Subject: [PATCH 18/33] =?UTF-8?q?feat:=20controller=20=EC=BD=94=EB=93=9C?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/newsletter/api/controller/NewsletterController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/gachi/be/domain/newsletter/api/controller/NewsletterController.java b/src/main/java/com/gachi/be/domain/newsletter/api/controller/NewsletterController.java index 893d179..a8cc5de 100644 --- a/src/main/java/com/gachi/be/domain/newsletter/api/controller/NewsletterController.java +++ b/src/main/java/com/gachi/be/domain/newsletter/api/controller/NewsletterController.java @@ -71,7 +71,7 @@ public ApiResponse upload( @Pattern(regexp = "KO|US|ZH|VI", message = "language는 KO/US/ZH/VI 중 하나여야 합니다.") String language) { - NewsletterUploadResponse response = newsletterService.upload(userId, file, childId, language); + NewsletterUploadResponse response = newsletterService.upload(userId, file, childId); return ApiResponse.success(SuccessCode.NEWSLETTER_UPLOAD_SUCCESS, response); } From 85ab4ae03faed9ef51b4d29e54314352ffe1519a Mon Sep 17 00:00:00 2001 From: minkyung Date: Mon, 25 May 2026 09:36:33 +0900 Subject: [PATCH 19/33] =?UTF-8?q?feat:=20controller=20=EC=BD=94=EB=93=9C?= =?UTF-8?q?=20=EC=88=98=EC=A0=95-parameter=20=EB=B0=9B=EA=B8=B0=20?= =?UTF-8?q?=EB=8C=80=EC=8B=A0=EC=97=90=20=EB=B0=9B=EC=95=84=EC=98=A4?= =?UTF-8?q?=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../newsletter/api/controller/NewsletterController.java | 6 +----- ...dd_language_code.sql => V12__user_add_language_code.sql} | 0 2 files changed, 1 insertion(+), 5 deletions(-) rename src/main/resources/db/migration/{V11__user_add_language_code.sql => V12__user_add_language_code.sql} (100%) diff --git a/src/main/java/com/gachi/be/domain/newsletter/api/controller/NewsletterController.java b/src/main/java/com/gachi/be/domain/newsletter/api/controller/NewsletterController.java index a8cc5de..a712fda 100644 --- a/src/main/java/com/gachi/be/domain/newsletter/api/controller/NewsletterController.java +++ b/src/main/java/com/gachi/be/domain/newsletter/api/controller/NewsletterController.java @@ -65,11 +65,7 @@ public ApiResponse upload( schema = @Schema(type = "string", format = "binary"))) @RequestPart("file") MultipartFile file, - @Parameter(description = "연결할 자녀 ID. 미선택 시 생략") @RequestParam(required = false) Long childId, - @Parameter(description = "언어 코드 (KO/US/ZH/VI). 기본값 KO") - @RequestParam(defaultValue = "KO") - @Pattern(regexp = "KO|US|ZH|VI", message = "language는 KO/US/ZH/VI 중 하나여야 합니다.") - String language) { + @Parameter(description = "연결할 자녀 ID. 미선택 시 생략") @RequestParam(required = false) Long childId) { NewsletterUploadResponse response = newsletterService.upload(userId, file, childId); return ApiResponse.success(SuccessCode.NEWSLETTER_UPLOAD_SUCCESS, response); diff --git a/src/main/resources/db/migration/V11__user_add_language_code.sql b/src/main/resources/db/migration/V12__user_add_language_code.sql similarity index 100% rename from src/main/resources/db/migration/V11__user_add_language_code.sql rename to src/main/resources/db/migration/V12__user_add_language_code.sql From 19d9f115f29ae14b3ff4c68a29d49389c55df09d Mon Sep 17 00:00:00 2001 From: minkyung Date: Mon, 25 May 2026 09:43:17 +0900 Subject: [PATCH 20/33] =?UTF-8?q?feat:=20description=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../be/domain/user/api/controller/UserController.java | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/main/java/com/gachi/be/domain/user/api/controller/UserController.java b/src/main/java/com/gachi/be/domain/user/api/controller/UserController.java index b4b5d93..631e8fc 100644 --- a/src/main/java/com/gachi/be/domain/user/api/controller/UserController.java +++ b/src/main/java/com/gachi/be/domain/user/api/controller/UserController.java @@ -9,6 +9,7 @@ import com.gachi.be.domain.user.repository.UserRepository; import com.gachi.be.global.api.ApiResponse; import com.gachi.be.global.code.SuccessCode; +import io.swagger.v3.oas.annotations.Operation; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -42,6 +43,12 @@ public ApiResponse getMyInfo( } /** 언어 설정 변경 API */ + @Operation( + summary = "사용자 언어 설정 변경", + description = + """ + 마이페이지에서 내가 회원가입 시에 설정했떤 언어를 변경할 수 있습니다. 해당 언어를 변경한 뒤에 스캔된 문서들은 전부 해당 언어로 번역됩니다. + """) @PatchMapping("/me/language") @Transactional public ApiResponse changeLanguage( From 00f0644817ff849d4d9058c2ea9108839bd9a790 Mon Sep 17 00:00:00 2001 From: minkyung Date: Mon, 25 May 2026 13:44:13 +0900 Subject: [PATCH 21/33] =?UTF-8?q?feat:=20migration=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EC=97=AC=EB=B6=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../db/migration/V3__user_add_notification_enabled.sql | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 src/main/resources/db/migration/V3__user_add_notification_enabled.sql diff --git a/src/main/resources/db/migration/V3__user_add_notification_enabled.sql b/src/main/resources/db/migration/V3__user_add_notification_enabled.sql new file mode 100644 index 0000000..a2405b6 --- /dev/null +++ b/src/main/resources/db/migration/V3__user_add_notification_enabled.sql @@ -0,0 +1,2 @@ +ALTER TABLE users + ADD COLUMN IF NOT EXISTS notification_enabled BOOLEAN NOT NULL DEFAULT TRUE; From 306a0ac1f4e355547bd510a1af4ddb7049c22070 Mon Sep 17 00:00:00 2001 From: minkyung Date: Mon, 25 May 2026 13:54:11 +0900 Subject: [PATCH 22/33] =?UTF-8?q?feat:=20user=20entity=EC=97=90=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=EC=84=A4=EC=A0=95=20=EC=97=AC=EB=B6=80=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/gachi/be/domain/user/entity/User.java | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/main/java/com/gachi/be/domain/user/entity/User.java b/src/main/java/com/gachi/be/domain/user/entity/User.java index 00285fb..6ba951c 100644 --- a/src/main/java/com/gachi/be/domain/user/entity/User.java +++ b/src/main/java/com/gachi/be/domain/user/entity/User.java @@ -51,6 +51,9 @@ public class User { @Column(name = "language_code", nullable = false, length = 10) private String languageCode; + @Column(name = "notification_enabled", nullable = false) + private boolean notificationEnabled; + @Column(name = "deleted_at") private OffsetDateTime deletedAt; @@ -84,6 +87,7 @@ public User( String phoneNumber, UserStatus status, String languageCode, + Boolean notificationEnabled, OffsetDateTime emailVerifiedAt, OffsetDateTime consentAgreedAt, String consentVersion, @@ -96,6 +100,7 @@ public User( this.phoneNumber = phoneNumber; this.status = status; this.languageCode = languageCode != null ? languageCode : "KO"; + this.notificationEnabled = notificationEnabled != null ? notificationEnabled : true; this.emailVerifiedAt = emailVerifiedAt; this.consentAgreedAt = consentAgreedAt; this.consentVersion = consentVersion; @@ -112,6 +117,10 @@ public void updateLanguage(String languageCode) { this.languageCode = languageCode; } + public void updateNotificationEnabled(boolean notificationEnabled) { + this.notificationEnabled = notificationEnabled; + } + @PrePersist protected void onCreate() { LocalDateTime now = LocalDateTime.now(); From 678ca2fd133a0a81280183f9d79a2f4311ef4b40 Mon Sep 17 00:00:00 2001 From: minkyung Date: Mon, 25 May 2026 14:39:28 +0900 Subject: [PATCH 23/33] =?UTF-8?q?feat:=20=EC=95=8C=EB=A6=BC=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EB=B3=80=EA=B2=BD=20dto=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/dto/request/ChangeNotificationRequest.java | 9 +++++++++ .../be/domain/user/dto/response/UserMeResponse.java | 10 +++++++++- .../java/com/gachi/be/domain/user/entity/User.java | 6 +++--- 3 files changed, 21 insertions(+), 4 deletions(-) create mode 100644 src/main/java/com/gachi/be/domain/user/dto/request/ChangeNotificationRequest.java diff --git a/src/main/java/com/gachi/be/domain/user/dto/request/ChangeNotificationRequest.java b/src/main/java/com/gachi/be/domain/user/dto/request/ChangeNotificationRequest.java new file mode 100644 index 0000000..d032959 --- /dev/null +++ b/src/main/java/com/gachi/be/domain/user/dto/request/ChangeNotificationRequest.java @@ -0,0 +1,9 @@ +package com.gachi.be.domain.user.dto.request; + +import jakarta.validation.constraints.NotNull; + +/**알림 설정 변경 요청 DTO.*/ +public record ChangeNotificationRequest( + @NotNull(message = "notificationEnabled는 필수입니다.") + boolean notificationEnabled +) {} diff --git a/src/main/java/com/gachi/be/domain/user/dto/response/UserMeResponse.java b/src/main/java/com/gachi/be/domain/user/dto/response/UserMeResponse.java index 49bdfab..245acb0 100644 --- a/src/main/java/com/gachi/be/domain/user/dto/response/UserMeResponse.java +++ b/src/main/java/com/gachi/be/domain/user/dto/response/UserMeResponse.java @@ -1,5 +1,13 @@ package com.gachi.be.domain.user.dto.response; +import java.time.LocalDateTime; + /** 내 정보 조회 응답 DTO. */ public record UserMeResponse( - Long userId, String loginId, String email, String name, String phoneNumber) {} + Long userId, + String loginId, + String email, + String name, + String languageCode, + Boolean notificationEnabled, + LocalDateTime createdAt) {} diff --git a/src/main/java/com/gachi/be/domain/user/entity/User.java b/src/main/java/com/gachi/be/domain/user/entity/User.java index 6ba951c..d77fc72 100644 --- a/src/main/java/com/gachi/be/domain/user/entity/User.java +++ b/src/main/java/com/gachi/be/domain/user/entity/User.java @@ -117,9 +117,9 @@ public void updateLanguage(String languageCode) { this.languageCode = languageCode; } - public void updateNotificationEnabled(boolean notificationEnabled) { - this.notificationEnabled = notificationEnabled; - } + public void updateNotificationEnabled(boolean notificationEnabled) { + this.notificationEnabled = notificationEnabled; + } @PrePersist protected void onCreate() { From 8be2ce84aadbb5e6168db917b4252c4eed2796c3 Mon Sep 17 00:00:00 2001 From: minkyung Date: Mon, 25 May 2026 14:40:59 +0900 Subject: [PATCH 24/33] =?UTF-8?q?feat:=20=EB=82=B4=20=EC=A0=95=EB=B3=B4=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20controller=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gachi/be/domain/user/api/controller/UserController.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/gachi/be/domain/user/api/controller/UserController.java b/src/main/java/com/gachi/be/domain/user/api/controller/UserController.java index 631e8fc..8be27f6 100644 --- a/src/main/java/com/gachi/be/domain/user/api/controller/UserController.java +++ b/src/main/java/com/gachi/be/domain/user/api/controller/UserController.java @@ -39,7 +39,9 @@ public ApiResponse getMyInfo( user.getLoginId(), user.getEmail(), user.getName(), - user.getPhoneNumber())); + user.getLanguageCode(), + user.isNotificationEnabled(), + user.getCreatedAt())); } /** 언어 설정 변경 API */ From 3d0ebd33901a30a7dcecd2f4cc799d9120a5d460 Mon Sep 17 00:00:00 2001 From: minkyung Date: Mon, 25 May 2026 14:43:43 +0900 Subject: [PATCH 25/33] =?UTF-8?q?feat:=20migration=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80-=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../db/migration/V13__user_add_notification_enabled.sql | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 src/main/resources/db/migration/V13__user_add_notification_enabled.sql diff --git a/src/main/resources/db/migration/V13__user_add_notification_enabled.sql b/src/main/resources/db/migration/V13__user_add_notification_enabled.sql new file mode 100644 index 0000000..a2405b6 --- /dev/null +++ b/src/main/resources/db/migration/V13__user_add_notification_enabled.sql @@ -0,0 +1,2 @@ +ALTER TABLE users + ADD COLUMN IF NOT EXISTS notification_enabled BOOLEAN NOT NULL DEFAULT TRUE; From cb243b3bc7d2dea92c3afceb1affe1d3af472bb9 Mon Sep 17 00:00:00 2001 From: minkyung Date: Mon, 25 May 2026 14:54:39 +0900 Subject: [PATCH 26/33] =?UTF-8?q?feat:=20spotlessapply=20=20=EB=B0=98?= =?UTF-8?q?=EC=98=81=20=EB=B0=8F=20description=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/dto/request/SignupRequest.java | 6 +- .../api/controller/NewsletterController.java | 3 +- .../repository/NewsletterRepository.java | 21 +++--- .../newsletter/service/NewsletterService.java | 3 +- .../service/impl/NewsletterServiceImpl.java | 25 ++++--- .../user/api/controller/UserController.java | 69 ++++++++++--------- .../dto/request/ChangeLanguageRequest.java | 10 +-- .../request/ChangeNotificationRequest.java | 6 +- .../com/gachi/be/domain/user/entity/User.java | 10 +-- .../V3__user_add_notification_enabled.sql | 2 - 10 files changed, 76 insertions(+), 79 deletions(-) delete mode 100644 src/main/resources/db/migration/V3__user_add_notification_enabled.sql diff --git a/src/main/java/com/gachi/be/domain/auth/dto/request/SignupRequest.java b/src/main/java/com/gachi/be/domain/auth/dto/request/SignupRequest.java index 2a2ac8a..0c4dc52 100644 --- a/src/main/java/com/gachi/be/domain/auth/dto/request/SignupRequest.java +++ b/src/main/java/com/gachi/be/domain/auth/dto/request/SignupRequest.java @@ -18,7 +18,5 @@ public record SignupRequest( @Pattern(regexp = PhoneNumberValidation.REGEXP, message = PhoneNumberValidation.MESSAGE) String phoneNumber, @NotNull Boolean consentAgreed, - @Pattern( - regexp = "^(KO|US|ZH|VI)$", - message = "지원하지 않는 언어 코드입니다. KO, US, ZH, VI 중 하나여야 합니다.") - String languageCode) {} + @Pattern(regexp = "^(KO|US|ZH|VI)$", message = "지원하지 않는 언어 코드입니다. KO, US, ZH, VI 중 하나여야 합니다.") + String languageCode) {} diff --git a/src/main/java/com/gachi/be/domain/newsletter/api/controller/NewsletterController.java b/src/main/java/com/gachi/be/domain/newsletter/api/controller/NewsletterController.java index a712fda..7d3f98b 100644 --- a/src/main/java/com/gachi/be/domain/newsletter/api/controller/NewsletterController.java +++ b/src/main/java/com/gachi/be/domain/newsletter/api/controller/NewsletterController.java @@ -65,7 +65,8 @@ public ApiResponse upload( schema = @Schema(type = "string", format = "binary"))) @RequestPart("file") MultipartFile file, - @Parameter(description = "연결할 자녀 ID. 미선택 시 생략") @RequestParam(required = false) Long childId) { + @Parameter(description = "연결할 자녀 ID. 미선택 시 생략") @RequestParam(required = false) + Long childId) { NewsletterUploadResponse response = newsletterService.upload(userId, file, childId); return ApiResponse.success(SuccessCode.NEWSLETTER_UPLOAD_SUCCESS, response); diff --git a/src/main/java/com/gachi/be/domain/newsletter/repository/NewsletterRepository.java b/src/main/java/com/gachi/be/domain/newsletter/repository/NewsletterRepository.java index 65106f9..04ea7f8 100644 --- a/src/main/java/com/gachi/be/domain/newsletter/repository/NewsletterRepository.java +++ b/src/main/java/com/gachi/be/domain/newsletter/repository/NewsletterRepository.java @@ -1,11 +1,10 @@ package com.gachi.be.domain.newsletter.repository; import com.gachi.be.domain.newsletter.entity.Newsletter; +import com.gachi.be.domain.newsletter.entity.enums.NewsletterStatus; import java.time.OffsetDateTime; import java.util.List; import java.util.Optional; - -import com.gachi.be.domain.newsletter.entity.enums.NewsletterStatus; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; @@ -98,19 +97,17 @@ List findRecentByUserId( @Param("rangeStart") OffsetDateTime rangeStart, @Param("rangeEnd") OffsetDateTime rangeEnd); - - /** 언어 변경 시 진행중인 파이프라인 중단 처리용 쿼리*/ - @Modifying - @Query( - """ + /** 언어 변경 시 진행중인 파이프라인 중단 처리용 쿼리 */ + @Modifying + @Query( + """ UPDATE Newsletter n SET n.status = :failedStatus WHERE n.userId = :userId AND n.status IN :targetStatuses """) - int cancelInProgressByUserId( - @Param("userId") Long userId, - @Param("targetStatuses") List targetStatuses, - @Param("failedStatus") NewsletterStatus failedStatus); - + int cancelInProgressByUserId( + @Param("userId") Long userId, + @Param("targetStatuses") List targetStatuses, + @Param("failedStatus") NewsletterStatus failedStatus); } diff --git a/src/main/java/com/gachi/be/domain/newsletter/service/NewsletterService.java b/src/main/java/com/gachi/be/domain/newsletter/service/NewsletterService.java index dc16910..92b6e6e 100644 --- a/src/main/java/com/gachi/be/domain/newsletter/service/NewsletterService.java +++ b/src/main/java/com/gachi/be/domain/newsletter/service/NewsletterService.java @@ -13,8 +13,7 @@ public interface NewsletterService { * @param childId 연결할 자녀 ID (미선택 시 null) * @return newsletterId + status(PENDING) */ - NewsletterUploadResponse upload( - Long userId, MultipartFile file, Long childId); + NewsletterUploadResponse upload(Long userId, MultipartFile file, Long childId); /** * 가정통신문의 현재 분석 상태와 진행률을 조회. diff --git a/src/main/java/com/gachi/be/domain/newsletter/service/impl/NewsletterServiceImpl.java b/src/main/java/com/gachi/be/domain/newsletter/service/impl/NewsletterServiceImpl.java index d7fb697..321447a 100644 --- a/src/main/java/com/gachi/be/domain/newsletter/service/impl/NewsletterServiceImpl.java +++ b/src/main/java/com/gachi/be/domain/newsletter/service/impl/NewsletterServiceImpl.java @@ -69,14 +69,13 @@ public class NewsletterServiceImpl implements NewsletterService { /** * 가정통신문 파일을 S3에 업로드하고 newsletter 레코드를 PENDING 상태로 생성한다. * - * 처리 순서: 파일 유효성 검사 (형식: jpg/png/pdf, 크기: 최대 10MB) SHA-256 해시 계산 (중복 방지용) 중복 파일 확인 S3 업로드 → + *

처리 순서: 파일 유효성 검사 (형식: jpg/png/pdf, 크기: 최대 10MB) SHA-256 해시 계산 (중복 방지용) 중복 파일 확인 S3 업로드 → * file_key 획득 childId가 있으면 children 테이블에서 자녀 정보 조회 (스냅샷용) newsletter 레코드 DB 저장 (status=PENDING 으로 * 변경) AI 분석 파이프라인 비동기 트리거 -> Asyncㅏ로 별도 스레드에서 실행하게 함. */ @Override @Transactional - public NewsletterUploadResponse upload( - Long userId, MultipartFile file, Long childId) { + public NewsletterUploadResponse upload(Long userId, MultipartFile file, Long childId) { // 파일 유효성 검사 validateFile(file); @@ -128,15 +127,15 @@ public NewsletterUploadResponse upload( // userId로 users 테이블에서 language_code를 조회하는 내부 메서드. // 사용자를 찾지 못하면 기본값 'KO'를 반환한다 (방어 코드). private String resolveUserLanguage(Long userId) { - return userRepository - .findById(userId) - .map(User::getLanguageCode) - .filter(lang -> lang != null && !lang.isBlank()) - .orElseGet( - () -> { - log.warn("[Newsletter] 사용자 언어 조회 실패. userId={}. 기본값 KO 사용.", userId); - return "KO"; - }); + return userRepository + .findById(userId) + .map(User::getLanguageCode) + .filter(lang -> lang != null && !lang.isBlank()) + .orElseGet( + () -> { + log.warn("[Newsletter] 사용자 언어 조회 실패. userId={}. 기본값 KO 사용.", userId); + return "KO"; + }); } @Transactional @@ -472,7 +471,7 @@ private Set getRegisteredNewsletterIds(Long userId, List newsl /** * 파일 유효성 검사. * - * TODO: 허용방식은 일단 이렇게만 지정해두고 테스트 해보면서 추가할 지 고려. 허용 형식: image/jpeg, image/png, application/pdf + *

TODO: 허용방식은 일단 이렇게만 지정해두고 테스트 해보면서 추가할 지 고려. 허용 형식: image/jpeg, image/png, application/pdf * 최대 크기: 10MB */ private void validateFile(MultipartFile file) { diff --git a/src/main/java/com/gachi/be/domain/user/api/controller/UserController.java b/src/main/java/com/gachi/be/domain/user/api/controller/UserController.java index 8be27f6..59d975f 100644 --- a/src/main/java/com/gachi/be/domain/user/api/controller/UserController.java +++ b/src/main/java/com/gachi/be/domain/user/api/controller/UserController.java @@ -11,13 +11,12 @@ import com.gachi.be.global.code.SuccessCode; import io.swagger.v3.oas.annotations.Operation; import jakarta.validation.Valid; +import java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.*; -import java.util.List; - /** 로그인 사용자 기준 내 정보 조회 API를 제공한다. */ @Slf4j @RestController @@ -28,6 +27,14 @@ public class UserController { private final UserRepository userRepository; private final NewsletterRepository newsletterRepository; + @Operation( + summary = "사용자 내 정보 조회", + description = + """ + 마이페이지에서 사용자의 정보를 볼 수 있습니다. 이름, 닉네임, 등록일을 반환하고 + 추후 이메일을 변경할 경우를 고려 이메일도 반환합니다. + 알림설정 여부와 사용자의 언어까지 조회합니다. + """) @GetMapping("/me") public ApiResponse getMyInfo( @RequestHeader(value = "Authorization", required = false) String authorizationHeader) { @@ -44,41 +51,41 @@ public ApiResponse getMyInfo( user.getCreatedAt())); } - /** 언어 설정 변경 API */ - @Operation( - summary = "사용자 언어 설정 변경", - description = - """ + /** 언어 설정 변경 API */ + @Operation( + summary = "사용자 언어 설정 변경", + description = + """ 마이페이지에서 내가 회원가입 시에 설정했떤 언어를 변경할 수 있습니다. 해당 언어를 변경한 뒤에 스캔된 문서들은 전부 해당 언어로 번역됩니다. """) - @PatchMapping("/me/language") - @Transactional - public ApiResponse changeLanguage( - @RequestHeader(value = "Authorization", required = false) String authorizationHeader, - @RequestBody @Valid ChangeLanguageRequest request) { - - User user = authenticatedUserResolver.resolveActiveUser(authorizationHeader); + @PatchMapping("/me/language") + @Transactional + public ApiResponse changeLanguage( + @RequestHeader(value = "Authorization", required = false) String authorizationHeader, + @RequestBody @Valid ChangeLanguageRequest request) { - String previousLanguage = user.getLanguageCode(); - String newLanguage = request.languageCode(); + User user = authenticatedUserResolver.resolveActiveUser(authorizationHeader); - user.updateLanguage(newLanguage); - userRepository.save(user); + String previousLanguage = user.getLanguageCode(); + String newLanguage = request.languageCode(); - // 진행 중인 파이프라인 FAILED 처리 - int cancelledCount = - newsletterRepository.cancelInProgressByUserId( - user.getId(), - List.of(NewsletterStatus.PENDING, NewsletterStatus.PROCESSING), - NewsletterStatus.FAILED); + user.updateLanguage(newLanguage); + userRepository.save(user); - log.info( - "[Language] 언어 설정 변경. userId={}, {} -> {}, cancelledPipelines={}", + // 진행 중인 파이프라인 FAILED 처리 + int cancelledCount = + newsletterRepository.cancelInProgressByUserId( user.getId(), - previousLanguage, - newLanguage, - cancelledCount); + List.of(NewsletterStatus.PENDING, NewsletterStatus.PROCESSING), + NewsletterStatus.FAILED); + + log.info( + "[Language] 언어 설정 변경. userId={}, {} -> {}, cancelledPipelines={}", + user.getId(), + previousLanguage, + newLanguage, + cancelledCount); - return ApiResponse.success(SuccessCode.USER_LANGUAGE_UPDATED, null); - } + return ApiResponse.success(SuccessCode.USER_LANGUAGE_UPDATED, null); + } } diff --git a/src/main/java/com/gachi/be/domain/user/dto/request/ChangeLanguageRequest.java b/src/main/java/com/gachi/be/domain/user/dto/request/ChangeLanguageRequest.java index fa83e1a..95a174d 100644 --- a/src/main/java/com/gachi/be/domain/user/dto/request/ChangeLanguageRequest.java +++ b/src/main/java/com/gachi/be/domain/user/dto/request/ChangeLanguageRequest.java @@ -3,10 +3,10 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; -/**언어 설정 변경 요청 DTO.*/ +/** 언어 설정 변경 요청 DTO. */ public record ChangeLanguageRequest( @NotBlank(message = "언어 코드는 필수입니다.") - @Pattern( - regexp = "^(KO|US|ZH|VI)$", - message = "지원하지 않는 언어 코드입니다. KO, US, ZH, VI 중 하나여야 합니다.") - String languageCode) {} + @Pattern( + regexp = "^(KO|US|ZH|VI)$", + message = "지원하지 않는 언어 코드입니다. KO, US, ZH, VI 중 하나여야 합니다.") + String languageCode) {} diff --git a/src/main/java/com/gachi/be/domain/user/dto/request/ChangeNotificationRequest.java b/src/main/java/com/gachi/be/domain/user/dto/request/ChangeNotificationRequest.java index d032959..fa798c1 100644 --- a/src/main/java/com/gachi/be/domain/user/dto/request/ChangeNotificationRequest.java +++ b/src/main/java/com/gachi/be/domain/user/dto/request/ChangeNotificationRequest.java @@ -2,8 +2,6 @@ import jakarta.validation.constraints.NotNull; -/**알림 설정 변경 요청 DTO.*/ +/** 알림 설정 변경 요청 DTO. */ public record ChangeNotificationRequest( - @NotNull(message = "notificationEnabled는 필수입니다.") - boolean notificationEnabled -) {} + @NotNull(message = "notificationEnabled는 필수입니다.") boolean notificationEnabled) {} diff --git a/src/main/java/com/gachi/be/domain/user/entity/User.java b/src/main/java/com/gachi/be/domain/user/entity/User.java index d77fc72..056714a 100644 --- a/src/main/java/com/gachi/be/domain/user/entity/User.java +++ b/src/main/java/com/gachi/be/domain/user/entity/User.java @@ -114,11 +114,11 @@ public boolean isActive() { } public void updateLanguage(String languageCode) { - this.languageCode = languageCode; + this.languageCode = languageCode; } public void updateNotificationEnabled(boolean notificationEnabled) { - this.notificationEnabled = notificationEnabled; + this.notificationEnabled = notificationEnabled; } @PrePersist @@ -131,9 +131,9 @@ protected void onCreate() { if (status == null) { status = UserStatus.ACTIVE; } - if (languageCode == null) { - languageCode = "KO"; - } + if (languageCode == null) { + languageCode = "KO"; + } } @PreUpdate diff --git a/src/main/resources/db/migration/V3__user_add_notification_enabled.sql b/src/main/resources/db/migration/V3__user_add_notification_enabled.sql deleted file mode 100644 index a2405b6..0000000 --- a/src/main/resources/db/migration/V3__user_add_notification_enabled.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE users - ADD COLUMN IF NOT EXISTS notification_enabled BOOLEAN NOT NULL DEFAULT TRUE; From 5316f9813467a48a6b2a386466687de90ac681a0 Mon Sep 17 00:00:00 2001 From: minju Date: Mon, 25 May 2026 15:41:04 +0900 Subject: [PATCH 27/33] =?UTF-8?q?feat:=20AI=20=EB=B6=84=EC=84=9D=20?= =?UTF-8?q?=EA=B2=B0=EA=B3=BC=20=EA=B8=B0=EB=B0=98=20=EC=BA=98=EB=A6=B0?= =?UTF-8?q?=EB=8D=94=20=EB=AF=B8=EB=A6=AC=EB=B3=B4=EA=B8=B0=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/.env.example | 2 +- deploy/docker-compose.yml | 2 +- docs/newsletter-ai-analyze-integration.md | 4 +- .../service/CalendarPreviewRedisService.java | 6 +- .../pipeline/NewsletterAiAnalyzer.java | 72 ++++++++++++++++--- .../pipeline/NewsletterAiAnalyzerTest.java | 59 +++++++++++++++ 6 files changed, 130 insertions(+), 15 deletions(-) diff --git a/deploy/.env.example b/deploy/.env.example index 2c563b7..8d0f866 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -34,7 +34,7 @@ SPRING_MAIL_USERNAME= AI_SERVER_BASE_URL=http://ai:8000 OPENAI_ENABLED=false OPENAI_API_KEY= -OPENAI_MODEL=gpt-4o-mini +OPENAI_MODEL=gpt-4.1-mini OPENAI_BASE_URL=https://api.openai.com/v1 OPENAI_TIMEOUT_SECONDS=60 diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index ac9d5b5..c357c30 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -122,7 +122,7 @@ services: environment: OPENAI_ENABLED: ${OPENAI_ENABLED:-false} OPENAI_API_KEY: ${OPENAI_API_KEY:-} - OPENAI_MODEL: ${OPENAI_MODEL:-gpt-4o-mini} + OPENAI_MODEL: ${OPENAI_MODEL:-gpt-4.1-mini} OPENAI_BASE_URL: ${OPENAI_BASE_URL:-https://api.openai.com/v1} OPENAI_TIMEOUT_SECONDS: ${OPENAI_TIMEOUT_SECONDS:-60} healthcheck: diff --git a/docs/newsletter-ai-analyze-integration.md b/docs/newsletter-ai-analyze-integration.md index 45ff266..46043be 100644 --- a/docs/newsletter-ai-analyze-integration.md +++ b/docs/newsletter-ai-analyze-integration.md @@ -17,10 +17,12 @@ - `summary`: AI 응답 요약을 우선 저장한다. 빈 값이면 기존 BE fallback 요약을 사용한다. - `items`: `title`이 비어 있지 않은 항목만 checklist로 저장한다. - `datetime`: TODO 저장 시 `targetDate`, `targetDateLabel` 생성에 사용한다. 파싱 실패 시 날짜 없이 저장한다. -- `dateStatus`: 현재 저장 모델에는 별도 컬럼이 없으므로 직접 저장하지 않는다. 날짜 확정 여부가 필요한 후속 정책은 calendar preview 저장 구조와 함께 별도 이슈에서 다룬다. +- `dateStatus`: checklist 저장 모델에는 별도 컬럼이 없으므로 직접 저장하지 않는다. 단, `confirmed`이고 `datetime`이 파싱 가능한 항목은 calendar preview 생성 대상으로 사용한다. +- `calendar preview`: AI 분석으로 저장된 checklist/TODO ID와 확정 날짜를 묶어 Redis preview에 저장한다. `ambiguous`, `missing`, 날짜 파싱 실패 항목은 사용자가 확인해야 하므로 preview에 자동 포함하지 않는다. ## 호환 정책 - AI 서버의 `extract-items` baseline API는 유지하지만, BE 파이프라인은 `analyze`를 우선 사용한다. - AI 서버가 `items`를 빈 배열로 반환해도 `title`, `summary`는 저장할 수 있다. +- 확정 날짜가 없는 분석 결과는 기존 Redis preview를 비워, 재분석 후 오래된 일정 후보가 남지 않게 한다. - AI 서버 장애 시에는 OCR/번역/dateCandidates 스냅샷을 보존하고 newsletter 상태를 `FAILED`로 둔다. diff --git a/src/main/java/com/gachi/be/domain/calendar/service/CalendarPreviewRedisService.java b/src/main/java/com/gachi/be/domain/calendar/service/CalendarPreviewRedisService.java index 515f40d..31a81ba 100644 --- a/src/main/java/com/gachi/be/domain/calendar/service/CalendarPreviewRedisService.java +++ b/src/main/java/com/gachi/be/domain/calendar/service/CalendarPreviewRedisService.java @@ -13,9 +13,9 @@ /** * 캘린더 일정 등록 플로우에서 사용하는 Redis 임시 데이터 관리 서비스. * - *

흐름 요약: 1. 가정통신문 AI 분석 완료 → AI 파이프라인에서 Redis에 preview 데이터 저장 (추후 연결) 2. GET /calendar/preview → - * Redis에서 읽어 팝업에 표시 3. PATCH /calendar/dates → Redis에서 날짜 수정 후 다시 저장 4. POST /calendar → Redis 데이터 - * 기반으로 calendar_events insert → Redis 키 삭제 + *

흐름 요약: 1. 가정통신문 AI 분석 완료 → AI 파이프라인에서 Redis에 preview 데이터 저장 2. GET /calendar/preview → Redis에서 + * 읽어 팝업에 표시 3. PATCH /calendar/dates → Redis에서 날짜 수정 후 다시 저장 4. POST /calendar → Redis 데이터 기반으로 + * calendar_events insert → Redis 키 삭제 * *

Redis 키 형식: newsletter:preview:{newsletterId} - TTL: 1시간 (사용자가 팝업을 열고 이탈해도 자동 만료) */ diff --git a/src/main/java/com/gachi/be/domain/newsletter/pipeline/NewsletterAiAnalyzer.java b/src/main/java/com/gachi/be/domain/newsletter/pipeline/NewsletterAiAnalyzer.java index f8349fd..1677cad 100644 --- a/src/main/java/com/gachi/be/domain/newsletter/pipeline/NewsletterAiAnalyzer.java +++ b/src/main/java/com/gachi/be/domain/newsletter/pipeline/NewsletterAiAnalyzer.java @@ -1,5 +1,7 @@ package com.gachi.be.domain.newsletter.pipeline; +import com.gachi.be.domain.calendar.dto.CalendarPreviewEvent; +import com.gachi.be.domain.calendar.service.CalendarPreviewRedisService; import com.gachi.be.domain.checklist.entity.Checklist; import com.gachi.be.domain.checklist.entity.enums.ChecklistType; import com.gachi.be.domain.checklist.repository.ChecklistRepository; @@ -9,6 +11,7 @@ import com.gachi.be.domain.newsletter.repository.NewsletterRepository; import java.time.LocalDate; import java.time.format.DateTimeParseException; +import java.util.ArrayList; import java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -26,6 +29,7 @@ public class NewsletterAiAnalyzer { private final AiNewsletterClient aiNewsletterClient; private final ChecklistRepository checklistRepository; + private final CalendarPreviewRedisService calendarPreviewRedisService; private final NewsletterRepository newsletterRepository; public AiAnalysisResult analyze( @@ -45,7 +49,9 @@ public AiAnalysisResult analyze( originalText, translatedText, language, newsletter.getDateCandidates()); List items = analysisResponse.items(); - saveExtractedItems(newsletterId, newsletter.getUserId(), items); + List savedItems = + saveExtractedItems(newsletterId, newsletter.getUserId(), items); + saveCalendarPreview(newsletterId, savedItems); String title = normalizeTitle(analysisResponse.title(), originalText); String summary = normalizeSummary(analysisResponse.summary(), translatedText, originalText); @@ -55,25 +61,31 @@ public AiAnalysisResult analyze( return new AiAnalysisResult(title, summary); } - private void saveExtractedItems(Long newsletterId, Long userId, List items) { + private List saveExtractedItems( + Long newsletterId, Long userId, List items) { if (items == null || items.isEmpty()) { log.warn("[AiAnalyzer] AI 서버 항목 추출 결과 없음. newsletterId={}", newsletterId); - return; + return List.of(); } + List validItems = + items.stream().filter(item -> item.title() != null && !item.title().isBlank()).toList(); List entities = - items.stream() - .filter(item -> item.title() != null && !item.title().isBlank()) - .map(item -> toChecklist(newsletterId, userId, item)) - .toList(); + validItems.stream().map(item -> toChecklist(newsletterId, userId, item)).toList(); if (entities.isEmpty()) { log.warn("[AiAnalyzer] 저장 가능한 항목 없음. newsletterId={}", newsletterId); - return; + return List.of(); } - checklistRepository.saveAll(entities); + List savedEntities = checklistRepository.saveAll(entities); log.debug("[AiAnalyzer] AI 서버 추출 항목 {}개 저장 완료.", entities.size()); + + List savedItems = new ArrayList<>(); + for (int i = 0; i < validItems.size(); i++) { + savedItems.add(new SavedExtractedItem(validItems.get(i), savedEntities.get(i))); + } + return savedItems; } private Checklist toChecklist(Long newsletterId, Long userId, ExtractedItem item) { @@ -111,6 +123,46 @@ private LocalDate parseTargetDate(String value) { } } + private void saveCalendarPreview(Long newsletterId, List savedItems) { + List previewEvents = new ArrayList<>(); + + for (SavedExtractedItem savedItem : savedItems) { + ExtractedItem item = savedItem.item(); + String extractedDate = normalizePreviewDate(item.datetime()); + if (!"confirmed".equalsIgnoreCase(item.dateStatus()) || extractedDate == null) { + continue; + } + + previewEvents.add( + new CalendarPreviewEvent( + "ai_evt_" + (previewEvents.size() + 1), + trimToMax(item.title().trim(), CHECKLIST_TEXT_MAX_LENGTH), + extractedDate, + true, + checklistIdList(savedItem.checklist()))); + } + + if (previewEvents.isEmpty()) { + // 재분석 결과에 확정 날짜가 없으면 이전 미리보기 데이터가 남아 잘못 등록될 수 있어 비운다. + calendarPreviewRedisService.deletePreview(newsletterId); + log.debug("[AiAnalyzer] 캘린더 preview 생성 대상 없음. newsletterId={}", newsletterId); + return; + } + + calendarPreviewRedisService.savePreview(newsletterId, previewEvents); + log.debug( + "[AiAnalyzer] 캘린더 preview {}개 저장 완료. newsletterId={}", previewEvents.size(), newsletterId); + } + + private String normalizePreviewDate(String value) { + LocalDate targetDate = parseTargetDate(value); + return targetDate != null ? targetDate.toString() : null; + } + + private List checklistIdList(Checklist checklist) { + return checklist.getId() != null ? List.of(checklist.getId()) : List.of(); + } + private String normalizeTitle(String aiTitle, String originalText) { if (aiTitle != null && !aiTitle.isBlank()) { return trimToMax(compact(aiTitle), TITLE_MAX_LENGTH); @@ -168,5 +220,7 @@ private String trimToMax(String value, int maxLength) { return value.substring(0, maxLength - 3).stripTrailing() + "..."; } + private record SavedExtractedItem(ExtractedItem item, Checklist checklist) {} + public record AiAnalysisResult(String title, String summary) {} } diff --git a/src/test/java/com/gachi/be/domain/newsletter/pipeline/NewsletterAiAnalyzerTest.java b/src/test/java/com/gachi/be/domain/newsletter/pipeline/NewsletterAiAnalyzerTest.java index c1a150c..e349357 100644 --- a/src/test/java/com/gachi/be/domain/newsletter/pipeline/NewsletterAiAnalyzerTest.java +++ b/src/test/java/com/gachi/be/domain/newsletter/pipeline/NewsletterAiAnalyzerTest.java @@ -2,9 +2,12 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import com.gachi.be.domain.calendar.dto.CalendarPreviewEvent; +import com.gachi.be.domain.calendar.service.CalendarPreviewRedisService; import com.gachi.be.domain.checklist.entity.Checklist; import com.gachi.be.domain.checklist.repository.ChecklistRepository; import com.gachi.be.domain.newsletter.entity.Newsletter; @@ -24,15 +27,18 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; @ExtendWith(MockitoExtension.class) class NewsletterAiAnalyzerTest { @Mock private AiNewsletterClient aiNewsletterClient; @Mock private ChecklistRepository checklistRepository; + @Mock private CalendarPreviewRedisService calendarPreviewRedisService; @Mock private NewsletterRepository newsletterRepository; @Captor private ArgumentCaptor> checklistsCaptor; + @Captor private ArgumentCaptor> previewEventsCaptor; @InjectMocks private NewsletterAiAnalyzer newsletterAiAnalyzer; @@ -64,6 +70,13 @@ void analyzeUsesAiTitleSummaryAndSavesItems() { when(newsletterRepository.findById(newsletterId)).thenReturn(Optional.of(newsletter)); when(aiNewsletterClient.analyze("원문", "번역문", "KO", List.of())) .thenReturn(new AnalysisResponse("AI 제목", "AI 요약", List.of(item), Map.of())); + when(checklistRepository.saveAll(anyList())) + .thenAnswer( + invocation -> { + List checklists = invocation.getArgument(0); + ReflectionTestUtils.setField(checklists.get(0), "id", 501L); + return checklists; + }); AiAnalysisResult result = newsletterAiAnalyzer.analyze(newsletterId, "원문", "번역문", "KO"); @@ -80,6 +93,52 @@ void analyzeUsesAiTitleSummaryAndSavesItems() { assertThat(savedItems.get(0).getDetail()).isEqualTo("체육복을 준비해 주세요."); assertThat(savedItems.get(0).getTargetDate()).isEqualTo(LocalDate.parse("2026-05-25")); assertThat(savedItems.get(0).getTargetDateLabel()).isEqualTo("5월 25일"); + + verify(calendarPreviewRedisService) + .savePreview(eq(newsletterId), previewEventsCaptor.capture()); + List previewEvents = previewEventsCaptor.getValue(); + assertThat(previewEvents).hasSize(1); + assertThat(previewEvents.get(0).tempEventId()).isEqualTo("ai_evt_1"); + assertThat(previewEvents.get(0).title()).isEqualTo("준비물 확인"); + assertThat(previewEvents.get(0).extractedDate()).isEqualTo("2026-05-25"); + assertThat(previewEvents.get(0).isDateExtracted()).isTrue(); + assertThat(previewEvents.get(0).checklistIds()).containsExactly(501L); + } + + @Test + void analyzeSkipsCalendarPreviewWhenDateIsNotConfirmed() { + Long newsletterId = 14L; + Newsletter newsletter = + Newsletter.builder() + .userId(24L) + .fileKey("newsletter.pdf") + .fileHash("hash") + .status(NewsletterStatus.PROCESSING) + .language("KO") + .build(); + + ExtractedItem item = + new ExtractedItem( + "deadline", + "신청서 제출", + null, + "2026-05-25", + "Asia/Seoul", + "체험학습 3일 전까지 신청서를 제출해 주세요.", + "ambiguous", + 0.7, + true, + "체험학습 날짜를 확인해 주세요."); + + when(newsletterRepository.findById(newsletterId)).thenReturn(Optional.of(newsletter)); + when(aiNewsletterClient.analyze("원문", "번역문", "KO", List.of())) + .thenReturn(new AnalysisResponse("AI 제목", "AI 요약", List.of(item), Map.of())); + when(checklistRepository.saveAll(anyList())) + .thenAnswer(invocation -> invocation.getArgument(0)); + + newsletterAiAnalyzer.analyze(newsletterId, "원문", "번역문", "KO"); + + verify(calendarPreviewRedisService).deletePreview(newsletterId); } @Test From 812a4ba47143302b1a7f7be5f5c2c69c42a25192 Mon Sep 17 00:00:00 2001 From: minju Date: Mon, 25 May 2026 16:04:10 +0900 Subject: [PATCH 28/33] =?UTF-8?q?fix:=20=EC=BA=98=EB=A6=B0=EB=8D=94=20?= =?UTF-8?q?=EB=AF=B8=EB=A6=AC=EB=B3=B4=EA=B8=B0=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=20=EA=B2=A9=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pipeline/NewsletterAiAnalyzer.java | 7 ++- .../pipeline/NewsletterAiAnalyzerTest.java | 47 +++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/gachi/be/domain/newsletter/pipeline/NewsletterAiAnalyzer.java b/src/main/java/com/gachi/be/domain/newsletter/pipeline/NewsletterAiAnalyzer.java index 1677cad..ca25419 100644 --- a/src/main/java/com/gachi/be/domain/newsletter/pipeline/NewsletterAiAnalyzer.java +++ b/src/main/java/com/gachi/be/domain/newsletter/pipeline/NewsletterAiAnalyzer.java @@ -51,7 +51,12 @@ public AiAnalysisResult analyze( List savedItems = saveExtractedItems(newsletterId, newsletter.getUserId(), items); - saveCalendarPreview(newsletterId, savedItems); + try { + saveCalendarPreview(newsletterId, savedItems); + } catch (RuntimeException e) { + log.warn( + "[AiAnalyzer] 캘린더 preview 저장 실패. 분석 결과 저장은 계속 진행합니다. newsletterId={}", newsletterId, e); + } String title = normalizeTitle(analysisResponse.title(), originalText); String summary = normalizeSummary(analysisResponse.summary(), translatedText, originalText); diff --git a/src/test/java/com/gachi/be/domain/newsletter/pipeline/NewsletterAiAnalyzerTest.java b/src/test/java/com/gachi/be/domain/newsletter/pipeline/NewsletterAiAnalyzerTest.java index e349357..0d98593 100644 --- a/src/test/java/com/gachi/be/domain/newsletter/pipeline/NewsletterAiAnalyzerTest.java +++ b/src/test/java/com/gachi/be/domain/newsletter/pipeline/NewsletterAiAnalyzerTest.java @@ -3,6 +3,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -141,6 +142,52 @@ void analyzeSkipsCalendarPreviewWhenDateIsNotConfirmed() { verify(calendarPreviewRedisService).deletePreview(newsletterId); } + @Test + void analyzeContinuesWhenCalendarPreviewSaveFails() { + Long newsletterId = 15L; + Newsletter newsletter = + Newsletter.builder() + .userId(25L) + .fileKey("newsletter.pdf") + .fileHash("hash") + .status(NewsletterStatus.PROCESSING) + .language("KO") + .build(); + + ExtractedItem item = + new ExtractedItem( + "schedule", + "현장학습 참석", + null, + "2026-05-25", + "Asia/Seoul", + "5월 25일 현장학습에 참석합니다.", + "confirmed", + 0.9, + false, + null); + + when(newsletterRepository.findById(newsletterId)).thenReturn(Optional.of(newsletter)); + when(aiNewsletterClient.analyze("원문", "번역문", "KO", List.of())) + .thenReturn(new AnalysisResponse("AI 제목", "AI 요약", List.of(item), Map.of())); + when(checklistRepository.saveAll(anyList())) + .thenAnswer( + invocation -> { + List checklists = invocation.getArgument(0); + ReflectionTestUtils.setField(checklists.get(0), "id", 502L); + return checklists; + }); + doThrow(new RuntimeException("Redis down")) + .when(calendarPreviewRedisService) + .savePreview(eq(newsletterId), anyList()); + + AiAnalysisResult result = newsletterAiAnalyzer.analyze(newsletterId, "원문", "번역문", "KO"); + + assertThat(result.title()).isEqualTo("AI 제목"); + assertThat(result.summary()).isEqualTo("AI 요약"); + verify(checklistRepository).saveAll(anyList()); + } + @Test void analyzeFallsBackWhenAiTitleSummaryAreBlank() { Long newsletterId = 11L; From 807188eac118f3edbca18aa80d12136e7ddd61a6 Mon Sep 17 00:00:00 2001 From: minkyung Date: Mon, 25 May 2026 22:35:29 +0900 Subject: [PATCH 29/33] =?UTF-8?q?feat:=20=EC=BD=94=EB=93=9C=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gachi/be/domain/auth/dto/request/SignupRequest.java | 1 + .../be/domain/newsletter/pipeline/AiNewsletterClient.java | 6 ++++-- .../newsletter/repository/NewsletterRepository.java | 6 ++++-- .../be/domain/user/api/controller/UserController.java | 8 +++++++- src/main/java/com/gachi/be/domain/user/entity/User.java | 5 ++++- 5 files changed, 20 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/gachi/be/domain/auth/dto/request/SignupRequest.java b/src/main/java/com/gachi/be/domain/auth/dto/request/SignupRequest.java index 0c4dc52..6ac80b7 100644 --- a/src/main/java/com/gachi/be/domain/auth/dto/request/SignupRequest.java +++ b/src/main/java/com/gachi/be/domain/auth/dto/request/SignupRequest.java @@ -18,5 +18,6 @@ public record SignupRequest( @Pattern(regexp = PhoneNumberValidation.REGEXP, message = PhoneNumberValidation.MESSAGE) String phoneNumber, @NotNull Boolean consentAgreed, + @NotNull(message = "languageCode는 필수입니다.") @Pattern(regexp = "^(KO|US|ZH|VI)$", message = "지원하지 않는 언어 코드입니다. KO, US, ZH, VI 중 하나여야 합니다.") String languageCode) {} diff --git a/src/main/java/com/gachi/be/domain/newsletter/pipeline/AiNewsletterClient.java b/src/main/java/com/gachi/be/domain/newsletter/pipeline/AiNewsletterClient.java index 89f791b..a4dffd7 100644 --- a/src/main/java/com/gachi/be/domain/newsletter/pipeline/AiNewsletterClient.java +++ b/src/main/java/com/gachi/be/domain/newsletter/pipeline/AiNewsletterClient.java @@ -55,6 +55,7 @@ public AnalysisResponse analyze( LocalDate.now(DEFAULT_ZONE), DEFAULT_ZONE.getId(), toDateCandidateRequests(dateCandidates))); + log.info("[AiNewsletterClient] 요청 body: {}", requestBody); HttpRequest request = HttpRequest.newBuilder() @@ -69,9 +70,10 @@ public AnalysisResponse analyze( if (response.statusCode() < 200 || response.statusCode() >= 300) { log.error( - "[AiNewsletterClient] AI 서버 분석 실패. status={}, bodyLength={}", + "[AiNewsletterClient] AI 서버 분석 실패. status={}, body={}", response.statusCode(), - response.body() != null ? response.body().length() : 0); + response.body()); + //response.body() != null ? response.body().length() : 0); throw new ExternalApiException( ErrorCode.EXTERNAL_API_ERROR, "AI 서버 분석 실패. status=" + response.statusCode()); } diff --git a/src/main/java/com/gachi/be/domain/newsletter/repository/NewsletterRepository.java b/src/main/java/com/gachi/be/domain/newsletter/repository/NewsletterRepository.java index 04ea7f8..8cc81ba 100644 --- a/src/main/java/com/gachi/be/domain/newsletter/repository/NewsletterRepository.java +++ b/src/main/java/com/gachi/be/domain/newsletter/repository/NewsletterRepository.java @@ -102,12 +102,14 @@ List findRecentByUserId( @Query( """ UPDATE Newsletter n - SET n.status = :failedStatus + SET n.status = :failedStatus, + n.language = :newLanguage WHERE n.userId = :userId AND n.status IN :targetStatuses """) int cancelInProgressByUserId( @Param("userId") Long userId, @Param("targetStatuses") List targetStatuses, - @Param("failedStatus") NewsletterStatus failedStatus); + @Param("failedStatus") NewsletterStatus failedStatus, + @Param("newLanguage") String newLanguage); } diff --git a/src/main/java/com/gachi/be/domain/user/api/controller/UserController.java b/src/main/java/com/gachi/be/domain/user/api/controller/UserController.java index 59d975f..df7b490 100644 --- a/src/main/java/com/gachi/be/domain/user/api/controller/UserController.java +++ b/src/main/java/com/gachi/be/domain/user/api/controller/UserController.java @@ -12,6 +12,8 @@ import io.swagger.v3.oas.annotations.Operation; import jakarta.validation.Valid; import java.util.List; +import java.util.Objects; + import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.transaction.annotation.Transactional; @@ -69,6 +71,10 @@ public ApiResponse changeLanguage( String previousLanguage = user.getLanguageCode(); String newLanguage = request.languageCode(); + if (Objects.equals(previousLanguage, newLanguage)) { + return ApiResponse.success(SuccessCode.USER_LANGUAGE_UPDATED, null); + } + user.updateLanguage(newLanguage); userRepository.save(user); @@ -77,7 +83,7 @@ public ApiResponse changeLanguage( newsletterRepository.cancelInProgressByUserId( user.getId(), List.of(NewsletterStatus.PENDING, NewsletterStatus.PROCESSING), - NewsletterStatus.FAILED); + NewsletterStatus.FAILED, request.languageCode()); log.info( "[Language] 언어 설정 변경. userId={}, {} -> {}, cancelledPipelines={}", diff --git a/src/main/java/com/gachi/be/domain/user/entity/User.java b/src/main/java/com/gachi/be/domain/user/entity/User.java index 056714a..7830992 100644 --- a/src/main/java/com/gachi/be/domain/user/entity/User.java +++ b/src/main/java/com/gachi/be/domain/user/entity/User.java @@ -114,7 +114,10 @@ public boolean isActive() { } public void updateLanguage(String languageCode) { - this.languageCode = languageCode; + if (languageCode == null || languageCode.isBlank()) { + throw new IllegalArgumentException("languageCode는 비어 있을 수 없습니다."); + } + this.languageCode = languageCode.trim().toUpperCase(); } public void updateNotificationEnabled(boolean notificationEnabled) { From ca1b3cc880d2d37cf3425775d34c340e4586b929 Mon Sep 17 00:00:00 2001 From: minkyung Date: Mon, 25 May 2026 23:25:02 +0900 Subject: [PATCH 30/33] =?UTF-8?q?fix:=20HttpClient=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../be/domain/auth/dto/request/SignupRequest.java | 4 +++- .../domain/newsletter/pipeline/AiNewsletterClient.java | 4 +++- .../be/domain/user/api/controller/UserController.java | 10 +++++----- .../java/com/gachi/be/domain/user/entity/User.java | 8 ++++---- 4 files changed, 15 insertions(+), 11 deletions(-) diff --git a/src/main/java/com/gachi/be/domain/auth/dto/request/SignupRequest.java b/src/main/java/com/gachi/be/domain/auth/dto/request/SignupRequest.java index 6ac80b7..31de87c 100644 --- a/src/main/java/com/gachi/be/domain/auth/dto/request/SignupRequest.java +++ b/src/main/java/com/gachi/be/domain/auth/dto/request/SignupRequest.java @@ -19,5 +19,7 @@ public record SignupRequest( String phoneNumber, @NotNull Boolean consentAgreed, @NotNull(message = "languageCode는 필수입니다.") - @Pattern(regexp = "^(KO|US|ZH|VI)$", message = "지원하지 않는 언어 코드입니다. KO, US, ZH, VI 중 하나여야 합니다.") + @Pattern( + regexp = "^(KO|US|ZH|VI)$", + message = "지원하지 않는 언어 코드입니다. KO, US, ZH, VI 중 하나여야 합니다.") String languageCode) {} diff --git a/src/main/java/com/gachi/be/domain/newsletter/pipeline/AiNewsletterClient.java b/src/main/java/com/gachi/be/domain/newsletter/pipeline/AiNewsletterClient.java index a4dffd7..4ec5791 100644 --- a/src/main/java/com/gachi/be/domain/newsletter/pipeline/AiNewsletterClient.java +++ b/src/main/java/com/gachi/be/domain/newsletter/pipeline/AiNewsletterClient.java @@ -37,6 +37,7 @@ public AiNewsletterClient(AiServerProperties aiServerProperties, ObjectMapper ob this.httpClient = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(aiServerProperties.getConnectTimeoutSeconds())) + .version(HttpClient.Version.HTTP_1_1) .build(); } @@ -61,6 +62,7 @@ public AnalysisResponse analyze( HttpRequest.newBuilder() .uri(URI.create(normalizedBaseUrl() + ANALYZE_PATH)) .header("Content-Type", "application/json") + .header("Accept", "application/json") .timeout(Duration.ofSeconds(aiServerProperties.getReadTimeoutSeconds())) .POST(HttpRequest.BodyPublishers.ofString(requestBody)) .build(); @@ -73,7 +75,7 @@ public AnalysisResponse analyze( "[AiNewsletterClient] AI 서버 분석 실패. status={}, body={}", response.statusCode(), response.body()); - //response.body() != null ? response.body().length() : 0); + // response.body() != null ? response.body().length() : 0); throw new ExternalApiException( ErrorCode.EXTERNAL_API_ERROR, "AI 서버 분석 실패. status=" + response.statusCode()); } diff --git a/src/main/java/com/gachi/be/domain/user/api/controller/UserController.java b/src/main/java/com/gachi/be/domain/user/api/controller/UserController.java index df7b490..1c1658d 100644 --- a/src/main/java/com/gachi/be/domain/user/api/controller/UserController.java +++ b/src/main/java/com/gachi/be/domain/user/api/controller/UserController.java @@ -13,7 +13,6 @@ import jakarta.validation.Valid; import java.util.List; import java.util.Objects; - import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.transaction.annotation.Transactional; @@ -71,9 +70,9 @@ public ApiResponse changeLanguage( String previousLanguage = user.getLanguageCode(); String newLanguage = request.languageCode(); - if (Objects.equals(previousLanguage, newLanguage)) { - return ApiResponse.success(SuccessCode.USER_LANGUAGE_UPDATED, null); - } + if (Objects.equals(previousLanguage, newLanguage)) { + return ApiResponse.success(SuccessCode.USER_LANGUAGE_UPDATED, null); + } user.updateLanguage(newLanguage); userRepository.save(user); @@ -83,7 +82,8 @@ public ApiResponse changeLanguage( newsletterRepository.cancelInProgressByUserId( user.getId(), List.of(NewsletterStatus.PENDING, NewsletterStatus.PROCESSING), - NewsletterStatus.FAILED, request.languageCode()); + NewsletterStatus.FAILED, + request.languageCode()); log.info( "[Language] 언어 설정 변경. userId={}, {} -> {}, cancelledPipelines={}", diff --git a/src/main/java/com/gachi/be/domain/user/entity/User.java b/src/main/java/com/gachi/be/domain/user/entity/User.java index 7830992..3ee8483 100644 --- a/src/main/java/com/gachi/be/domain/user/entity/User.java +++ b/src/main/java/com/gachi/be/domain/user/entity/User.java @@ -114,10 +114,10 @@ public boolean isActive() { } public void updateLanguage(String languageCode) { - if (languageCode == null || languageCode.isBlank()) { - throw new IllegalArgumentException("languageCode는 비어 있을 수 없습니다."); - } - this.languageCode = languageCode.trim().toUpperCase(); + if (languageCode == null || languageCode.isBlank()) { + throw new IllegalArgumentException("languageCode는 비어 있을 수 없습니다."); + } + this.languageCode = languageCode.trim().toUpperCase(); } public void updateNotificationEnabled(boolean notificationEnabled) { From 922291b0fbeb171c14ec8fb403678f0d95a2b6d5 Mon Sep 17 00:00:00 2001 From: minkyung Date: Mon, 25 May 2026 23:47:17 +0900 Subject: [PATCH 31/33] =?UTF-8?q?feat:=20AI=20=EB=B2=88=EC=97=AD=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=EB=A1=9C=20=EC=A7=84=ED=96=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pipeline/NewsletterAiAnalyzer.java | 90 +++++++++++++++---- 1 file changed, 71 insertions(+), 19 deletions(-) diff --git a/src/main/java/com/gachi/be/domain/newsletter/pipeline/NewsletterAiAnalyzer.java b/src/main/java/com/gachi/be/domain/newsletter/pipeline/NewsletterAiAnalyzer.java index ca25419..e7646a7 100644 --- a/src/main/java/com/gachi/be/domain/newsletter/pipeline/NewsletterAiAnalyzer.java +++ b/src/main/java/com/gachi/be/domain/newsletter/pipeline/NewsletterAiAnalyzer.java @@ -31,6 +31,7 @@ public class NewsletterAiAnalyzer { private final ChecklistRepository checklistRepository; private final CalendarPreviewRedisService calendarPreviewRedisService; private final NewsletterRepository newsletterRepository; + private final PapagoTranslateClient papagoTranslateClient; public AiAnalysisResult analyze( Long newsletterId, String originalText, String translatedText, String language) { @@ -50,15 +51,17 @@ public AiAnalysisResult analyze( List items = analysisResponse.items(); List savedItems = - saveExtractedItems(newsletterId, newsletter.getUserId(), items); + saveExtractedItems(newsletterId, newsletter.getUserId(), items, language); + try { - saveCalendarPreview(newsletterId, savedItems); + saveCalendarPreview(newsletterId, savedItems); } catch (RuntimeException e) { - log.warn( - "[AiAnalyzer] 캘린더 preview 저장 실패. 분석 결과 저장은 계속 진행합니다. newsletterId={}", newsletterId, e); + log.warn( + "[AiAnalyzer] 캘린더 preview 저장 실패. 분석 결과 저장은 계속 진행합니다. newsletterId={}", newsletterId, e); } - String title = normalizeTitle(analysisResponse.title(), originalText); + String rawTitle = normalizeTitle(analysisResponse.title(), originalText); + String title = translateIfNeeded(rawTitle, language, "title", newsletterId); String summary = normalizeSummary(analysisResponse.summary(), translatedText, originalText); log.info( @@ -67,7 +70,7 @@ public AiAnalysisResult analyze( } private List saveExtractedItems( - Long newsletterId, Long userId, List items) { + Long newsletterId, Long userId, List items, String language) { if (items == null || items.isEmpty()) { log.warn("[AiAnalyzer] AI 서버 항목 추출 결과 없음. newsletterId={}", newsletterId); return List.of(); @@ -76,11 +79,13 @@ private List saveExtractedItems( List validItems = items.stream().filter(item -> item.title() != null && !item.title().isBlank()).toList(); List entities = - validItems.stream().map(item -> toChecklist(newsletterId, userId, item)).toList(); + validItems.stream() + .map(item -> toChecklist(newsletterId, userId, item, language)) + .toList(); if (entities.isEmpty()) { - log.warn("[AiAnalyzer] 저장 가능한 항목 없음. newsletterId={}", newsletterId); - return List.of(); + log.warn("[AiAnalyzer] 저장 가능한 항목 없음. newsletterId={}", newsletterId); + return List.of(); } List savedEntities = checklistRepository.saveAll(entities); @@ -88,33 +93,80 @@ private List saveExtractedItems( List savedItems = new ArrayList<>(); for (int i = 0; i < validItems.size(); i++) { - savedItems.add(new SavedExtractedItem(validItems.get(i), savedEntities.get(i))); + savedItems.add(new SavedExtractedItem(validItems.get(i), savedEntities.get(i))); } - return savedItems; - } + return savedItems;} - private Checklist toChecklist(Long newsletterId, Long userId, ExtractedItem item) { + private Checklist toChecklist(Long newsletterId, Long userId, ExtractedItem item, String language) { ChecklistType checklistType = "checklist".equalsIgnoreCase(item.type()) ? ChecklistType.CHECKLIST : ChecklistType.TODO; LocalDate targetDate = parseTargetDate(item.datetime()); - String targetDateLabel = - targetDate != null - ? targetDate.getMonthValue() + "월 " + targetDate.getDayOfMonth() + "일" - : null; + + String targetDateLabel = null; + if (targetDate != null) { + String koreanLabel = + targetDate.getMonthValue() + "월 " + targetDate.getDayOfMonth() + "일"; + targetDateLabel = + translateIfNeeded(koreanLabel, language, "targetDateLabel", newsletterId); + } + + // content: AI 서버가 한국어로 추출한 항목명을 파파고로 번역 + String content = + translateIfNeeded( + trimToMax(item.title().trim(), CHECKLIST_TEXT_MAX_LENGTH), + language, + "content", + newsletterId); + + // detail: AI 서버가 한국어로 추출한 상세 설명을 파파고로 번역 + String detail = null; + if (item.evidenceText() != null && !item.evidenceText().isBlank()) { + String trimmedDetail = trimNullable(item.evidenceText(), CHECKLIST_TEXT_MAX_LENGTH); + detail = translateIfNeeded(trimmedDetail, language, "detail", newsletterId); + } return Checklist.builder() .newsletterId(newsletterId) .calendarEventId(null) .userId(userId) .type(checklistType) - .content(trimToMax(item.title().trim(), CHECKLIST_TEXT_MAX_LENGTH)) - .detail(trimNullable(item.evidenceText(), CHECKLIST_TEXT_MAX_LENGTH)) + .content(content) + .detail(detail) .targetDate(checklistType == ChecklistType.TODO ? targetDate : null) .targetDateLabel(checklistType == ChecklistType.TODO ? targetDateLabel : null) .build(); } + /**KO가 아닌 경우 파파고로 번역. KO이거나 번역 실패 시 원문 그대로 반환.*/ + private String translateIfNeeded( + String text, String language, String fieldName, Long newsletterId) { + if (text == null || text.isBlank()) { + return text; + } + if ("KO".equals(language)) { + // KO는 번역 스킵, 원문 그대로 반환 + return text; + } + try { + String translated = papagoTranslateClient.translate(text, language); + if (translated != null && !translated.isBlank()) { + log.debug( + "[AiAnalyzer] {} 번역 완료. newsletterId={}, language={}", fieldName, newsletterId, language); + return translated; + } + } catch (Exception e) { + // 번역 실패 시 원문 유지 (파이프라인 전체를 중단하지 않음) + log.warn( + "[AiAnalyzer] {} 번역 실패. 원문 유지. newsletterId={}, language={}, error={}", + fieldName, + newsletterId, + language, + e.getMessage()); + } + return text; + } + private LocalDate parseTargetDate(String value) { if (value == null || value.isBlank()) { return null; From 84f5e12b3fe622079b707f2ea21709bd31f08b7d Mon Sep 17 00:00:00 2001 From: minkyung Date: Mon, 25 May 2026 23:50:26 +0900 Subject: [PATCH 32/33] =?UTF-8?q?feat:=20spotlessApply=20=EB=B0=98?= =?UTF-8?q?=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pipeline/NewsletterAiAnalyzer.java | 83 ++++++++++--------- 1 file changed, 42 insertions(+), 41 deletions(-) diff --git a/src/main/java/com/gachi/be/domain/newsletter/pipeline/NewsletterAiAnalyzer.java b/src/main/java/com/gachi/be/domain/newsletter/pipeline/NewsletterAiAnalyzer.java index e7646a7..7958e14 100644 --- a/src/main/java/com/gachi/be/domain/newsletter/pipeline/NewsletterAiAnalyzer.java +++ b/src/main/java/com/gachi/be/domain/newsletter/pipeline/NewsletterAiAnalyzer.java @@ -54,10 +54,10 @@ public AiAnalysisResult analyze( saveExtractedItems(newsletterId, newsletter.getUserId(), items, language); try { - saveCalendarPreview(newsletterId, savedItems); + saveCalendarPreview(newsletterId, savedItems); } catch (RuntimeException e) { - log.warn( - "[AiAnalyzer] 캘린더 preview 저장 실패. 분석 결과 저장은 계속 진행합니다. newsletterId={}", newsletterId, e); + log.warn( + "[AiAnalyzer] 캘린더 preview 저장 실패. 분석 결과 저장은 계속 진행합니다. newsletterId={}", newsletterId, e); } String rawTitle = normalizeTitle(analysisResponse.title(), originalText); @@ -79,13 +79,11 @@ private List saveExtractedItems( List validItems = items.stream().filter(item -> item.title() != null && !item.title().isBlank()).toList(); List entities = - validItems.stream() - .map(item -> toChecklist(newsletterId, userId, item, language)) - .toList(); + validItems.stream().map(item -> toChecklist(newsletterId, userId, item, language)).toList(); if (entities.isEmpty()) { - log.warn("[AiAnalyzer] 저장 가능한 항목 없음. newsletterId={}", newsletterId); - return List.of(); + log.warn("[AiAnalyzer] 저장 가능한 항목 없음. newsletterId={}", newsletterId); + return List.of(); } List savedEntities = checklistRepository.saveAll(entities); @@ -93,11 +91,13 @@ private List saveExtractedItems( List savedItems = new ArrayList<>(); for (int i = 0; i < validItems.size(); i++) { - savedItems.add(new SavedExtractedItem(validItems.get(i), savedEntities.get(i))); + savedItems.add(new SavedExtractedItem(validItems.get(i), savedEntities.get(i))); } - return savedItems;} + return savedItems; + } - private Checklist toChecklist(Long newsletterId, Long userId, ExtractedItem item, String language) { + private Checklist toChecklist( + Long newsletterId, Long userId, ExtractedItem item, String language) { ChecklistType checklistType = "checklist".equalsIgnoreCase(item.type()) ? ChecklistType.CHECKLIST : ChecklistType.TODO; @@ -105,10 +105,8 @@ private Checklist toChecklist(Long newsletterId, Long userId, ExtractedItem item String targetDateLabel = null; if (targetDate != null) { - String koreanLabel = - targetDate.getMonthValue() + "월 " + targetDate.getDayOfMonth() + "일"; - targetDateLabel = - translateIfNeeded(koreanLabel, language, "targetDateLabel", newsletterId); + String koreanLabel = targetDate.getMonthValue() + "월 " + targetDate.getDayOfMonth() + "일"; + targetDateLabel = translateIfNeeded(koreanLabel, language, "targetDateLabel", newsletterId); } // content: AI 서버가 한국어로 추출한 항목명을 파파고로 번역 @@ -122,8 +120,8 @@ private Checklist toChecklist(Long newsletterId, Long userId, ExtractedItem item // detail: AI 서버가 한국어로 추출한 상세 설명을 파파고로 번역 String detail = null; if (item.evidenceText() != null && !item.evidenceText().isBlank()) { - String trimmedDetail = trimNullable(item.evidenceText(), CHECKLIST_TEXT_MAX_LENGTH); - detail = translateIfNeeded(trimmedDetail, language, "detail", newsletterId); + String trimmedDetail = trimNullable(item.evidenceText(), CHECKLIST_TEXT_MAX_LENGTH); + detail = translateIfNeeded(trimmedDetail, language, "detail", newsletterId); } return Checklist.builder() @@ -138,33 +136,36 @@ private Checklist toChecklist(Long newsletterId, Long userId, ExtractedItem item .build(); } - /**KO가 아닌 경우 파파고로 번역. KO이거나 번역 실패 시 원문 그대로 반환.*/ + /** KO가 아닌 경우 파파고로 번역. KO이거나 번역 실패 시 원문 그대로 반환. */ private String translateIfNeeded( String text, String language, String fieldName, Long newsletterId) { - if (text == null || text.isBlank()) { - return text; - } - if ("KO".equals(language)) { - // KO는 번역 스킵, 원문 그대로 반환 - return text; - } - try { - String translated = papagoTranslateClient.translate(text, language); - if (translated != null && !translated.isBlank()) { - log.debug( - "[AiAnalyzer] {} 번역 완료. newsletterId={}, language={}", fieldName, newsletterId, language); - return translated; - } - } catch (Exception e) { - // 번역 실패 시 원문 유지 (파이프라인 전체를 중단하지 않음) - log.warn( - "[AiAnalyzer] {} 번역 실패. 원문 유지. newsletterId={}, language={}, error={}", - fieldName, - newsletterId, - language, - e.getMessage()); - } + if (text == null || text.isBlank()) { return text; + } + if ("KO".equals(language)) { + // KO는 번역 스킵, 원문 그대로 반환 + return text; + } + try { + String translated = papagoTranslateClient.translate(text, language); + if (translated != null && !translated.isBlank()) { + log.debug( + "[AiAnalyzer] {} 번역 완료. newsletterId={}, language={}", + fieldName, + newsletterId, + language); + return translated; + } + } catch (Exception e) { + // 번역 실패 시 원문 유지 (파이프라인 전체를 중단하지 않음) + log.warn( + "[AiAnalyzer] {} 번역 실패. 원문 유지. newsletterId={}, language={}, error={}", + fieldName, + newsletterId, + language, + e.getMessage()); + } + return text; } private LocalDate parseTargetDate(String value) { From 8abed563425ce9d7ef8acb0c236be2fc14394bce Mon Sep 17 00:00:00 2001 From: minkyung Date: Mon, 25 May 2026 23:58:28 +0900 Subject: [PATCH 33/33] =?UTF-8?q?feat:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=EC=97=90=20=EC=96=B8=EC=96=B4=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/api/controller/AuthControllerIntegrationTest.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/test/java/com/gachi/be/domain/auth/api/controller/AuthControllerIntegrationTest.java b/src/test/java/com/gachi/be/domain/auth/api/controller/AuthControllerIntegrationTest.java index 4913323..b2499ed 100644 --- a/src/test/java/com/gachi/be/domain/auth/api/controller/AuthControllerIntegrationTest.java +++ b/src/test/java/com/gachi/be/domain/auth/api/controller/AuthControllerIntegrationTest.java @@ -113,7 +113,8 @@ void signupLoginReissueAndRotation() throws Exception { "password":"password123", "passwordConfirm":"password123", "phoneNumber":"01012345678", - "consentAgreed":true + "consentAgreed":true, + "languageCode":"KO" } """) .andExpect(status().isCreated()) @@ -677,7 +678,8 @@ private String signupPayload( "password", password, "passwordConfirm", passwordConfirm, "phoneNumber", phoneNumber, - "consentAgreed", consentAgreed)); + "consentAgreed", consentAgreed, + "languageCode", "KO")); } private org.springframework.test.web.servlet.ResultActions login(String payload)