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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/main/java/com/gachi/be/GachiBeApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;

@EnableAsync
@EnableScheduling
@SpringBootApplication
public class GachiBeApplication {

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.gachi.be.domain.auth.dto.request;

import com.gachi.be.domain.user.entity.enums.NotificationPreference;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
Expand All @@ -22,4 +23,5 @@ public record SignupRequest(
@Pattern(
regexp = "^(KO|US|ZH|VI)$",
message = "지원하지 않는 언어 코드입니다. KO, US, ZH, VI 중 하나여야 합니다.")
String languageCode) {}
String languageCode,
NotificationPreference notificationPreference) {}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +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.enums.NotificationPreference;
import com.gachi.be.domain.user.entity.enums.UserStatus;
import com.gachi.be.domain.user.repository.UserRepository;
import com.gachi.be.global.code.ErrorCode;
Expand Down Expand Up @@ -139,6 +140,10 @@ public SignupResponse signup(SignupRequest request) {
.phoneNumber(phoneNumber)
.status(UserStatus.ACTIVE)
.languageCode(request.languageCode())
.notificationPreference(
request.notificationPreference() != null
? request.notificationPreference()
: NotificationPreference.IMPORTANT)
.emailVerifiedAt(now)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
.consentAgreedAt(now)
.consentVersion(authProperties.getConsentVersion())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ List<CalendarEvent> findEventsInRange(
/** 소유권 검증 포함 단건 조회. */
Optional<CalendarEvent> findByIdAndUserId(Long id, Long userId);

List<CalendarEvent> findByStartAtGreaterThanEqualAndStartAtLessThan(
OffsetDateTime rangeStart, OffsetDateTime rangeEnd);

/** 특정 가정통신문의 모든 일정 삭제. */
void deleteByNewsletterIdAndUserId(Long newsletterId, Long userId);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.gachi.be.domain.checklist.entity.Checklist;
import com.gachi.be.domain.checklist.entity.enums.ChecklistType;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
Expand All @@ -25,6 +26,11 @@ public interface ChecklistRepository extends JpaRepository<Checklist, Long> {
/** 특정 사용자의 미완료 CHECKLIST 항목 전체 조회. */
List<Checklist> findByUserIdAndTypeAndCompletedFalse(Long userId, ChecklistType type);

long countByUserIdAndTypeAndCompletedFalse(Long userId, ChecklistType type);

List<Checklist> findByTypeAndCompletedFalseAndTargetDate(
ChecklistType type, LocalDate targetDate);

/** 특정 캘린더 일정에 연결된 CHECKLIST 타입 항목 조회. */
List<Checklist> findByCalendarEventIdAndTypeOrderByIdAsc(
Long calendarEventId, ChecklistType type);
Expand All @@ -47,4 +53,14 @@ List<Checklist> findIncompleteChecklistsByCalendarEventIds(

/** e ventId IN 절로 한 번에 조회하여 서비스에서 Map으로 그룹핑 */
List<Checklist> findByCalendarEventIdInAndType(List<Long> calendarEventIds, ChecklistType type);

@Query(
"""
SELECT c FROM Checklist c
WHERE c.calendarEventId IN :calendarEventIds
AND c.type = com.gachi.be.domain.checklist.entity.enums.ChecklistType.CHECKLIST
AND c.completed = false
""")
List<Checklist> findIncompleteChecklistItemsByCalendarEventIds(
@Param("calendarEventIds") List<Long> calendarEventIds);
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ public interface ChildRepository extends JpaRepository<Child, Long> {

long countByUserId(Long userId);

/** 특정 사용자의 활성 자녀 목록 조회 */
List<Child> findByUserIdAndDeletedAtIsNull(Long userId);

/** 특정 사용자의 특정 자녀 조회 */
Optional<Child> findByIdAndUserIdAndDeletedAtIsNull(Long id, Long userId);

Optional<Child> findFirstByUserIdAndNameAndDeletedAtIsNull(Long userId, String name);
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import software.amazon.awssdk.core.ResponseBytes;
import software.amazon.awssdk.core.sync.RequestBody;
Expand All @@ -34,6 +33,7 @@ public class NewsletterPipelineService {
private final PapagoTranslateClient papagoTranslateClient;
private final NewsletterAiAnalyzer newsletterAiAnalyzer;
private final NewsletterDateCandidateService newsletterDateCandidateService;
private final NewsletterPipelineStatusService newsletterPipelineStatusService;

@Async
@Transactional
Expand All @@ -46,7 +46,7 @@ public void runPipeline(Long newsletterId) {
return;
}

markProcessing(newsletterId);
newsletterPipelineStatusService.markProcessing(newsletterId);
log.debug("[Pipeline] PROCESSING 전환 완료. newsletterId={}", newsletterId);

String tempFileKey = null;
Expand Down Expand Up @@ -116,13 +116,13 @@ public void runPipeline(Long newsletterId) {
e.getClass().getSimpleName(),
e.getMessage(),
e);
markFailedWithSnapshot(
newsletterPipelineStatusService.markFailedWithSnapshot(
newsletterId, ocrText, originalText, translatedText, failureStage, failureReason(e));
return;
}
log.debug("[Pipeline][STEP7] AI 서버 분석 완료. title={}", aiResult.title());

markCompleted(
newsletterPipelineStatusService.markCompleted(
newsletterId,
ocrText,
originalText,
Expand All @@ -139,68 +139,21 @@ public void runPipeline(Long newsletterId) {
e.getClass().getSimpleName(),
e.getMessage(),
e);
markFailedWithSnapshot(
newsletterPipelineStatusService.markFailedWithSnapshot(
newsletterId, ocrText, originalText, translatedText, failureStage, failureReason(e));
} finally {
if (tempFileKey != null) {
try {
deleteFromS3(tempFileKey);
log.debug("[Pipeline] 임시 파일 삭제 완료. tempFileKey={}", tempFileKey);
} catch (Exception ex) {
// 임시 파일 정리는 후처리라서 실패해도 분석 결과는 되돌리지 않는다.
log.warn(
"[Pipeline] 임시 파일 삭제 실패. tempFileKey={}, error={}", tempFileKey, ex.getMessage());
}
}
}
}

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void markProcessing(Long newsletterId) {
newsletterRepository
.findById(newsletterId)
.ifPresent(
n -> {
n.startProcessing();
newsletterRepository.save(n);
});
}

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void markCompleted(
Long newsletterId,
String ocrText,
String originalText,
String translatedText,
String title,
String summary) {
newsletterRepository
.findById(newsletterId)
.ifPresent(
n -> {
n.complete(ocrText, originalText, translatedText, title, summary);
newsletterRepository.save(n);
});
}

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void markFailedWithSnapshot(
Long newsletterId,
String ocrText,
String originalText,
String translatedText,
String failureStage,
String failureReason) {
newsletterRepository
.findById(newsletterId)
.ifPresent(
n -> {
n.failWithSnapshot(
ocrText, originalText, translatedText, failureStage, failureReason);
newsletterRepository.save(n);
});
}

private String failureReason(Exception e) {
String message = e.getMessage();
if (message == null || message.isBlank()) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
package com.gachi.be.domain.newsletter.pipeline;

import com.gachi.be.domain.child.repository.ChildRepository;
import com.gachi.be.domain.newsletter.entity.Newsletter;
import com.gachi.be.domain.newsletter.repository.NewsletterRepository;
import com.gachi.be.domain.notification.entity.enums.NotificationLevel;
import com.gachi.be.domain.notification.entity.enums.NotificationType;
import com.gachi.be.domain.notification.service.NotificationCreateCommand;
import com.gachi.be.domain.notification.service.NotificationService;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;

@Slf4j
@Service
@RequiredArgsConstructor
public class NewsletterPipelineStatusService {

private final NewsletterRepository newsletterRepository;
private final NotificationService notificationService;
private final ChildRepository childRepository;

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void markProcessing(Long newsletterId) {
newsletterRepository
.findById(newsletterId)
.ifPresent(
newsletter -> {
newsletter.startProcessing();
newsletterRepository.save(newsletter);
});
}

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void markCompleted(
Long newsletterId,
String ocrText,
String originalText,
String translatedText,
String title,
String summary) {
newsletterRepository
.findById(newsletterId)
.ifPresent(
newsletter -> {
newsletter.complete(ocrText, originalText, translatedText, title, summary);
Newsletter saved = newsletterRepository.save(newsletter);
scheduleAnalysisCompletedNotification(saved);
});
}

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void markFailedWithSnapshot(
Long newsletterId,
String ocrText,
String originalText,
String translatedText,
String failureStage,
String failureReason) {
newsletterRepository
.findById(newsletterId)
.ifPresent(
newsletter -> {
newsletter.failWithSnapshot(
ocrText, originalText, translatedText, failureStage, failureReason);
newsletterRepository.save(newsletter);
});
}

private void scheduleAnalysisCompletedNotification(Newsletter newsletter) {
if (TransactionSynchronizationManager.isSynchronizationActive()) {
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override
public void afterCommit() {
createAnalysisCompletedNotificationSafely(newsletter);
}
});
return;
}
createAnalysisCompletedNotificationSafely(newsletter);
}

private void createAnalysisCompletedNotificationSafely(Newsletter newsletter) {
try {
createAnalysisCompletedNotification(newsletter);
} catch (Exception ex) {
log.warn(
"[Pipeline] 분석 완료 알림 생성 실패. newsletterId={}, error={}",
newsletter.getId(),
ex.getMessage(),
ex);
}
}

private void createAnalysisCompletedNotification(Newsletter newsletter) {
Long childId = resolveChildId(newsletter);
notificationService.createNotification(
newsletter.getUserId(),
new NotificationCreateCommand(
NotificationType.NEWSLETTER_ANALYSIS,
"새 가정통신문 분석 완료",
newsletter.getTitle() != null && !newsletter.getTitle().isBlank()
? newsletter.getTitle() + " 분석이 완료되었어요"
: "가정통신문 분석이 완료되었어요",
Map.of(
"newsletterId",
newsletter.getId(),
"childName",
newsletter.getChildName() != null ? newsletter.getChildName() : ""),
"newsletter-analysis:" + newsletter.getId(),
NotificationLevel.IMPORTANT,
childId,
newsletter.getChildName()));
}

private Long resolveChildId(Newsletter newsletter) {
if (newsletter.getChildName() == null || newsletter.getChildName().isBlank()) {
return null;
}
return childRepository
.findFirstByUserIdAndNameAndDeletedAtIsNull(
newsletter.getUserId(), newsletter.getChildName())
.map(child -> child.getId())
.orElse(null);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,9 @@ List<Newsletter> findRecentByUserId(
@Param("rangeStart") OffsetDateTime rangeStart,
@Param("rangeEnd") OffsetDateTime rangeEnd);

long countByUserIdAndCreatedAtGreaterThanEqualAndCreatedAtLessThan(
Long userId, OffsetDateTime rangeStart, OffsetDateTime rangeEnd);

/** 언어 변경 시 진행중인 파이프라인 중단 처리용 쿼리 */
@Modifying
@Query(
Expand Down
Loading
Loading