feat : initial commit

This commit is contained in:
2025-06-20 05:42:24 +00:00
commit 409d7abdc6
245 changed files with 17069 additions and 0 deletions
+39
View File
@@ -0,0 +1,39 @@
plugins {
id 'java'
id 'org.springframework.boot' version '3.4.0'
id 'io.spring.dependency-management' version '1.1.4'
}
group = 'com.healthsync'
version = '1.0.0'
java {
sourceCompatibility = '17'
}
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
dependencies {
implementation project(':common')
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-webflux'
implementation 'org.springframework.boot:spring-boot-starter-batch'
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.batch:spring-batch-test'
}
tasks.named('test') {
useJUnitPlatform()
}
@@ -0,0 +1,25 @@
package com.healthsync.motivator;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
import org.springframework.batch.core.configuration.annotation.EnableBatchProcessing;
import org.springframework.scheduling.annotation.EnableScheduling;
/**
* Motivator Service의 메인 애플리케이션 클래스입니다.
* 사용자 동기부여 메시지 생성 및 배치 알림 처리 기능을 제공합니다.
*
* @author healthsync-team
* @version 1.0
*/
@SpringBootApplication(scanBasePackages = {"com.healthsync.motivator", "com.healthsync.common"})
@ConfigurationPropertiesScan
@EnableBatchProcessing
@EnableScheduling
public class MotivatorServiceApplication {
public static void main(String[] args) {
SpringApplication.run(MotivatorServiceApplication.class, args);
}
}
@@ -0,0 +1,216 @@
package com.healthsync.motivator.application_services;
import com.healthsync.motivator.domain.services.UserAnalysisDomainService;
import com.healthsync.motivator.domain.services.MessageGenerationDomainService;
import com.healthsync.motivator.domain.services.BatchProcessingDomainService;
import com.healthsync.motivator.domain.repositories.NotificationRepository;
import com.healthsync.motivator.dto.*;
import com.healthsync.motivator.infrastructure.ports.*;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
/**
* 동기부여 관련 유스케이스입니다.
* Clean Architecture의 Application Service 계층에 해당합니다.
*
* @author healthsync-team
* @version 1.0
*/
@Slf4j
@Service
@RequiredArgsConstructor
@Transactional
public class MotivationUseCase {
private final UserAnalysisDomainService userAnalysisDomainService;
private final MessageGenerationDomainService messageGenerationDomainService;
private final BatchProcessingDomainService batchProcessingDomainService;
private final NotificationRepository notificationRepository;
private final GoalServicePort goalServicePort;
private final ClaudeApiPort claudeApiPort;
private final CachePort cachePort;
private final EventPublisherPort eventPublisherPort;
/**
* 독려 메시지를 생성합니다.
*
* @param request 독려 요청
* @return 독려 응답
*/
public EncouragementResponse generateEncouragementMessage(EncouragementRequest request) {
log.info("독려 메시지 생성 시작: userId={}", request.getUserId());
// 요청 검증
validateEncouragementRequest(request);
// 캐시에서 기존 메시지 확인
String cacheKey = generateCacheKey(request.getUserId(), request.getMissionsStatus());
EncouragementResponse cachedResponse = cachePort.getCachedEncouragementMessage(cacheKey);
if (cachedResponse != null) {
log.info("캐시에서 독려 메시지 조회: userId={}", request.getUserId());
return cachedResponse;
}
// 사용자 미션 진행 상황 분석
DailyProgress dailyProgress = goalServicePort.getUserDailyProgress(request.getUserId());
ProgressAnalysis progressAnalysis = userAnalysisDomainService.analyzeMissionProgress(
request.getUserId(), request.getMissionsStatus(), dailyProgress);
// AI 독려 메시지 생성
String encouragementMessage = messageGenerationDomainService.generateEncouragementMessage(
progressAnalysis, claudeApiPort);
// 응답 구성
EncouragementResponse response = EncouragementResponse.builder()
.message(encouragementMessage)
.motivationType(progressAnalysis.getMotivationType().name())
.timing(calculateOptimalTiming(progressAnalysis))
.personalizedTip(generatePersonalizedTip(progressAnalysis))
.priority(progressAnalysis.getUrgencyLevel().name())
.build();
// 캐시에 저장
cachePort.cacheEncouragementMessage(cacheKey, response);
// 알림 로그 저장
notificationRepository.saveNotificationLog(request.getUserId(), null, "encouragement", encouragementMessage);
// 이벤트 발행
eventPublisherPort.publishEncouragementSentEvent(request.getUserId(), "encouragement");
log.info("독려 메시지 생성 완료: userId={}, motivationType={}",
request.getUserId(), response.getMotivationType());
return response;
}
/**
* 배치 알림을 처리합니다.
*
* @param request 배치 알림 요청
* @return 배치 처리 결과
*/
public BatchNotificationResponse processBatchNotifications(BatchNotificationRequest request) {
log.info("배치 알림 처리 시작: triggerTime={}, targetCount={}",
request.getTriggerTime(), request.getTargetUsers().size());
// 배치 처리 시작
String batchId = generateBatchId();
LocalDateTime startTime = LocalDateTime.now();
// 전체 사용자 미션 상태 조회
List<UserMissionStatus> allUsersStatus = goalServicePort.getAllUsersWithActiveMissions();
// 대상 사용자 필터링
List<UserMissionStatus> targetUsers = batchProcessingDomainService
.filterUsersNeedingNotification(allUsersStatus, request);
// 우선순위별 정렬
List<UserMissionStatus> prioritizedUsers = batchProcessingDomainService
.prioritizeUsersByUrgency(targetUsers);
// 배치 처리 실행
BatchProcessingResult result = batchProcessingDomainService
.processBatchNotifications(prioritizedUsers, batchId, claudeApiPort, cachePort);
// 처리 결과 로깅
LocalDateTime endTime = LocalDateTime.now();
log.info("배치 알림 처리 완료: batchId={}, processed={}, success={}, failed={}, duration={}ms",
batchId, result.getProcessedCount(), result.getSuccessCount(),
result.getFailedCount(), java.time.Duration.between(startTime, endTime).toMillis());
// 다음 스케줄 시간 계산
String nextScheduledTime = calculateNextScheduledTime(request.getTriggerTime());
return BatchNotificationResponse.builder()
.batchId(batchId)
.processedCount(result.getProcessedCount())
.successCount(result.getSuccessCount())
.failedCount(result.getFailedCount())
.nextScheduledTime(nextScheduledTime)
.build();
}
/**
* 독려 요청을 검증합니다.
*
* @param request 독려 요청
*/
private void validateEncouragementRequest(EncouragementRequest request) {
if (request.getUserId() == null || request.getUserId().trim().isEmpty()) {
throw new IllegalArgumentException("사용자 ID는 필수입니다.");
}
if (request.getMissionsStatus() == null || request.getMissionsStatus().isEmpty()) {
throw new IllegalArgumentException("미션 상태 정보는 필수입니다.");
}
}
/**
* 캐시 키를 생성합니다.
*
* @param userId 사용자 ID
* @param missionsStatus 미션 상태 목록
* @return 캐시 키
*/
private String generateCacheKey(String userId, List<MissionStatus> missionsStatus) {
// 미션 완료 상태를 바탕으로 캐시 키 생성
long completedCount = missionsStatus.stream().mapToLong(ms -> ms.isCompleted() ? 1 : 0).sum();
double completionRate = (double) completedCount / missionsStatus.size();
return String.format("encouragement:%s:%.1f", userId, completionRate);
}
/**
* 최적 타이밍을 계산합니다.
*
* @param progressAnalysis 진행 분석
* @return 최적 타이밍
*/
private String calculateOptimalTiming(ProgressAnalysis progressAnalysis) {
return switch (progressAnalysis.getUrgencyLevel()) {
case HIGH -> "즉시";
case MEDIUM -> "1시간 후";
case LOW -> "내일 오전";
};
}
/**
* 개인화된 팁을 생성합니다.
*
* @param progressAnalysis 진행 분석
* @return 개인화된 팁
*/
private String generatePersonalizedTip(ProgressAnalysis progressAnalysis) {
return switch (progressAnalysis.getMotivationType()) {
case ACHIEVEMENT -> "작은 목표부터 차근차근 달성해보세요!";
case SOCIAL -> "친구들과 함께 도전하면 더 재미있어요!";
case HEALTH_BENEFIT -> "건강한 습관이 당신의 미래를 바꿉니다!";
case HABIT_FORMATION -> "21일만 지속하면 습관이 됩니다!";
};
}
/**
* 배치 ID를 생성합니다.
*
* @return 배치 ID
*/
private String generateBatchId() {
return "batch_" + System.currentTimeMillis();
}
/**
* 다음 스케줄 시간을 계산합니다.
*
* @param triggerTime 현재 트리거 시간
* @return 다음 스케줄 시간
*/
private String calculateNextScheduledTime(String triggerTime) {
// 실제 구현에서는 cron 표현식 등을 활용하여 계산
return LocalDateTime.now().plusHours(2).toString();
}
}
@@ -0,0 +1,65 @@
package com.healthsync.motivator.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Arrays;
/**
* Motivator Service의 보안 설정을 관리하는 클래스입니다.
*
* @author healthsync-team
* @version 1.0
*/
@Configuration
@EnableWebSecurity
public class MotivatorSecurityConfig {
/**
* Security Filter Chain을 구성합니다.
*
* @param http HttpSecurity
* @return SecurityFilterChain
* @throws Exception 예외
*/
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/swagger-ui/**", "/api-docs/**").permitAll()
.requestMatchers("/actuator/**").permitAll()
.anyRequest().authenticated()
);
return http.build();
}
/**
* CORS 설정을 구성합니다.
*
* @return CorsConfigurationSource
*/
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOriginPatterns(Arrays.asList("*"));
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(Arrays.asList("*"));
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}
@@ -0,0 +1,43 @@
package com.healthsync.motivator.domain.repositories;
import com.healthsync.motivator.infrastructure.entities.NotificationLogEntity;
import java.util.List;
/**
* 알림 데이터 저장소 인터페이스입니다.
* Clean Architecture의 Domain 계층에서 정의합니다.
*
* @author healthsync-team
* @version 1.0
*/
public interface NotificationRepository {
/**
* 알림 로그를 저장합니다.
*
* @param userId 사용자 ID
* @param missionId 미션 ID
* @param notificationType 알림 유형
* @param message 메시지
* @return 저장된 알림 로그 엔티티
*/
NotificationLogEntity saveNotificationLog(String userId, String missionId, String notificationType, String message);
/**
* 사용자의 최근 알림 로그를 조회합니다.
*
* @param userId 사용자 ID
* @param limit 조회할 최대 수
* @return 알림 로그 목록
*/
List<NotificationLogEntity> findRecentNotificationLogs(String userId, int limit);
/**
* 배치 알림 로그를 조회합니다.
*
* @param batchId 배치 ID
* @return 알림 로그 목록
*/
List<NotificationLogEntity> findNotificationLogsByBatchId(String batchId);
}
@@ -0,0 +1,285 @@
package com.healthsync.motivator.domain.services;
import com.healthsync.motivator.dto.*;
import com.healthsync.motivator.infrastructure.ports.ClaudeApiPort;
import com.healthsync.motivator.infrastructure.ports.CachePort;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.LocalTime;
import java.util.Comparator;
import java.util.List;
import java.util.stream.Collectors;
/**
* 배치 처리를 담당하는 도메인 서비스입니다.
*
* @author healthsync-team
* @version 1.0
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class BatchProcessingDomainService {
private final MessageGenerationDomainService messageGenerationDomainService;
private final UserAnalysisDomainService userAnalysisDomainService;
/**
* 알림이 필요한 사용자를 필터링합니다.
*
* @param allUsers 전체 사용자 목록
* @param request 배치 요청
* @return 필터링된 사용자 목록
*/
public List<UserMissionStatus> filterUsersNeedingNotification(
List<UserMissionStatus> allUsers, BatchNotificationRequest request) {
LocalTime currentTime = LocalTime.now();
return allUsers.stream()
.filter(user -> isEligibleForNotification(user, currentTime))
.filter(user -> request.getTargetUsers().isEmpty() ||
request.getTargetUsers().contains(user.getUserId()))
.collect(Collectors.toList());
}
/**
* 사용자를 긴급도별로 우선순위를 정렬합니다.
*
* @param users 사용자 목록
* @return 우선순위별 정렬된 사용자 목록
*/
public List<UserMissionStatus> prioritizeUsersByUrgency(List<UserMissionStatus> users) {
return users.stream()
.sorted(Comparator
.comparing((UserMissionStatus u) -> calculateUrgencyScore(u))
.reversed()
.thenComparing(UserMissionStatus::getLastActiveTime))
.collect(Collectors.toList());
}
/**
* 배치 알림을 처리합니다.
*
* @param prioritizedUsers 우선순위별 사용자 목록
* @param batchId 배치 ID
* @param claudeApiPort Claude API 포트
* @param cachePort 캐시 포트
* @return 배치 처리 결과
*/
public BatchProcessingResult processBatchNotifications(
List<UserMissionStatus> prioritizedUsers,
String batchId,
ClaudeApiPort claudeApiPort,
CachePort cachePort) {
log.info("배치 알림 처리 시작: batchId={}, userCount={}", batchId, prioritizedUsers.size());
int processedCount = 0;
int successCount = 0;
int failedCount = 0;
for (UserMissionStatus userStatus : prioritizedUsers) {
try {
processedCount++;
// 사용자별 알림 컨텍스트 분석
UserNotificationContext context = analyzeUserNotificationContext(userStatus);
// 개인화된 배치 메시지 생성
String message = generateBatchEncouragementMessage(context, claudeApiPort);
// 배치 메시지 캐시에 저장
cachePort.storeBatchMessage(userStatus.getUserId(), message);
successCount++;
log.debug("배치 알림 처리 성공: userId={}, batchId={}", userStatus.getUserId(), batchId);
} catch (Exception e) {
failedCount++;
log.error("배치 알림 처리 실패: userId={}, batchId={}, error={}",
userStatus.getUserId(), batchId, e.getMessage(), e);
}
}
log.info("배치 알림 처리 완료: batchId={}, processed={}, success={}, failed={}",
batchId, processedCount, successCount, failedCount);
return BatchProcessingResult.builder()
.batchId(batchId)
.processedCount(processedCount)
.successCount(successCount)
.failedCount(failedCount)
.build();
}
/**
* 알림 대상 여부를 확인합니다.
*
* @param user 사용자 미션 상태
* @param currentTime 현재 시간
* @return 알림 대상 여부
*/
private boolean isEligibleForNotification(UserMissionStatus user, LocalTime currentTime) {
// 미션 완료율이 낮거나 연속 실패 시 알림 대상
double completionRate = calculateCompletionRate(user);
int consecutiveFailures = calculateConsecutiveFailures(user);
// 조용한 시간대 확인 (밤 10시 ~ 오전 8시)
boolean isQuietHours = currentTime.isBefore(LocalTime.of(8, 0)) ||
currentTime.isAfter(LocalTime.of(22, 0));
return (completionRate < 0.6 || consecutiveFailures >= 2) && !isQuietHours;
}
/**
* 긴급도 점수를 계산합니다.
*
* @param user 사용자 미션 상태
* @return 긴급도 점수 (높을수록 우선순위 높음)
*/
private int calculateUrgencyScore(UserMissionStatus user) {
int score = 0;
// 완료율이 낮을수록 높은 점수
double completionRate = calculateCompletionRate(user);
score += (int) ((1.0 - completionRate) * 50);
// 연속 실패 일수에 따른 점수
int consecutiveFailures = calculateConsecutiveFailures(user);
score += consecutiveFailures * 20;
// 마지막 활동 시간이 오래될수록 높은 점수
long daysSinceLastActive = java.time.Duration.between(
user.getLastActiveTime(), java.time.LocalDateTime.now()).toDays();
score += (int) Math.min(daysSinceLastActive * 5, 30);
return score;
}
/**
* 완료율을 계산합니다.
*
* @param user 사용자 미션 상태
* @return 완료율
*/
private double calculateCompletionRate(UserMissionStatus user) {
if (user.getTotalMissions() == 0) return 0.0;
return (double) user.getCompletedMissions() / user.getTotalMissions();
}
/**
* 연속 실패 일수를 계산합니다.
*
* @param user 사용자 미션 상태
* @return 연속 실패 일수
*/
private int calculateConsecutiveFailures(UserMissionStatus user) {
// 실제 구현에서는 사용자의 최근 미션 이력을 분석
// Mock 데이터로 계산
double completionRate = calculateCompletionRate(user);
if (completionRate < 0.3) return 3;
else if (completionRate < 0.5) return 2;
else if (completionRate < 0.7) return 1;
else return 0;
}
/**
* 사용자 알림 컨텍스트를 분석합니다.
*
* @param userStatus 사용자 미션 상태
* @return 사용자 알림 컨텍스트
*/
private UserNotificationContext analyzeUserNotificationContext(UserMissionStatus userStatus) {
return UserNotificationContext.builder()
.userId(userStatus.getUserId())
.completionRate(calculateCompletionRate(userStatus))
.consecutiveFailures(calculateConsecutiveFailures(userStatus))
.lastActiveTime(userStatus.getLastActiveTime())
.totalMissions(userStatus.getTotalMissions())
.completedMissions(userStatus.getCompletedMissions())
.build();
}
/**
* 배치 독려 메시지를 생성합니다.
*
* @param context 사용자 알림 컨텍스트
* @param claudeApiPort Claude API 포트
* @return 배치 독려 메시지
*/
private String generateBatchEncouragementMessage(UserNotificationContext context, ClaudeApiPort claudeApiPort) {
try {
String prompt = prepareBatchPrompt(context);
String aiResponse = claudeApiPort.callClaudeApi(prompt);
return addPersonalizedTouch(aiResponse, context);
} catch (Exception e) {
log.warn("배치 AI 메시지 생성 실패, 기본 메시지 사용: userId={}", context.getUserId());
return generateFallbackBatchMessage(context);
}
}
/**
* 배치 프롬프트를 준비합니다.
*
* @param context 사용자 알림 컨텍스트
* @return 배치 프롬프트
*/
private String prepareBatchPrompt(UserNotificationContext context) {
return String.format("""
건강 코치로서 사용자에게 따뜻한 배치 알림 메시지를 작성해주세요.
[상황]
- 완료율: %.1f%%
- 연속 실패: %d일
- 마지막 활동: %s
[요구사항]
- 80자 이내 간결한 메시지
- 부담스럽지 않고 격려하는 톤
- 오늘 다시 시작할 수 있다는 희망적 메시지
- 이모지 활용하여 친근함 표현
""",
context.getCompletionRate() * 100,
context.getConsecutiveFailures(),
context.getLastActiveTime().toLocalDate()
);
}
/**
* 개인화된 터치를 추가합니다.
*
* @param message 기본 메시지
* @param context 사용자 컨텍스트
* @return 개인화된 메시지
*/
private String addPersonalizedTouch(String message, UserNotificationContext context) {
// 메시지 길이 제한 및 개인화 요소 추가
String personalizedMessage = message.trim();
if (personalizedMessage.length() > 80) {
personalizedMessage = personalizedMessage.substring(0, 77) + "...";
}
return personalizedMessage;
}
/**
* 기본 배치 메시지를 생성합니다.
*
* @param context 사용자 컨텍스트
* @return 기본 배치 메시지
*/
private String generateFallbackBatchMessage(UserNotificationContext context) {
if (context.getConsecutiveFailures() >= 3) {
return "🌅 새로운 하루, 새로운 시작! 작은 한 걸음부터 다시 시작해봐요!";
} else if (context.getCompletionRate() < 0.5) {
return "💪 포기하지 마세요! 오늘부터 다시 건강한 습관을 만들어가요!";
} else {
return "🎯 조금만 더 힘내세요! 건강한 목표까지 거의 다 왔어요!";
}
}
}
@@ -0,0 +1,137 @@
package com.healthsync.motivator.domain.services;
import com.healthsync.motivator.dto.ProgressAnalysis;
import com.healthsync.motivator.infrastructure.ports.ClaudeApiPort;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
/**
* 메시지 생성을 담당하는 도메인 서비스입니다.
*
* @author healthsync-team
* @version 1.0
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class MessageGenerationDomainService {
/**
* 독려 메시지를 생성합니다.
*
* @param progressAnalysis 진행 분석
* @param claudeApiPort Claude API 포트
* @return 독려 메시지
*/
public String generateEncouragementMessage(ProgressAnalysis progressAnalysis, ClaudeApiPort claudeApiPort) {
log.info("독려 메시지 생성: userId={}, motivationType={}",
progressAnalysis.getUserId(), progressAnalysis.getMotivationType());
try {
// AI 프롬프트 준비
String prompt = prepareEncouragementPrompt(progressAnalysis);
// Claude API 호출
String aiResponse = claudeApiPort.callClaudeApi(prompt);
// 응답 포맷팅
return formatEncouragementResponse(aiResponse);
} catch (Exception e) {
log.warn("AI 메시지 생성 실패, 기본 메시지 사용: userId={}, error={}",
progressAnalysis.getUserId(), e.getMessage());
return useFallbackMessage(progressAnalysis);
}
}
/**
* 독려 프롬프트를 준비합니다.
*
* @param analysis 진행 분석
* @return 프롬프트
*/
private String prepareEncouragementPrompt(ProgressAnalysis analysis) {
return String.format("""
당신은 친근하고 따뜻한 건강 코치입니다. 다음 정보를 바탕으로 개인화된 독려 메시지를 생성해주세요.
[사용자 진행 상황]
- 완료율: %.1f%% (%d/%d 미션 완료)
- 연속 달성 일수: %d일
- 동기부여 유형: %s
- 긴급도: %s
- 주간 완료율: %.1f%%
[실패한 미션]
%s
[요구사항]
- 100자 이내의 간결한 메시지
- %s 스타일의 동기부여
- 구체적인 행동 제안 포함
- 긍정적이고 격려하는 톤
- 이모지 사용하여 친근감 표현
""",
analysis.getCompletionRate() * 100,
analysis.getCompletedMissionsCount(),
analysis.getTotalMissionsCount(),
analysis.getStreakDays(),
analysis.getMotivationType(),
analysis.getUrgencyLevel(),
analysis.getWeeklyCompletionRate() * 100,
analysis.getFailurePoints().isEmpty() ? "없음" : String.join(", ", analysis.getFailurePoints()),
getMotivationStyleDescription(analysis.getMotivationType())
);
}
/**
* AI 응답을 포맷팅합니다.
*
* @param aiResponse AI 응답
* @return 포맷팅된 메시지
*/
private String formatEncouragementResponse(String aiResponse) {
// AI 응답에서 불필요한 부분 제거 및 길이 제한
String cleaned = aiResponse.trim()
.replaceAll("\\n+", " ")
.replaceAll("\\s+", " ");
if (cleaned.length() > 100) {
cleaned = cleaned.substring(0, 97) + "...";
}
return cleaned;
}
/**
* 기본 메시지를 사용합니다.
*
* @param analysis 진행 분석
* @return 기본 독려 메시지
*/
private String useFallbackMessage(ProgressAnalysis analysis) {
return switch (analysis.getMotivationType()) {
case ACHIEVEMENT -> String.format("🎯 현재 %.0f%% 달성! 목표까지 조금만 더 화이팅!",
analysis.getCompletionRate() * 100);
case HABIT_FORMATION -> String.format("💪 %d일 연속 도전 중! 습관 만들기까지 파이팅!",
analysis.getStreakDays());
case SOCIAL -> "👥 함께라면 더 멀리 갈 수 있어요! 오늘도 건강한 하루 만들어봐요!";
case HEALTH_BENEFIT -> "🌟 건강한 변화는 작은 실천에서 시작됩니다. 오늘 한 걸음 더!";
};
}
/**
* 동기부여 스타일 설명을 반환합니다.
*
* @param motivationType 동기부여 유형
* @return 스타일 설명
*/
private String getMotivationStyleDescription(com.healthsync.motivator.enums.MotivationType motivationType) {
return switch (motivationType) {
case ACHIEVEMENT -> "성취감 중심";
case HABIT_FORMATION -> "습관 형성 중심";
case SOCIAL -> "사회적 동기 중심";
case HEALTH_BENEFIT -> "건강 효과 중심";
};
}
}
@@ -0,0 +1,150 @@
package com.healthsync.motivator.domain.services;
import com.healthsync.motivator.dto.*;
import com.healthsync.motivator.enums.MotivationType;
import com.healthsync.motivator.enums.UrgencyLevel;
import com.healthsync.motivator.enums.EngagementLevel;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* 사용자 분석을 담당하는 도메인 서비스입니다.
*
* @author healthsync-team
* @version 1.0
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class UserAnalysisDomainService {
/**
* 미션 진행 상황을 분석합니다.
*
* @param userId 사용자 ID
* @param missionsStatus 미션 상태 목록
* @param dailyProgress 일일 진행 상황
* @return 진행 분석 결과
*/
public ProgressAnalysis analyzeMissionProgress(String userId, List<MissionStatus> missionsStatus, DailyProgress dailyProgress) {
log.info("미션 진행 상황 분석: userId={}, missionCount={}", userId, missionsStatus.size());
// 진행률 계산
long completedCount = missionsStatus.stream().mapToLong(ms -> ms.isCompleted() ? 1 : 0).sum();
double completionRate = (double) completedCount / missionsStatus.size();
// 실패 포인트 식별
List<String> failurePoints = identifyFailurePoints(missionsStatus);
// 진행 패턴 계산
String progressPattern = calculateProgressPatterns(dailyProgress);
// 동기부여 유형 결정
MotivationType motivationType = determineMotivationType(completionRate, progressPattern);
// 긴급도 수준 결정
UrgencyLevel urgencyLevel = determineUrgencyLevel(completionRate, failurePoints.size());
// 참여도 수준 결정
EngagementLevel engagementLevel = determineEngagementLevel(dailyProgress);
return ProgressAnalysis.builder()
.userId(userId)
.completionRate(completionRate)
.completedMissionsCount((int) completedCount)
.totalMissionsCount(missionsStatus.size())
.failurePoints(failurePoints)
.progressPattern(progressPattern)
.motivationType(motivationType)
.urgencyLevel(urgencyLevel)
.engagementLevel(engagementLevel)
.streakDays(dailyProgress.getCurrentStreak())
.build();
}
/**
* 실패 포인트를 식별합니다.
*
* @param missionsStatus 미션 상태 목록
* @return 실패 포인트 목록
*/
private List<String> identifyFailurePoints(List<MissionStatus> missionsStatus) {
return missionsStatus.stream()
.filter(ms -> !ms.isCompleted())
.map(MissionStatus::getMissionId)
.toList();
}
/**
* 진행 패턴을 계산합니다.
*
* @param dailyProgress 일일 진행 상황
* @return 진행 패턴
*/
private String calculateProgressPatterns(DailyProgress dailyProgress) {
if (dailyProgress.getCurrentStreak() >= 7) {
return "consistent_high_performer";
} else if (dailyProgress.getCurrentStreak() >= 3) {
return "steady_improver";
} else if (dailyProgress.getWeeklyCompletionRate() >= 0.7) {
return "weekend_warrior";
} else {
return "needs_support";
}
}
/**
* 동기부여 유형을 결정합니다.
*
* @param completionRate 완료율
* @param progressPattern 진행 패턴
* @return 동기부여 유형
*/
private MotivationType determineMotivationType(double completionRate, String progressPattern) {
if (completionRate >= 0.8) {
return MotivationType.ACHIEVEMENT;
} else if (completionRate >= 0.5) {
return MotivationType.HABIT_FORMATION;
} else if ("weekend_warrior".equals(progressPattern)) {
return MotivationType.SOCIAL;
} else {
return MotivationType.HEALTH_BENEFIT;
}
}
/**
* 긴급도 수준을 결정합니다.
*
* @param completionRate 완료율
* @param failureCount 실패 개수
* @return 긴급도 수준
*/
private UrgencyLevel determineUrgencyLevel(double completionRate, int failureCount) {
if (completionRate < 0.3 || failureCount >= 3) {
return UrgencyLevel.HIGH;
} else if (completionRate < 0.6 || failureCount >= 2) {
return UrgencyLevel.MEDIUM;
} else {
return UrgencyLevel.LOW;
}
}
/**
* 참여도 수준을 결정합니다.
*
* @param dailyProgress 일일 진행 상황
* @return 참여도 수준
*/
private EngagementLevel determineEngagementLevel(DailyProgress dailyProgress) {
if (dailyProgress.getWeeklyCompletionRate() >= 0.8) {
return EngagementLevel.HIGH;
} else if (dailyProgress.getWeeklyCompletionRate() >= 0.5) {
return EngagementLevel.MEDIUM;
} else {
return EngagementLevel.LOW;
}
}
}
@@ -0,0 +1,37 @@
package com.healthsync.motivator.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.util.List;
/**
* 배치 알림 요청 DTO 클래스입니다.
*
* @author healthsync-team
* @version 1.0
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "배치 알림 요청")
public class BatchNotificationRequest {
@NotBlank(message = "트리거 시간은 필수입니다.")
@Schema(description = "트리거 시간")
private String triggerTime;
@NotNull(message = "대상 사용자 목록은 필수입니다.")
@Schema(description = "대상 사용자 ID 목록")
private List<String> targetUsers;
@NotBlank(message = "알림 유형은 필수입니다.")
@Schema(description = "알림 유형")
private String notificationType;
}
@@ -0,0 +1,36 @@
package com.healthsync.motivator.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 배치 알림 응답 DTO 클래스입니다.
*
* @author healthsync-team
* @version 1.0
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "배치 알림 응답")
public class BatchNotificationResponse {
@Schema(description = "배치 ID")
private String batchId;
@Schema(description = "처리된 사용자 수")
private int processedCount;
@Schema(description = "성공한 알림 수")
private int successCount;
@Schema(description = "실패한 알림 수")
private int failedCount;
@Schema(description = "다음 스케줄 시간")
private String nextScheduledTime;
}
@@ -0,0 +1,33 @@
package com.healthsync.motivator.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 배치 처리 결과 DTO 클래스입니다.
*
* @author healthsync-team
* @version 1.0
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "배치 처리 결과")
public class BatchProcessingResult {
@Schema(description = "배치 ID")
private String batchId;
@Schema(description = "처리된 수")
private int processedCount;
@Schema(description = "성공한 수")
private int successCount;
@Schema(description = "실패한 수")
private int failedCount;
}
@@ -0,0 +1,33 @@
package com.healthsync.motivator.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 일일 진행 상황 DTO 클래스입니다.
*
* @author healthsync-team
* @version 1.0
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "일일 진행 상황")
public class DailyProgress {
@Schema(description = "현재 연속 달성 일수")
private int currentStreak;
@Schema(description = "주간 완료율")
private double weeklyCompletionRate;
@Schema(description = "오늘 완료된 미션 수")
private int todayCompletedCount;
@Schema(description = "오늘 총 미션 수")
private int todayTotalCount;
}
@@ -0,0 +1,33 @@
package com.healthsync.motivator.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import java.util.List;
/**
* 독려 메시지 요청 DTO 클래스입니다.
*
* @author healthsync-team
* @version 1.0
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "독려 메시지 요청")
public class EncouragementRequest {
@NotBlank(message = "사용자 ID는 필수입니다.")
@Schema(description = "사용자 ID")
private String userId;
@NotEmpty(message = "미션 상태 정보는 필수입니다.")
@Valid
@Schema(description = "미션 상태 목록")
private List<MissionStatus> missionsStatus;
}
@@ -0,0 +1,36 @@
package com.healthsync.motivator.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 독려 메시지 응답 DTO 클래스입니다.
*
* @author healthsync-team
* @version 1.0
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "독려 메시지 응답")
public class EncouragementResponse {
@Schema(description = "독려 메시지")
private String message;
@Schema(description = "동기부여 유형")
private String motivationType;
@Schema(description = "최적 타이밍")
private String timing;
@Schema(description = "개인화된 팁")
private String personalizedTip;
@Schema(description = "우선순위")
private String priority;
}
@@ -0,0 +1,27 @@
package com.healthsync.motivator.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 미션 상태 DTO 클래스입니다.
*
* @author healthsync-team
* @version 1.0
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "미션 상태")
public class MissionStatus {
@Schema(description = "미션 ID")
private String missionId;
@Schema(description = "완료 여부")
private boolean completed;
}
@@ -0,0 +1,59 @@
package com.healthsync.motivator.dto;
import com.healthsync.motivator.enums.MotivationType;
import com.healthsync.motivator.enums.UrgencyLevel;
import com.healthsync.motivator.enums.EngagementLevel;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* 진행 분석 DTO 클래스입니다.
*
* @author healthsync-team
* @version 1.0
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "진행 분석")
public class ProgressAnalysis {
@Schema(description = "사용자 ID")
private String userId;
@Schema(description = "완료율")
private double completionRate;
@Schema(description = "완료된 미션 수")
private int completedMissionsCount;
@Schema(description = "전체 미션 수")
private int totalMissionsCount;
@Schema(description = "실패 포인트 목록")
private List<String> failurePoints;
@Schema(description = "진행 패턴")
private String progressPattern;
@Schema(description = "동기부여 유형")
private MotivationType motivationType;
@Schema(description = "긴급도 수준")
private UrgencyLevel urgencyLevel;
@Schema(description = "참여도 수준")
private EngagementLevel engagementLevel;
@Schema(description = "연속 달성 일수")
private int streakDays;
@Schema(description = "주간 완료율")
private double weeklyCompletionRate;
}
@@ -0,0 +1,35 @@
package com.healthsync.motivator.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 사용자 미션 상태 DTO 클래스입니다.
*
* @author healthsync-team
* @version 1.0
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "사용자 미션 상태")
public class UserMissionStatus {
@Schema(description = "사용자 ID")
private String userId;
@Schema(description = "총 미션 수")
private int totalMissions;
@Schema(description = "완료된 미션 수")
private int completedMissions;
@Schema(description = "마지막 활동 시간")
private LocalDateTime lastActiveTime;
}
@@ -0,0 +1,41 @@
package com.healthsync.motivator.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 사용자 알림 컨텍스트 DTO 클래스입니다.
*
* @author healthsync-team
* @version 1.0
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "사용자 알림 컨텍스트")
public class UserNotificationContext {
@Schema(description = "사용자 ID")
private String userId;
@Schema(description = "완료율")
private double completionRate;
@Schema(description = "연속 실패 일수")
private int consecutiveFailures;
@Schema(description = "마지막 활동 시간")
private LocalDateTime lastActiveTime;
@Schema(description = "총 미션 수")
private int totalMissions;
@Schema(description = "완료된 미션 수")
private int completedMissions;
}
@@ -0,0 +1,25 @@
package com.healthsync.motivator.enums;
/**
* 참여도 수준을 나타내는 열거형입니다.
*
* @author healthsync-team
* @version 1.0
*/
public enum EngagementLevel {
/**
* 높은 참여도
*/
HIGH,
/**
* 보통 참여도
*/
MEDIUM,
/**
* 낮은 참여도
*/
LOW
}
@@ -0,0 +1,30 @@
package com.healthsync.motivator.enums;
/**
* 동기부여 유형을 나타내는 열거형입니다.
*
* @author healthsync-team
* @version 1.0
*/
public enum MotivationType {
/**
* 성취감 중심 동기부여
*/
ACHIEVEMENT,
/**
* 습관 형성 중심 동기부여
*/
HABIT_FORMATION,
/**
* 사회적 동기부여
*/
SOCIAL,
/**
* 건강 효과 중심 동기부여
*/
HEALTH_BENEFIT
}
@@ -0,0 +1,25 @@
package com.healthsync.motivator.enums;
/**
* 긴급도 수준을 나타내는 열거형입니다.
*
* @author healthsync-team
* @version 1.0
*/
public enum UrgencyLevel {
/**
* 높은 긴급도
*/
HIGH,
/**
* 보통 긴급도
*/
MEDIUM,
/**
* 낮은 긴급도
*/
LOW
}
@@ -0,0 +1,56 @@
package com.healthsync.motivator.infrastructure.adapters;
import com.healthsync.motivator.dto.EncouragementResponse;
import com.healthsync.motivator.infrastructure.ports.CachePort;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import java.time.Duration;
/**
* Redis 캐시와의 통신을 담당하는 어댑터 클래스입니다.
* Clean Architecture의 Infrastructure 계층에 해당합니다.
*
* @author healthsync-team
* @version 1.0
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class CacheAdapter implements CachePort {
private final RedisTemplate<String, Object> redisTemplate;
@Override
public EncouragementResponse getCachedEncouragementMessage(String cacheKey) {
try {
return (EncouragementResponse) redisTemplate.opsForValue().get(cacheKey);
} catch (Exception e) {
log.warn("독려 메시지 캐시 조회 실패: key={}, error={}", cacheKey, e.getMessage());
return null;
}
}
@Override
public void cacheEncouragementMessage(String cacheKey, EncouragementResponse response) {
try {
redisTemplate.opsForValue().set(cacheKey, response, Duration.ofMinutes(30));
log.info("독려 메시지 캐시 저장: key={}", cacheKey);
} catch (Exception e) {
log.warn("독려 메시지 캐시 저장 실패: key={}, error={}", cacheKey, e.getMessage());
}
}
@Override
public void storeBatchMessage(String userId, String message) {
try {
String batchKey = "batch_message:" + userId;
redisTemplate.opsForValue().set(batchKey, message, Duration.ofHours(24));
log.info("배치 메시지 저장: userId={}", userId);
} catch (Exception e) {
log.warn("배치 메시지 저장 실패: userId={}, error={}", userId, e.getMessage());
}
}
}
@@ -0,0 +1,71 @@
package com.healthsync.motivator.infrastructure.adapters;
import com.healthsync.common.exception.ExternalApiException;
import com.healthsync.motivator.infrastructure.ports.ClaudeApiPort;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;
/**
* Claude API와의 통신을 담당하는 어댑터 클래스입니다.
* Clean Architecture의 Infrastructure 계층에 해당합니다.
*
* @author healthsync-team
* @version 1.0
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ClaudeApiAdapter implements ClaudeApiPort {
private final WebClient webClient = WebClient.builder().build();
@Value("${claude.api.url}")
private String claudeApiUrl;
@Value("${claude.api.key}")
private String claudeApiKey;
@Value("${claude.api.model}")
private String claudeModel;
@Value("${claude.api.max-tokens}")
private int maxTokens;
@Override
public String callClaudeApi(String prompt) {
try {
log.info("Claude API 호출: promptLength={}", prompt.length());
// 실제 구현에서는 Claude API 호출
// Mock 응답 반환
return generateMockMotivationMessage(prompt);
} catch (Exception e) {
log.error("Claude API 호출 실패: error={}", e.getMessage(), e);
throw new ExternalApiException("AI 메시지 생성에 실패했습니다.");
}
}
/**
* Mock 동기부여 메시지를 생성합니다.
*
* @param prompt 프롬프트
* @return Mock 동기부여 메시지
*/
private String generateMockMotivationMessage(String prompt) {
if (prompt.contains("완료율") && prompt.contains("")) {
return "🌟 포기하지 마세요! 작은 변화가 큰 결과를 만듭니다. 오늘 한 가지만 더 해볼까요?";
} else if (prompt.contains("연속")) {
return "🔥 연속 달성 중이시군요! 이 멋진 흐름을 계속 이어가봐요!";
} else if (prompt.contains("배치")) {
return "💪 새로운 하루가 시작됐어요! 건강한 습관으로 오늘도 화이팅!";
} else if (prompt.contains("실패")) {
return "🌅 괜찮아요! 다시 시작하는 것이 중요해요. 오늘부터 새롭게 도전해봐요!";
} else {
return "✨ 건강한 하루 만들기, 함께 해요! 작은 실천이 큰 변화를 만듭니다!";
}
}
}
@@ -0,0 +1,41 @@
package com.healthsync.motivator.infrastructure.adapters;
import com.healthsync.motivator.infrastructure.ports.EventPublisherPort;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
/**
* 이벤트 발행을 담당하는 어댑터 클래스입니다.
* Clean Architecture의 Infrastructure 계층에 해당합니다.
*
* @author healthsync-team
* @version 1.0
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class EventPublisherAdapter implements EventPublisherPort {
// 실제 구현에서는 Spring Cloud Stream 또는 Azure Service Bus 사용
// private final ServiceBusTemplate serviceBusTemplate;
@Override
public void publishEncouragementSentEvent(String userId, String messageType) {
try {
log.info("독려 메시지 전송 이벤트 발행: userId={}, messageType={}", userId, messageType);
// 실제 구현에서는 이벤트 브로커에 발행
// EncouragementSentEvent event = EncouragementSentEvent.builder()
// .userId(userId)
// .messageType(messageType)
// .timestamp(LocalDateTime.now())
// .build();
// serviceBusTemplate.send("encouragement-sent-topic", event);
log.info("독려 메시지 전송 이벤트 발행 완료: userId={}", userId);
} catch (Exception e) {
log.error("독려 메시지 전송 이벤트 발행 실패: userId={}, error={}", userId, e.getMessage(), e);
}
}
}
@@ -0,0 +1,75 @@
package com.healthsync.motivator.infrastructure.adapters;
import com.healthsync.common.exception.ExternalApiException;
import com.healthsync.motivator.dto.DailyProgress;
import com.healthsync.motivator.dto.UserMissionStatus;
import com.healthsync.motivator.infrastructure.ports.GoalServicePort;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;
import java.time.LocalDateTime;
import java.util.List;
import java.util.stream.IntStream;
/**
* Goal Service와의 통신을 담당하는 어댑터 클래스입니다.
* Clean Architecture의 Infrastructure 계층에 해당합니다.
*
* @author healthsync-team
* @version 1.0
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class GoalServiceAdapter implements GoalServicePort {
private final WebClient webClient = WebClient.builder().build();
@Value("${services.goal-service.url}")
private String goalServiceUrl;
@Override
public DailyProgress getUserDailyProgress(String userId) {
try {
log.info("Goal Service 사용자 일일 진행 상황 조회: userId={}", userId);
// 실제 구현에서는 Goal Service API 호출
// Mock 데이터 반환
return DailyProgress.builder()
.currentStreak(5)
.weeklyCompletionRate(0.75)
.todayCompletedCount(3)
.todayTotalCount(5)
.build();
} catch (Exception e) {
log.error("Goal Service 일일 진행 상황 조회 실패: userId={}, error={}", userId, e.getMessage(), e);
throw new ExternalApiException("사용자 일일 진행 상황 조회에 실패했습니다.");
}
}
@Override
public List<UserMissionStatus> getAllUsersWithActiveMissions() {
try {
log.info("Goal Service 활성 미션 사용자 조회");
// 실제 구현에서는 Goal Service API 호출
// Mock 데이터 반환
return IntStream.range(1, 11)
.mapToObj(i -> UserMissionStatus.builder()
.userId("user_" + i)
.totalMissions(5)
.completedMissions(i % 3 + 1) // 1-3개 완료
.lastActiveTime(LocalDateTime.now().minusHours(i))
.build())
.toList();
} catch (Exception e) {
log.error("Goal Service 활성 미션 사용자 조회 실패: error={}", e.getMessage(), e);
throw new ExternalApiException("활성 미션 사용자 조회에 실패했습니다.");
}
}
}
@@ -0,0 +1,103 @@
package com.healthsync.motivator.infrastructure.batch;
import com.healthsync.motivator.application_services.MotivationUseCase;
import com.healthsync.motivator.dto.BatchNotificationRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.core.step.builder.StepBuilder;
import org.springframework.batch.core.step.tasklet.Tasklet;
import org.springframework.batch.repeat.RepeatStatus;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.PlatformTransactionManager;
import java.time.LocalDateTime;
import java.util.Collections;
/**
* 알림 배치 작업을 정의하는 클래스입니다.
*
* @author healthsync-team
* @version 1.0
*/
@Slf4j
@Configuration
@Component
@RequiredArgsConstructor
public class NotificationBatchJob {
private final MotivationUseCase motivationUseCase;
/**
* 알림 배치 작업을 정의합니다.
*
* @param jobRepository Job 저장소
* @param transactionManager 트랜잭션 매니저
* @return 알림 배치 Job
*/
@Bean
public Job notificationJob(JobRepository jobRepository, PlatformTransactionManager transactionManager) {
return new JobBuilder("notificationJob", jobRepository)
.start(notificationStep(jobRepository, transactionManager))
.build();
}
/**
* 알림 배치 스텝을 정의합니다.
*
* @param jobRepository Job 저장소
* @param transactionManager 트랜잭션 매니저
* @return 알림 배치 Step
*/
@Bean
public Step notificationStep(JobRepository jobRepository, PlatformTransactionManager transactionManager) {
return new StepBuilder("notificationStep", jobRepository)
.tasklet(notificationTasklet(), transactionManager)
.build();
}
/**
* 알림 배치 태스클릿을 정의합니다.
*
* @return 알림 배치 Tasklet
*/
@Bean
public Tasklet notificationTasklet() {
return (contribution, chunkContext) -> {
log.info("배치 알림 작업 시작");
BatchNotificationRequest request = BatchNotificationRequest.builder()
.triggerTime(LocalDateTime.now().toString())
.targetUsers(Collections.emptyList()) // 전체 사용자 대상
.notificationType("daily_encouragement")
.build();
motivationUseCase.processBatchNotifications(request);
log.info("배치 알림 작업 완료");
return RepeatStatus.FINISHED;
};
}
/**
* 스케줄러를 통한 배치 작업 실행 (매일 오전 9시)
*/
@Scheduled(cron = "0 0 9 * * ?")
public void executeScheduledNotification() {
log.info("스케줄된 배치 알림 실행");
BatchNotificationRequest request = BatchNotificationRequest.builder()
.triggerTime(LocalDateTime.now().toString())
.targetUsers(Collections.emptyList())
.notificationType("scheduled_daily")
.build();
motivationUseCase.processBatchNotifications(request);
}
}
@@ -0,0 +1,104 @@
package com.healthsync.motivator.infrastructure.entities;
import com.healthsync.common.entity.BaseEntity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 알림 로그를 저장하는 엔티티 클래스입니다.
*
* @author healthsync-team
* @version 1.0
*/
@Entity
@Table(name = "notification_logs")
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class NotificationLogEntity extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "user_id", nullable = false)
private String userId;
@Column(name = "mission_id")
private String missionId;
@Column(name = "notification_type", nullable = false)
private String notificationType;
@Column(name = "message", nullable = false, columnDefinition = "TEXT")
private String message;
@Column(name = "delivery_channel")
private String deliveryChannel;
@Column(name = "scheduled_at")
private LocalDateTime scheduledAt;
@Column(name = "sent_at")
private LocalDateTime sentAt;
@Column(name = "delivery_status")
private String deliveryStatus;
@Column(name = "response_action")
private String responseAction;
@Column(name = "response_time")
private LocalDateTime responseTime;
@Column(name = "effectiveness")
private Double effectiveness;
@Column(name = "batch_id")
private String batchId;
@Column(name = "metadata", columnDefinition = "TEXT")
private String metadata;
/**
* 전송 완료 여부를 확인합니다.
*
* @return 전송 완료 여부
*/
public boolean isDelivered() {
return "SENT".equals(deliveryStatus) || "DELIVERED".equals(deliveryStatus);
}
/**
* 전송 완료로 마크합니다.
*/
public void markAsDelivered() {
this.deliveryStatus = "DELIVERED";
this.sentAt = LocalDateTime.now();
}
/**
* 사용자 응답을 기록합니다.
*
* @param action 응답 액션
*/
public void recordResponse(String action) {
this.responseAction = action;
this.responseTime = LocalDateTime.now();
}
/**
* 효과성 점수를 업데이트합니다.
*
* @param score 효과성 점수
*/
public void updateEffectiveness(double score) {
this.effectiveness = score;
}
}
@@ -0,0 +1,37 @@
package com.healthsync.motivator.infrastructure.ports;
import com.healthsync.motivator.dto.EncouragementResponse;
/**
* 캐시와의 통신을 위한 포트 인터페이스입니다.
* Clean Architecture의 Domain 계층에서 정의합니다.
*
* @author healthsync-team
* @version 1.0
*/
public interface CachePort {
/**
* 캐시된 독려 메시지를 조회합니다.
*
* @param cacheKey 캐시 키
* @return 독려 응답 (캐시 미스 시 null)
*/
EncouragementResponse getCachedEncouragementMessage(String cacheKey);
/**
* 독려 메시지를 캐시에 저장합니다.
*
* @param cacheKey 캐시 키
* @param response 독려 응답
*/
void cacheEncouragementMessage(String cacheKey, EncouragementResponse response);
/**
* 배치 메시지를 저장합니다.
*
* @param userId 사용자 ID
* @param message 메시지
*/
void storeBatchMessage(String userId, String message);
}
@@ -0,0 +1,19 @@
package com.healthsync.motivator.infrastructure.ports;
/**
* Claude API와의 통신을 위한 포트 인터페이스입니다.
* Clean Architecture의 Domain 계층에서 정의합니다.
*
* @author healthsync-team
* @version 1.0
*/
public interface ClaudeApiPort {
/**
* Claude API를 호출합니다.
*
* @param prompt 프롬프트
* @return AI 응답
*/
String callClaudeApi(String prompt);
}
@@ -0,0 +1,19 @@
package com.healthsync.motivator.infrastructure.ports;
/**
* 이벤트 발행을 위한 포트 인터페이스입니다.
* Clean Architecture의 Domain 계층에서 정의합니다.
*
* @author healthsync-team
* @version 1.0
*/
public interface EventPublisherPort {
/**
* 독려 메시지 전송 이벤트를 발행합니다.
*
* @param userId 사용자 ID
* @param messageType 메시지 유형
*/
void publishEncouragementSentEvent(String userId, String messageType);
}
@@ -0,0 +1,31 @@
package com.healthsync.motivator.infrastructure.ports;
import com.healthsync.motivator.dto.DailyProgress;
import com.healthsync.motivator.dto.UserMissionStatus;
import java.util.List;
/**
* Goal Service와의 통신을 위한 포트 인터페이스입니다.
* Clean Architecture의 Domain 계층에서 정의합니다.
*
* @author healthsync-team
* @version 1.0
*/
public interface GoalServicePort {
/**
* 사용자의 일일 진행 상황을 조회합니다.
*
* @param userId 사용자 ID
* @return 일일 진행 상황
*/
DailyProgress getUserDailyProgress(String userId);
/**
* 활성 미션이 있는 모든 사용자를 조회합니다.
*
* @return 사용자 미션 상태 목록
*/
List<UserMissionStatus> getAllUsersWithActiveMissions();
}
@@ -0,0 +1,35 @@
package com.healthsync.motivator.infrastructure.repositories;
import com.healthsync.motivator.infrastructure.entities.NotificationLogEntity;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
/**
* 알림 로그를 위한 JPA 리포지토리입니다.
*
* @author healthsync-team
* @version 1.0
*/
@Repository
public interface NotificationLogJpaRepository extends JpaRepository<NotificationLogEntity, Long> {
/**
* 사용자의 알림 로그를 시간 역순으로 조회합니다.
*
* @param userId 사용자 ID
* @param pageable 페이징 정보
* @return 알림 로그 목록
*/
List<NotificationLogEntity> findByUserIdOrderBySentAtDesc(String userId, Pageable pageable);
/**
* 배치 ID로 알림 로그를 조회합니다.
*
* @param batchId 배치 ID
* @return 알림 로그 목록
*/
List<NotificationLogEntity> findByBatchId(String batchId);
}
@@ -0,0 +1,56 @@
package com.healthsync.motivator.infrastructure.repositories;
import com.healthsync.motivator.domain.repositories.NotificationRepository;
import com.healthsync.motivator.infrastructure.entities.NotificationLogEntity;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.List;
/**
* 알림 데이터 저장소 구현체입니다.
* Clean Architecture의 Infrastructure 계층에 해당합니다.
*
* @author healthsync-team
* @version 1.0
*/
@Slf4j
@Repository
@RequiredArgsConstructor
public class NotificationRepositoryImpl implements NotificationRepository {
private final NotificationLogJpaRepository notificationLogJpaRepository;
@Override
public NotificationLogEntity saveNotificationLog(String userId, String missionId, String notificationType, String message) {
NotificationLogEntity entity = NotificationLogEntity.builder()
.userId(userId)
.missionId(missionId)
.notificationType(notificationType)
.message(message)
.scheduledAt(LocalDateTime.now())
.sentAt(LocalDateTime.now())
.deliveryStatus("SENT")
.build();
NotificationLogEntity savedEntity = notificationLogJpaRepository.save(entity);
log.info("알림 로그 저장 완료: userId={}, notificationType={}", userId, notificationType);
return savedEntity;
}
@Override
public List<NotificationLogEntity> findRecentNotificationLogs(String userId, int limit) {
PageRequest pageRequest = PageRequest.of(0, limit, Sort.by(Sort.Direction.DESC, "sentAt"));
return notificationLogJpaRepository.findByUserIdOrderBySentAtDesc(userId, pageRequest);
}
@Override
public List<NotificationLogEntity> findNotificationLogsByBatchId(String batchId) {
return notificationLogJpaRepository.findByBatchId(batchId);
}
}
@@ -0,0 +1,69 @@
package com.healthsync.motivator.interface_adapters.controllers;
import com.healthsync.common.dto.ApiResponse;
import com.healthsync.motivator.application_services.MotivationUseCase;
import com.healthsync.motivator.dto.EncouragementRequest;
import com.healthsync.motivator.dto.EncouragementResponse;
import com.healthsync.motivator.dto.BatchNotificationRequest;
import com.healthsync.motivator.dto.BatchNotificationResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import jakarta.validation.Valid;
/**
* 동기부여 알림 관련 API를 제공하는 컨트롤러입니다.
* Clean Architecture의 Interface Adapter 계층에 해당합니다.
*
* @author healthsync-team
* @version 1.0
*/
@Slf4j
@RestController
@RequestMapping("/api/motivator")
@RequiredArgsConstructor
@Tag(name = "동기부여 알림", description = "사용자 동기부여 메시지 생성 및 배치 알림 API")
public class NotificationController {
private final MotivationUseCase motivationUseCase;
/**
* 미션 독려 메시지를 생성합니다.
*
* @param request 독려 요청
* @return 독려 메시지
*/
@PostMapping("/notifications/encouragement")
@Operation(summary = "미션 독려 메시지 생성", description = "사용자의 미션 진행 상황을 바탕으로 개인화된 독려 메시지를 생성합니다")
public ResponseEntity<ApiResponse<EncouragementResponse>> generateEncouragementMessage(@Valid @RequestBody EncouragementRequest request) {
log.info("독려 메시지 생성 요청: userId={}, missionCount={}", request.getUserId(), request.getMissionsStatus().size());
EncouragementResponse response = motivationUseCase.generateEncouragementMessage(request);
log.info("독려 메시지 생성 완료: userId={}, motivationType={}", request.getUserId(), response.getMotivationType());
return ResponseEntity.ok(ApiResponse.success("독려 메시지가 생성되었습니다.", response));
}
/**
* 배치 알림을 처리합니다.
*
* @param request 배치 알림 요청
* @return 배치 처리 결과
*/
@PostMapping("/batch/notifications")
@Operation(summary = "주기적 AI 알림 트리거", description = "전체 사용자를 대상으로 배치 형태의 동기부여 알림을 처리합니다")
public ResponseEntity<ApiResponse<BatchNotificationResponse>> processBatchNotifications(@Valid @RequestBody BatchNotificationRequest request) {
log.info("배치 알림 처리 요청: triggerTime={}, targetUsers={}, type={}",
request.getTriggerTime(), request.getTargetUsers().size(), request.getNotificationType());
BatchNotificationResponse response = motivationUseCase.processBatchNotifications(request);
log.info("배치 알림 처리 완료: processedCount={}, successCount={}",
response.getProcessedCount(), response.getSuccessCount());
return ResponseEntity.ok(ApiResponse.success("배치 알림 처리가 완료되었습니다.", response));
}
}
@@ -0,0 +1,76 @@
spring:
application:
name: motivator-service
datasource:
url: ${DB_URL:jdbc:postgresql://localhost:5432/healthsync_motivator}
username: ${DB_USERNAME:team1tier}
password: ${DB_PASSWORD:Hi5Jessica!}
driver-class-name: org.postgresql.Driver
jpa:
hibernate:
ddl-auto: ${DDL_AUTO:update}
show-sql: ${SHOW_SQL:false}
properties:
hibernate:
dialect: org.hibernate.dialect.PostgreSQLDialect
format_sql: true
data:
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:HUezXQsxbphIeBy8FV9JDA3WaZDwOozGEAzCaByUk40=}
timeout: 2000ms
lettuce:
pool:
max-active: 8
max-idle: 8
min-idle: 0
batch:
job:
enabled: true
jdbc:
initialize-schema: always
server:
port: ${SERVER_PORT:8085}
# 외부 서비스 URL
services:
goal-service:
url: ${GOAL_SERVICE_URL:http://localhost:8084}
intelligence-service:
url: ${INTELLIGENCE_SERVICE_URL:http://localhost:8083}
# Claude API 설정
claude:
api:
url: ${CLAUDE_API_URL:https://api.anthropic.com/v1/messages}
key: ${CLAUDE_API_KEY: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}
model: ${CLAUDE_MODEL:claude-3-sonnet-20240229}
max-tokens: ${CLAUDE_MAX_TOKENS:512}
# 배치 처리 설정
batch:
notification:
batch-size: ${BATCH_SIZE:100}
max-processing-time: ${MAX_PROCESSING_TIME:PT30M}
retry-attempts: ${RETRY_ATTEMPTS:3}
# JWT 설정
jwt:
secret-key: ${JWT_SECRET:healthsync-secret-key-2024-very-long-secret-key}
access-token-validity: ${JWT_ACCESS_VALIDITY:3600000}
refresh-token-validity: ${JWT_REFRESH_VALIDITY:86400000}
# 로깅 설정
logging:
level:
com.healthsync.motivator: ${LOG_LEVEL:INFO}
org.springframework.web: ${WEB_LOG_LEVEL:INFO}
org.springframework.batch: ${BATCH_LOG_LEVEL:INFO}
pattern:
console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"