feat : initial commit
This commit is contained in:
@@ -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()
|
||||
}
|
||||
+25
@@ -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);
|
||||
}
|
||||
}
|
||||
+216
@@ -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();
|
||||
}
|
||||
}
|
||||
+65
@@ -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;
|
||||
}
|
||||
}
|
||||
+43
@@ -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);
|
||||
}
|
||||
+285
@@ -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 "🎯 조금만 더 힘내세요! 건강한 목표까지 거의 다 왔어요!";
|
||||
}
|
||||
}
|
||||
}
|
||||
+137
@@ -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 -> "건강 효과 중심";
|
||||
};
|
||||
}
|
||||
}
|
||||
+150
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+37
@@ -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;
|
||||
}
|
||||
+36
@@ -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;
|
||||
}
|
||||
+33
@@ -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;
|
||||
}
|
||||
+33
@@ -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;
|
||||
}
|
||||
+36
@@ -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;
|
||||
}
|
||||
+41
@@ -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
|
||||
}
|
||||
+56
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+71
@@ -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 "✨ 건강한 하루 만들기, 함께 해요! 작은 실천이 큰 변화를 만듭니다!";
|
||||
}
|
||||
}
|
||||
}
|
||||
+41
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+75
@@ -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("활성 미션 사용자 조회에 실패했습니다.");
|
||||
}
|
||||
}
|
||||
}
|
||||
+103
@@ -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);
|
||||
}
|
||||
}
|
||||
+104
@@ -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;
|
||||
}
|
||||
}
|
||||
+37
@@ -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);
|
||||
}
|
||||
+19
@@ -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);
|
||||
}
|
||||
+19
@@ -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);
|
||||
}
|
||||
+31
@@ -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();
|
||||
}
|
||||
+35
@@ -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);
|
||||
}
|
||||
+56
@@ -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);
|
||||
}
|
||||
}
|
||||
+69
@@ -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"
|
||||
Reference in New Issue
Block a user