Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
1d0f578
feat: migration v11 추가
Hminkyung May 21, 2026
13d7dbf
feat: user에 update 코드 추가
Hminkyung May 21, 2026
cdc7f61
feat: repository 코드 추가
Hminkyung May 24, 2026
24aabae
feat: request dto 추가
Hminkyung May 24, 2026
0b486e9
feat: controller 코드 추가 및 successcode 추가
Hminkyung May 24, 2026
9273a8d
feat: errorcode 추가
Hminkyung May 24, 2026
410b5a8
feat: auth dto에 회원가입 언어코드 추가
Hminkyung May 24, 2026
91260a8
feat: auth service 로직에 자녀 색상코드 추가
Hminkyung May 24, 2026
ea40cb0
Merge branch 'develop' of https://github.com/GACHI-Project/GACHI-BE i…
Hminkyung May 24, 2026
5c3cb36
feat: service 로직 추가- 사용자언어설정으로 받아와서 번역처리
Hminkyung May 25, 2026
bc93db6
feat: controller 코드 수정
Hminkyung May 25, 2026
85ab4ae
feat: controller 코드 수정-parameter 받기 대신에 받아오기
Hminkyung May 25, 2026
19d9f11
feat: description 추가
Hminkyung May 25, 2026
00f0644
feat: migration 알림 여부 추가
Hminkyung May 25, 2026
306a0ac
feat: user entity에 알림 설정 여부 추가
Hminkyung May 25, 2026
678ca2f
feat: 알림 설정 변경 dto 추가
Hminkyung May 25, 2026
8be2ce8
feat: 내 정보 조회 controller 추가
Hminkyung May 25, 2026
3d0ebd3
feat: migration 파일 추가-사용자 알림설정
Hminkyung May 25, 2026
cb243b3
feat: spotlessapply 반영 및 description 추가
Hminkyung May 25, 2026
807188e
feat: 코드리뷰 반영
Hminkyung May 25, 2026
23572b5
Merge branch 'develop' of https://github.com/GACHI-Project/GACHI-BE i…
Hminkyung May 25, 2026
ca1b3cc
fix: HttpClient 변경
Hminkyung May 25, 2026
922291b
feat: AI 번역 추가로 진행
Hminkyung May 25, 2026
84f5e12
feat: spotlessApply 반영
Hminkyung May 25, 2026
8abed56
feat: 테스트 코드에 언어설정 추가
Hminkyung May 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,9 @@ public record SignupRequest(
@NotBlank
@Pattern(regexp = PhoneNumberValidation.REGEXP, message = PhoneNumberValidation.MESSAGE)
String phoneNumber,
@NotNull Boolean consentAgreed) {}
@NotNull Boolean consentAgreed,
@NotNull(message = "languageCode는 필수입니다.")
@Pattern(
regexp = "^(KO|US|ZH|VI)$",
message = "지원하지 않는 언어 코드입니다. KO, US, ZH, VI 중 하나여야 합니다.")
String languageCode) {}
Comment thread
Hminkyung marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,13 +65,10 @@ public ApiResponse<NewsletterUploadResponse> 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, language);
NewsletterUploadResponse response = newsletterService.upload(userId, file, childId);
return ApiResponse.success(SuccessCode.NEWSLETTER_UPLOAD_SUCCESS, response);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

Expand All @@ -55,11 +56,13 @@ public AnalysisResponse analyze(
LocalDate.now(DEFAULT_ZONE),
DEFAULT_ZONE.getId(),
toDateCandidateRequests(dateCandidates)));
log.info("[AiNewsletterClient] 요청 body: {}", requestBody);

HttpRequest request =
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();
Expand All @@ -69,9 +72,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());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -50,15 +51,17 @@ public AiAnalysisResult analyze(
List<ExtractedItem> items = analysisResponse.items();

List<SavedExtractedItem> savedItems =
saveExtractedItems(newsletterId, newsletter.getUserId(), items);
saveExtractedItems(newsletterId, newsletter.getUserId(), items, language);

try {
saveCalendarPreview(newsletterId, savedItems);
} catch (RuntimeException 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(
Expand All @@ -67,7 +70,7 @@ public AiAnalysisResult analyze(
}

private List<SavedExtractedItem> saveExtractedItems(
Long newsletterId, Long userId, List<ExtractedItem> items) {
Long newsletterId, Long userId, List<ExtractedItem> items, String language) {
if (items == null || items.isEmpty()) {
log.warn("[AiAnalyzer] AI 서버 항목 추출 결과 없음. newsletterId={}", newsletterId);
return List.of();
Expand All @@ -76,7 +79,7 @@ private List<SavedExtractedItem> saveExtractedItems(
List<ExtractedItem> validItems =
items.stream().filter(item -> item.title() != null && !item.title().isBlank()).toList();
List<Checklist> 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);
Expand All @@ -93,28 +96,78 @@ private List<SavedExtractedItem> saveExtractedItems(
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;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
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;
Expand Down Expand Up @@ -95,4 +96,20 @@ List<Newsletter> findRecentByUserId(
@Param("userId") Long userId,
@Param("rangeStart") OffsetDateTime rangeStart,
@Param("rangeEnd") OffsetDateTime rangeEnd);

/** 언어 변경 시 진행중인 파이프라인 중단 처리용 쿼리 */
@Modifying
@Query(
"""
UPDATE Newsletter n
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<NewsletterStatus> targetStatuses,
@Param("failedStatus") NewsletterStatus failedStatus,
@Param("newLanguage") String newLanguage);
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,9 @@ 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);
NewsletterUploadResponse upload(Long userId, MultipartFile file, Long childId);

/**
* 가정통신문의 현재 분석 상태와 진행률을 조회.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -60,6 +62,7 @@ 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;

Expand All @@ -72,8 +75,7 @@ public class NewsletterServiceImpl implements NewsletterService {
*/
@Override
@Transactional
public NewsletterUploadResponse upload(
Long userId, MultipartFile file, Long childId, String userLanguage) {
public NewsletterUploadResponse upload(Long userId, MultipartFile file, Long childId) {

// 파일 유효성 검사
validateFile(file);
Expand Down Expand Up @@ -110,6 +112,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);
Expand All @@ -118,6 +124,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,
Expand Down
Loading