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
+41
View File
@@ -0,0 +1,41 @@
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 = '21'
targetCompatibility = '21'
}
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'
// ✅ WebClient는 Mock에서 사용하지 않으므로 제거 가능하지만 향후 확장을 위해 유지
implementation 'org.springframework.boot:spring-boot-starter-webflux'
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0'
runtimeOnly 'org.postgresql:postgresql'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
tasks.named('test') {
useJUnitPlatform()
}
@@ -0,0 +1,28 @@
package com.healthsync.goal;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
import org.springframework.context.annotation.Bean;
import org.springframework.web.client.RestTemplate;
/**
* Goal Service의 메인 애플리케이션 클래스입니다.
* 사용자의 건강 목표 설정 및 미션 관리 기능을 제공합니다.
*
* @author healthsync-team
* @version 1.0
*/
@SpringBootApplication(scanBasePackages = {"com.healthsync.goal", "com.healthsync.common"})
@ConfigurationPropertiesScan
public class GoalServiceApplication {
public static void main(String[] args) {
SpringApplication.run(GoalServiceApplication.class, args);
}
@Bean
public RestTemplate restTemplate() {
return new RestTemplate();
}
}
@@ -0,0 +1,612 @@
package com.healthsync.goal.application_services;
import com.healthsync.goal.domain.services.GoalDomainService;
import com.healthsync.goal.domain.repositories.GoalRepository;
import com.healthsync.goal.dto.*;
import com.healthsync.goal.infrastructure.entities.MissionCompletionHistoryEntity;
import com.healthsync.goal.infrastructure.entities.UserMissionGoalEntity;
import com.healthsync.goal.infrastructure.ports.*;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* 목표 관리 유스케이스입니다.
* Clean Architecture의 Application Service 계층에 해당합니다.
*
* @author healthsync-team
* @version 1.0
*/
@Slf4j
@Service
@RequiredArgsConstructor
@Transactional
public class GoalUseCase {
private final GoalDomainService goalDomainService;
private final GoalRepository goalRepository;
private final UserServicePort userServicePort;
private final com.healthsync.goal.domain.ports.IntelligenceServicePort intelligenceServicePort;
private final CachePort cachePort;
private final EventPublisherPort eventPublisherPort;
/**
* 미션을 선택하고 목표를 설정합니다.
*
* @param request 미션 선택 요청
* @return 목표 설정 결과
*/
public GoalSetupResponse selectMissions(MissionSelectionRequest request) {
log.info("미션 선택 처리 시작: memberSerialNumber={}", request.getMemberSerialNumber());
// 사용자 정보 검증 (간단히 처리)
// userServicePort.validateUserExists(request.getMemberSerialNumber());
// 미션 선택 검증
goalDomainService.validateMissionSelection(request);
// 기존 활성 미션 비활성화
goalRepository.deactivateCurrentMissions(request.getMemberSerialNumber());
// 새 미션 설정 저장
String goalId = goalRepository.saveGoalSettings(request);
// 선택된 미션 정보 구성 - SelectedMissionDetail에서 정보 추출
List<SelectedMission> selectedMissions = request.getSelectedMissionIds().stream()
.map(missionDetail -> SelectedMission.builder()
.missionId(generateMissionId(missionDetail)) // 미션 ID 생성
.title(missionDetail.getTitle()) // 제목은 SelectedMissionDetail에서
.description(generateMissionDescription(missionDetail)) // 설명 생성
.startDate(LocalDate.now().toString())
.build())
.toList();
// 이벤트 발행 - 미션 ID 목록 추출
List<String> missionIdList = request.getSelectedMissionIds().stream()
.map(this::generateMissionId)
.toList();
eventPublisherPort.publishGoalSetEvent(request.getMemberSerialNumber(), missionIdList);
// 캐시 무효화
cachePort.invalidateUserMissionCache(request.getMemberSerialNumber());
GoalSetupResponse response = GoalSetupResponse.builder()
.goalId(goalId)
.selectedMissions(selectedMissions)
.message("선택하신 미션으로 건강 목표가 설정되었습니다.")
.setupCompletedAt(java.time.LocalDateTime.now().toString())
.build();
log.info("미션 선택 처리 완료: memberSerialNumber={}, goalId={}", request.getMemberSerialNumber(), goalId);
return response;
}
/**
* 설정된 활성 미션을 조회합니다.
*
* @param memberSerialNumber 회원 시리얼 번호
* @return 활성 미션 목록
*/
@Transactional(readOnly = true)
public ActiveMissionsResponse getActiveMissions(String memberSerialNumber) {
log.info("활성 미션 조회: memberSerialNumber={}", memberSerialNumber);
// 캐시 확인
ActiveMissionsResponse cachedResponse = cachePort.getActiveMissions(memberSerialNumber);
if (cachedResponse != null) {
log.info("캐시에서 활성 미션 조회: memberSerialNumber={}", memberSerialNumber);
return cachedResponse;
}
// 활성 미션 조회
List<DailyMission> dailyMissions = goalRepository.findActiveMissionsByUserId(memberSerialNumber);
// 완료 통계 계산
int totalMissions = dailyMissions.size();
int todayCompletedCount = (int) dailyMissions.stream()
.mapToInt(mission -> mission.isCompletedToday() ? 1 : 0)
.sum();
double completionRate = totalMissions > 0 ?
((double) todayCompletedCount / totalMissions) * 100.0 : 0.0;
// 연속 달성 일수 계산
int currentStreak = dailyMissions.stream()
.mapToInt(DailyMission::getStreakDays)
.max()
.orElse(0);
ActiveMissionsResponse response = ActiveMissionsResponse.builder()
.dailyMissions(dailyMissions)
.totalMissions(totalMissions)
.todayCompletedCount(todayCompletedCount)
.completionRate(completionRate)
.currentStreak(currentStreak)
.bestStreak(calculateBestStreak(memberSerialNumber))
.motivationalMessage(generateMotivationalMessage(completionRate))
.build();
// 캐시 저장
cachePort.cacheActiveMissions(memberSerialNumber, response);
log.info("활성 미션 조회 완료: memberSerialNumber={}, totalMissions={}", memberSerialNumber, totalMissions);
return response;
}
/**
* 미션 완료를 처리합니다.
*
* 🎯 핵심 기능:
* 1. 미션 완료 기록 (mission_completion_history 테이블)
* 2. 목표 달성 여부 확인 (daily_completed_count >= daily_target_count)
* 3. 목표 달성 시 HealthSync_Intelligence Python API 호출
*
* 🐍 Python API 연동:
* - 조건: daily_completed_count == daily_target_count
* - 호출: POST /api/intelligence/missions/celebrate
* - 요청: { userId: long, missionId: long }
* - 응답: { congratsMessage: str }
*
* @param missionId 미션 ID
* @param request 미션 완료 요청
* @return 미션 완료 결과 (Python 축하 메시지 포함)
*/
public MissionCompleteResponse completeMission(String missionId, MissionCompleteRequest request) {
log.info("미션 완료 처리: memberSerialNumber={}, missionId={}", request.getMemberSerialNumber(), missionId);
// 미션 완료 기록
goalRepository.recordMissionCompletion(missionId, request);
// 🎯 목표 달성 여부 확인 및 축하 API 호출
CelebrationResponse celebrationResponse = checkAndCelebrateMissionAchievement(missionId, request.getMemberSerialNumber());
// 연속 달성 일수 계산
int newStreakDays = calculateNewStreakDays(request.getMemberSerialNumber(), missionId);
// 총 완료 횟수 조회
int totalCompletedCount = goalRepository.getTotalCompletedCount(request.getMemberSerialNumber(), missionId);
// 성취 메시지 생성 (축하 API 응답 우선 사용)
String achievementMessage = celebrationResponse != null && celebrationResponse.getCongratsMessage() != null
? celebrationResponse.getCongratsMessage()
: generateAchievementMessage(newStreakDays, totalCompletedCount);
// 캐시 무효화
cachePort.invalidateUserMissionCache(request.getMemberSerialNumber());
// 이벤트 발행
eventPublisherPort.publishMissionCompleteEvent(request.getMemberSerialNumber(), missionId, newStreakDays);
MissionCompleteResponse response = MissionCompleteResponse.builder()
.message("미션이 완료되었습니다!")
.status("SUCCESS")
.achievementMessage(achievementMessage)
.newStreakDays(newStreakDays)
.totalCompletedCount(totalCompletedCount)
.earnedPoints(calculateEarnedPoints(newStreakDays))
.build();
log.info("미션 완료 처리 완료: memberSerialNumber={}, missionId={}, streakDays={}",
request.getMemberSerialNumber(), missionId, newStreakDays);
return response;
}
/**
* 🎯 목표 달성 여부를 확인하고 달성시 축하 API를 호출합니다.
*
* @param missionId 미션 ID
* @param memberSerialNumber 회원 시리얼 번호
* @return 축하 응답 (달성하지 않은 경우 null)
*/
private CelebrationResponse checkAndCelebrateMissionAchievement(String missionId, String memberSerialNumber) {
log.info("🎯 [MISSION_ACHIEVEMENT] 목표 달성 여부 확인: memberSerialNumber={}, missionId={}",
memberSerialNumber, missionId);
try {
// 오늘의 미션 완료 이력 조회
boolean isTargetAchieved = goalRepository.isTodayTargetAchieved(memberSerialNumber, missionId);
if (isTargetAchieved) {
log.info("🎉 [MISSION_ACHIEVEMENT] 목표 달성 확인! Python 축하 API 호출: memberSerialNumber={}, missionId={}",
memberSerialNumber, missionId);
try {
// 🔧 Python API 스펙에 맞춘 축하 요청 생성 (userId: long, missionId: long)
CelebrationRequest celebrationRequest = CelebrationRequest.builder()
.userId(Long.parseLong(memberSerialNumber)) // 🔧 String → Long 변환
.missionId(Long.parseLong(missionId)) // 🔧 String → Long 변환 (큰 ID 지원)
.build();
// HealthSync_Intelligence Python Service의 축하 API 호출
return intelligenceServicePort.celebrateMissionAchievement(celebrationRequest);
} catch (NumberFormatException e) {
log.error("❌ [MISSION_ACHIEVEMENT] 숫자 변환 실패: memberSerialNumber={}, missionId={}, error={}",
memberSerialNumber, missionId, e.getMessage());
// 🔧 숫자 변환 실패시 Fallback 축하 메시지 반환
return CelebrationResponse.builder()
.congratsMessage("🎉 목표를 달성하셨습니다! 훌륭해요! 💪✨")
.build();
}
} else {
log.info("📝 [MISSION_ACHIEVEMENT] 목표 미달성: memberSerialNumber={}, missionId={}",
memberSerialNumber, missionId);
return null;
}
} catch (Exception e) {
log.error("❌ [MISSION_ACHIEVEMENT] 목표 달성 확인 중 오류: memberSerialNumber={}, missionId={}, error={}",
memberSerialNumber, missionId, e.getMessage(), e);
return null;
}
}
// 점진적 완료 처리 메서드 추가
private MissionCompleteResponse processIncrementalCompletion(String missionId, MissionCompleteRequest request, UserMissionGoalEntity mission) {
// 오늘 완료 기록 조회/생성
MissionCompletionHistoryEntity todayCompletion = goalRepository.findOrCreateTodayCompletion(
missionId, request.getMemberSerialNumber(), mission.getDailyTargetCount());
// 목표 초과 방지
int currentCount = todayCompletion.getDailyCompletedCount();
int targetCount = todayCompletion.getDailyTargetCount();
if (currentCount >= targetCount) {
throw new IllegalStateException("이미 오늘 목표를 달성했습니다.");
}
// 진행도 증가
int newCount = Math.min(currentCount + request.getIncrementCount(), targetCount);
todayCompletion.setDailyCompletedCount(newCount);
goalRepository.saveMissionCompletion(todayCompletion);
// 결과 계산
boolean isTargetAchieved = newCount >= targetCount;
double achievementRate = (double) newCount / targetCount * 100.0;
// 목표 달성 시 추가 처리
String celebrationMessage = null;
String achievementMessage = null;
int streakDays = 0;
int earnedPoints = 5; // 기본 포인트
if (isTargetAchieved) {
streakDays = goalRepository.calculateStreakDays(request.getMemberSerialNumber(), missionId);
celebrationMessage = "🎉 오늘 목표를 달성했어요!";
achievementMessage = generateAchievementMessage(streakDays, newCount);
earnedPoints += 10; // 목표 달성 보너스
// 이벤트 발행
eventPublisherPort.publishMissionCompleteEvent(request.getMemberSerialNumber(), missionId, streakDays);
}
// 캐시 무효화
cachePort.invalidateUserMissionCache(request.getMemberSerialNumber());
// 응답 생성
return MissionCompleteResponse.builder()
.message(isTargetAchieved ? "🎉 오늘 목표를 달성했어요!" : "좋아요! 계속 진행해보세요!")
.status("SUCCESS")
.achievementMessage(achievementMessage)
.newStreakDays(streakDays)
.totalCompletedCount(goalRepository.getTotalCompletedCount(request.getMemberSerialNumber(), missionId))
.earnedPoints(earnedPoints)
.currentCount(newCount)
.targetCount(targetCount)
.isTargetAchieved(isTargetAchieved)
.achievementRate(achievementRate)
.completionType("INCREMENT")
.celebrationMessage(celebrationMessage)
.build();
}
// 전체 완료 처리 메서드 추가 (기존 로직 유지)
private MissionCompleteResponse processFullCompletion(String missionId, MissionCompleteRequest request, UserMissionGoalEntity mission) {
// 기존 완료 로직 수행
goalRepository.recordMissionCompletion(missionId, request);
// 연속 달성 일수 계산
int newStreakDays = calculateNewStreakDays(request.getMemberSerialNumber(), missionId);
// 총 완료 횟수 조회
int totalCompletedCount = goalRepository.getTotalCompletedCount(request.getMemberSerialNumber(), missionId);
// 성취 메시지 생성
String achievementMessage = generateAchievementMessage(newStreakDays, totalCompletedCount);
// 캐시 무효화 및 이벤트 발행
cachePort.invalidateUserMissionCache(request.getMemberSerialNumber());
eventPublisherPort.publishMissionCompleteEvent(request.getMemberSerialNumber(), missionId, newStreakDays);
// 오늘 완료 상태 조회
MissionCompletionHistoryEntity todayCompletion = goalRepository.findOrCreateTodayCompletion(
missionId, request.getMemberSerialNumber(), mission.getDailyTargetCount());
return MissionCompleteResponse.builder()
.message("미션이 완료되었습니다!")
.status("SUCCESS")
.achievementMessage(achievementMessage)
.newStreakDays(newStreakDays)
.totalCompletedCount(totalCompletedCount)
.earnedPoints(calculateEarnedPoints(newStreakDays))
.currentCount(todayCompletion.getDailyCompletedCount())
.targetCount(todayCompletion.getDailyTargetCount())
.isTargetAchieved(true)
.achievementRate(100.0)
.completionType("FULL")
.celebrationMessage(achievementMessage)
.build();
}
/**
* 미션 달성 이력을 조회합니다.
*
* @param memberSerialNumber 회원 시리얼 번호
* @param startDate 시작일
* @param endDate 종료일
* @param missionIds 미션 ID 목록
* @return 미션 달성 이력
*/
@Transactional(readOnly = true)
public MissionHistoryResponse getMissionHistory(String memberSerialNumber, String startDate, String endDate, String missionIds) {
log.info("미션 이력 조회: memberSerialNumber={}, period={} to {}", memberSerialNumber, startDate, endDate);
// 캐시 키 생성
String cacheKey = String.format("mission_history:%s:%s:%s:%s", memberSerialNumber, startDate, endDate, missionIds);
// 캐시 확인
MissionHistoryResponse cachedResponse = cachePort.getMissionHistory(cacheKey);
if (cachedResponse != null) {
log.info("캐시에서 미션 이력 조회: memberSerialNumber={}", memberSerialNumber);
return cachedResponse;
}
// 기본값 설정
if (startDate == null) startDate = LocalDate.now().minusMonths(1).toString();
if (endDate == null) endDate = LocalDate.now().toString();
// 미션 이력 조회
List<MissionStats> missionStats = goalRepository.findMissionHistoryByPeriod(memberSerialNumber, startDate, endDate, missionIds);
// 실제 데이터를 기반으로 통계 계산
AchievementStats achievementStats = calculateStatsFromMissionData(missionStats);
// 차트 데이터 생성
Map<String, Object> chartData = goalDomainService.generateChartData(missionStats);
// 인사이트 생성
List<String> insights = goalDomainService.analyzeProgressPatterns(missionStats);
MissionHistoryResponse response = MissionHistoryResponse.builder()
.totalAchievementRate(achievementStats.getTotalAchievementRate())
.periodAchievementRate(achievementStats.getPeriodAchievementRate())
.bestStreak(achievementStats.getBestStreak())
.missionStats(missionStats)
.chartData(chartData)
.period(Period.builder()
.startDate(startDate)
.endDate(endDate)
.build())
.insights(insights)
.build();
// 캐시 저장
cachePort.cacheMissionHistory(cacheKey, response);
log.info("미션 이력 조회 완료: memberSerialNumber={}, achievementRate={}", memberSerialNumber, response.getTotalAchievementRate());
return response;
}
/**
* 미션 데이터를 기반으로 달성 통계를 계산합니다.
*
* @param missionStats 미션 통계 목록
* @return 달성 통계
*/
private AchievementStats calculateStatsFromMissionData(List<MissionStats> missionStats) {
if (missionStats.isEmpty()) {
log.info("미션 데이터가 없어 기본값으로 통계 반환");
return AchievementStats.builder()
.totalAchievementRate(0.0)
.periodAchievementRate(0.0)
.bestStreak(0)
.completedDays(0)
.totalDays(0)
.build();
}
// 평균 달성률 계산
double avgAchievementRate = missionStats.stream()
.mapToDouble(MissionStats::getAchievementRate)
.average()
.orElse(0.0);
// 총 완료 일수와 전체 일수 합계
int totalCompletedDays = missionStats.stream()
.mapToInt(MissionStats::getCompletedDays)
.sum();
int totalDays = missionStats.stream()
.mapToInt(MissionStats::getTotalDays)
.sum();
// 최고 연속 달성 계산 (간단한 로직)
int bestStreak = missionStats.stream()
.mapToInt(stat -> (int) (stat.getAchievementRate() / 10)) // 임시 계산
.max()
.orElse(0);
log.info("미션 통계 계산 완료: 평균달성률={}, 총완료일수={}, 전체일수={}", avgAchievementRate, totalCompletedDays, totalDays);
return AchievementStats.builder()
.totalAchievementRate(avgAchievementRate)
.periodAchievementRate(avgAchievementRate)
.bestStreak(bestStreak)
.completedDays(totalCompletedDays)
.totalDays(totalDays)
.build();
}
/**
* 미션을 재설정합니다.
*
* @param request 미션 재설정 요청
* @return 미션 재설정 결과
*/
public MissionResetResponse resetMissions(MissionResetRequest request) {
log.info("미션 재설정: memberSerialNumber={}, reason={}", request.getMemberSerialNumber(), request.getReason());
// 현재 활성 미션 비활성화 (기존 구현 사용)
goalRepository.deactivateCurrentMissions(request.getMemberSerialNumber());
// 새로운 미션 추천 요청 (기존 구현 사용)
List<RecommendedMission> newRecommendations = intelligenceServicePort
.getNewMissionRecommendations(request.getMemberSerialNumber(), request.getReason());
// 캐시 무효화
cachePort.invalidateUserMissionCache(request.getMemberSerialNumber());
// 이벤트 발행 (기존 구현 사용)
eventPublisherPort.publishMissionResetEvent(request.getMemberSerialNumber(), request.getReason());
MissionResetResponse response = MissionResetResponse.builder()
.message("미션이 재설정되었습니다.")
.newRecommendations(newRecommendations)
.resetCompletedAt(java.time.LocalDateTime.now().toString())
.build();
log.info("미션 재설정 완료: memberSerialNumber={}", request.getMemberSerialNumber());
return response;
}
// === Private Helper Methods ===
/**
* SelectedMissionDetail로부터 고유한 미션 ID를 생성합니다.
*/
private String generateMissionId(SelectedMissionDetail missionDetail) {
if (missionDetail == null || missionDetail.getTitle() == null) {
return UUID.randomUUID().toString();
}
// 제목을 기반으로 ID 생성 + UUID 일부 추가 (중복 방지)
String baseId = missionDetail.getTitle()
.replaceAll("[^가-힣a-zA-Z0-9]", "_")
.toLowerCase()
.replaceAll("_+", "_")
.replaceAll("^_|_$", "");
String shortUuid = UUID.randomUUID().toString().substring(0, 8);
return String.format("mission_%s_%s", baseId, shortUuid);
}
/**
* SelectedMissionDetail로부터 미션 설명을 생성합니다.
*/
private String generateMissionDescription(SelectedMissionDetail missionDetail) {
return String.format("%s (일일 %d회) - %s",
missionDetail.getTitle(),
missionDetail.getDaily_target_count(),
missionDetail.getReason());
}
/**
* 기존 메서드들 - 호환성을 위해 유지
*/
private String getMissionTitle(String missionId) {
// 실제 구현에서는 미션 ID로 제목을 조회
return "미션 제목"; // placeholder
}
private String getMissionDescription(String missionId) {
// 실제 구현에서는 미션 ID로 설명을 조회
return "미션 설명"; // placeholder
}
/**
* 기존 GoalUseCase에 있던 메서드들 (기존 구현 유지)
*/
private int calculateNewStreakDays(String memberSerialNumber, String missionId) {
// 기존 구현 로직 유지
return 1; // placeholder - 실제로는 연속 일수 계산
}
private String generateAchievementMessage(int streakDays, int totalCount) {
if (streakDays >= 7) {
return String.format("🔥 대단해요! %d일 연속 달성!", streakDays);
} else if (streakDays >= 3) {
return String.format("💪 좋아요! %d일 연속 달성 중!", streakDays);
} else {
return "🌟 오늘도 목표 달성! 계속 화이팅!";
}
}
private int calculateEarnedPoints(int streakDays) {
// 기본 포인트 + 연속 달성 보너스 (기존 구현)
int basePoints = 10;
int streakBonus = Math.min(streakDays * 2, 50); // 최대 50점
return basePoints + streakBonus;
}
private int calculateBestStreak(String memberSerialNumber) {
// 기존 구현 - Repository에서 조회하지 않고 간단히 처리
return 7; // placeholder
}
private String generateMotivationalMessage(double completionRate) {
if (completionRate >= 80) {
return "🔥 오늘도 대단해요! 이런 페이스로 계속 가세요!";
} else if (completionRate >= 50) {
return "💪 잘하고 있어요! 조금만 더 힘내세요!";
} else {
return "🌱 시작이 반이에요! 작은 걸음부터 차근차근 해봐요!";
}
}
private int calculateEarnedPoints(String missionId, int streakDays) {
// 오버로드된 메서드 - 위의 calculateEarnedPoints(int)를 호출
return calculateEarnedPoints(streakDays);
}
private double calculateTotalAchievementRate(List<MissionStats> missionStats) {
if (missionStats.isEmpty()) return 0.0;
return missionStats.stream()
.mapToDouble(MissionStats::getAchievementRate)
.average()
.orElse(0.0);
}
private double calculatePeriodAchievementRate(List<MissionStats> missionStats) {
// 기간별 달성률 계산 로직
return calculateTotalAchievementRate(missionStats);
}
private Object generateChartData(List<MissionStats> missionStats, String startDate, String endDate) {
// 차트 데이터 생성 로직
return new Object(); // placeholder
}
private List<String> generateInsights(List<MissionStats> missionStats, double achievementRate) {
List<String> insights = new java.util.ArrayList<>();
if (achievementRate >= 80) {
insights.add("훌륭한 성과를 보이고 있습니다!");
} else if (achievementRate >= 60) {
insights.add("꾸준히 목표를 향해 나아가고 있어요.");
} else {
insights.add("조금 더 꾸준함이 필요해 보여요.");
}
return insights;
}
}
@@ -0,0 +1,73 @@
package com.healthsync.goal.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;
/**
* Goal Service의 보안 설정을 관리하는 클래스입니다.
*
* @author healthsync-team
* @version 1.0
*/
@Configuration
@EnableWebSecurity
public class GoalSecurityConfig {
/**
* 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
// Swagger UI 관련 경로들 모두 허용
.requestMatchers("/swagger-ui/**", "/swagger-ui.html").permitAll()
.requestMatchers("/v3/api-docs/**", "/api-docs/**").permitAll()
.requestMatchers("/webjars/**").permitAll()
.requestMatchers("/swagger-resources/**").permitAll()
// Actuator 허용
.requestMatchers("/actuator/**").permitAll()
// 🎯 API 경로들 허용 (개발용)
.requestMatchers("/api/**").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,88 @@
// goal-service/src/main/java/com/healthsync/goal/config/SwaggerAccessController.java
package com.healthsync.goal.config;
import org.springframework.core.io.Resource;
import org.springframework.core.io.ResourceLoader;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
/**
* Swagger UI 접근을 위한 경로 처리 컨트롤러입니다.
* Ingress에서 /api/goals 경로로 들어오는 요청을 처리합니다.
*
* @author healthsync-team
* @version 1.0
*/
@Slf4j
@Controller
@RequiredArgsConstructor
public class SwaggerAccessController {
/**
* /api/goals/swagger-ui.html 요청을 /swagger-ui.html로 리다이렉트합니다.
*/
@GetMapping("/api/goals/swagger-ui.html")
public String redirectToSwaggerUi(HttpServletRequest request) {
log.info("Swagger UI 접근 요청: {}", request.getRequestURI());
return "redirect:/swagger-ui.html";
}
/**
* /api/goals/swagger-ui/** 요청을 /swagger-ui/**로 리다이렉트합니다.
*/
@GetMapping("/api/goals/swagger-ui/**")
public String redirectToSwaggerUiResources(HttpServletRequest request) {
String originalPath = request.getRequestURI();
String redirectPath = originalPath.replace("/api/goals", "");
log.info("Swagger UI 리소스 리다이렉트: {} -> {}", originalPath, redirectPath);
return "redirect:" + redirectPath;
}
/**
* /api/goals/v3/api-docs 요청을 /v3/api-docs로 리다이렉트합니다.
*/
@GetMapping("/api/goals/v3/api-docs")
public String redirectToApiDocs(HttpServletRequest request) {
log.info("API Docs 접근 요청: {}", request.getRequestURI());
return "redirect:/v3/api-docs";
}
/**
* /api/goals/v3/api-docs/** 요청을 /v3/api-docs/**로 리다이렉트합니다.
*/
@GetMapping("/api/goals/v3/api-docs/**")
public String redirectToApiDocsResources(HttpServletRequest request) {
String originalPath = request.getRequestURI();
String redirectPath = originalPath.replace("/api/goals", "");
log.info("API Docs 리소스 리다이렉트: {} -> {}", originalPath, redirectPath);
return "redirect:" + redirectPath;
}
/**
* /api/goals/actuator/health 요청을 /actuator/health로 리다이렉트합니다.
*/
@GetMapping("/api/goals/actuator/health")
public String redirectToActuatorHealth(HttpServletRequest request) {
log.info("Actuator Health 접근 요청: {}", request.getRequestURI());
return "redirect:/actuator/health";
}
/**
* /api/goals/actuator/** 요청을 /actuator/**로 리다이렉트합니다.
*/
@GetMapping("/api/goals/actuator/**")
public String redirectToActuatorResources(HttpServletRequest request) {
String originalPath = request.getRequestURI();
String redirectPath = originalPath.replace("/api/goals", "");
log.info("Actuator 리소스 리다이렉트: {} -> {}", originalPath, redirectPath);
return "redirect:" + redirectPath;
}
}
@@ -0,0 +1,26 @@
package com.healthsync.goal.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.reactive.function.client.WebClient;
/**
* WebClient 설정 클래스입니다.
*
* @author healthsync-team
* @version 1.0
*/
@Configuration
public class WebClientConfig {
/**
* WebClient Bean을 생성합니다.
*
* @return WebClient 인스턴스
*/
@Bean
public WebClient webClient() {
return WebClient.builder()
.build();
}
}
@@ -0,0 +1,99 @@
package com.healthsync.goal.domain.repositories;
import com.healthsync.goal.dto.*;
import com.healthsync.goal.infrastructure.entities.MissionCompletionHistoryEntity;
import com.healthsync.goal.infrastructure.entities.UserMissionGoalEntity;
import java.util.List;
/**
* 목표 데이터 저장소 인터페이스입니다.
* Clean Architecture의 Domain 계층에서 정의합니다.
*
* @author healthsync-team
* @version 1.0
*/
public interface GoalRepository {
/**
* 목표 설정을 저장합니다.
*
* @param request 미션 선택 요청
* @return 목표 ID
*/
String saveGoalSettings(MissionSelectionRequest request);
/**
* 사용자의 활성 미션을 조회합니다.
*
* @param memberSerialNumber 회원 시리얼 번호
* @return 활성 미션 목록
*/
List<DailyMission> findActiveMissionsByUserId(String memberSerialNumber);
/**
* 현재 미션을 비활성화합니다.
*
* @param memberSerialNumber 회원 시리얼 번호
*/
void deactivateCurrentMissions(String memberSerialNumber);
/**
* 미션 완료를 기록합니다.
*
* @param missionId 미션 ID
* @param request 미션 완료 요청
*/
void recordMissionCompletion(String missionId, MissionCompleteRequest request);
/**
* 총 완료 횟수를 조회합니다.
*
* @param memberSerialNumber 회원 시리얼 번호
* @param missionId 미션 ID
* @return 총 완료 횟수
*/
int getTotalCompletedCount(String memberSerialNumber, String missionId);
/**
* 기간별 미션 이력을 조회합니다.
*
* @param memberSerialNumber 회원 시리얼 번호
* @param startDate 시작일
* @param endDate 종료일
* @param missionIds 미션 ID 목록
* @return 미션 통계 목록
*/
List<MissionStats> findMissionHistoryByPeriod(String memberSerialNumber, String startDate, String endDate, String missionIds);
/**
* 미션 ID와 사용자로 미션을 조회합니다.
*/
UserMissionGoalEntity findMissionByIdAndUser(String missionId, String memberSerialNumber);
/**
* 오늘의 완료 기록을 조회하거나 생성합니다.
*/
MissionCompletionHistoryEntity findOrCreateTodayCompletion(String missionId, String memberSerialNumber, Integer dailyTargetCount);
/**
* 미션 완료 기록을 저장합니다.
*/
void saveMissionCompletion(MissionCompletionHistoryEntity completion);
/**
* 연속 달성일수를 계산합니다.
*/
int calculateStreakDays(String memberSerialNumber, String missionId);
/**
* 오늘 해당 미션의 목표를 달성했는지 확인합니다.
*
* @param memberSerialNumber 회원 시리얼 번호
* @param missionId 미션 ID
* @return 목표 달성 여부
*/
boolean isTodayTargetAchieved(String memberSerialNumber, String missionId);
}
@@ -0,0 +1,120 @@
package com.healthsync.goal.domain.services;
import com.healthsync.common.exception.ValidationException;
import com.healthsync.goal.dto.*;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 목표 관련 비즈니스 로직을 처리하는 도메인 서비스입니다.
*
* @author healthsync-team
* @version 1.0
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class GoalDomainService {
/**
* 미션 선택을 검증합니다.
*
* @param request 미션 선택 요청
*/
public void validateMissionSelection(MissionSelectionRequest request) {
if (request.getSelectedMissionIds() == null || request.getSelectedMissionIds().isEmpty()) {
throw new ValidationException("최소 1개 이상의 미션을 선택해야 합니다.");
}
if (request.getSelectedMissionIds().size() > 5) {
throw new ValidationException("최대 5개까지 미션을 선택할 수 있습니다.");
}
// 중복 미션 검사
if (request.getSelectedMissionIds().size() != request.getSelectedMissionIds().stream().distinct().count()) {
throw new ValidationException("중복된 미션이 선택되었습니다.");
}
log.info("미션 선택 검증 완료: memberSerialNumber={}, missionCount={}", request.getMemberSerialNumber(), request.getSelectedMissionIds().size());
}
/**
* 미션 완료를 검증합니다.
*
* @param missionId 미션 ID
* @param userId 사용자 ID
*/
public void validateMissionCompletion(String missionId, String userId) {
if (missionId == null || missionId.trim().isEmpty()) {
throw new ValidationException("미션 ID가 필요합니다.");
}
if (userId == null || userId.trim().isEmpty()) {
throw new ValidationException("사용자 ID가 필요합니다.");
}
log.info("미션 완료 검증 완료: userId={}, missionId={}", userId, missionId);
}
/**
* 연속 달성 일수를 계산합니다.
*
* @param userId 사용자 ID
* @param missionId 미션 ID
* @return 연속 달성 일수
*/
public int calculateStreakDays(String userId, String missionId) {
// 실제 구현에서는 DB에서 연속 달성 일수 계산
// Mock 데이터로 반환
return (int) (Math.random() * 10) + 1;
}
/**
* 차트 데이터를 생성합니다.
*
* @param missionStats 미션 통계 목록
* @return 차트 데이터
*/
public Map<String, Object> generateChartData(List<MissionStats> missionStats) { // Object -> Map<String, Object>로 변경
Map<String, Object> chartData = new HashMap<>();
// 미션별 달성률 데이터
List<Map<String, Object>> achievementData = missionStats.stream()
.map(stat -> {
Map<String, Object> data = new HashMap<>();
data.put("missionTitle", stat.getTitle());
data.put("achievementRate", stat.getAchievementRate());
data.put("completedDays", stat.getCompletedDays());
return data;
})
.toList();
chartData.put("achievementByMission", achievementData);
chartData.put("chartType", "bar");
chartData.put("title", "미션별 달성률");
return chartData; // 이미 Map<String, Object>를 반환하고 있었음
}
/**
* 진행 패턴을 분석하여 인사이트를 생성합니다.
*
* @param missionStats 미션 통계 목록
* @return 인사이트 목록
*/
public List<String> analyzeProgressPatterns(List<MissionStats> missionStats) {
// 실제 구현에서는 통계 데이터를 분석하여 패턴 발견
return List.of(
"💪 운동 미션의 달성률이 생활습관 미션보다 15% 높습니다.",
"📈 최근 1주일간 미션 달성률이 20% 향상되었습니다.",
"🎯 '어깨 스트레칭' 미션이 가장 높은 달성률(95%)을 보입니다.",
"⏰ 오전에 시작하는 미션들의 달성률이 더 높은 경향을 보입니다."
);
}
}
@@ -0,0 +1,36 @@
package com.healthsync.goal.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 AchievementStats {
@Schema(description = "전체 달성률 (%)")
private double totalAchievementRate;
@Schema(description = "기간 내 달성률 (%)")
private double periodAchievementRate;
@Schema(description = "최고 연속 달성 일수")
private int bestStreak;
@Schema(description = "완료 일수")
private int completedDays;
@Schema(description = "총 일수")
private int totalDays;
}
@@ -0,0 +1,44 @@
package com.healthsync.goal.dto;
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 ActiveMissionsResponse {
@Schema(description = "일일 미션 목록")
private List<DailyMission> dailyMissions;
@Schema(description = "총 미션 수")
private int totalMissions;
@Schema(description = "오늘 완료된 미션 수")
private int todayCompletedCount;
@Schema(description = "완료율 (%)")
private double completionRate;
@Schema(description = "현재 연속 달성 일수")
private int currentStreak;
@Schema(description = "최고 연속 달성 일수")
private int bestStreak;
@Schema(description = "동기부여 메시지")
private String motivationalMessage;
}
@@ -0,0 +1,27 @@
package com.healthsync.goal.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* HealthSync_Intelligence Python API의 celebration 엔드포인트 호출용 요청 모델
* Python 스펙: userId(int), missionId(int) - Python int는 Java Long에 해당
*
* @author healthsync-team
* @version 1.0
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CelebrationRequest {
@JsonProperty("userId")
private Long userId; // 🔧 Integer → Long 변경 (Python int = Java Long)
@JsonProperty("missionId")
private Long missionId; // 🔧 Integer → Long 변경 (큰 ID 값 지원)
}
@@ -0,0 +1,24 @@
package com.healthsync.goal.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* HealthSync_Intelligence Python API의 celebration 응답 모델
* Python 스펙: congratsMessage(str)
*
* @author healthsync-team
* @version 1.0
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CelebrationResponse {
@JsonProperty("congratsMessage")
private String congratsMessage;
}
@@ -0,0 +1,36 @@
package com.healthsync.goal.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 CompletionData {
@Schema(description = "사용자 ID")
private String userId;
@Schema(description = "미션 ID")
private String missionId;
@Schema(description = "완료 여부")
private boolean completed;
@Schema(description = "완료 시간")
private String completedAt;
@Schema(description = "메모")
private String notes;
}
@@ -0,0 +1,64 @@
package com.healthsync.goal.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 일일 미션 DTO 클래스입니다.
* API 설계서와 실제 사용에 맞춰 필드를 정리했습니다.
*
* @author healthsync-team
* @version 1.0
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "일일 미션")
public class DailyMission {
@Schema(description = "미션 ID")
private String missionId;
@Schema(description = "미션 제목")
private String title;
@Schema(description = "미션 설명")
private String description;
@Schema(description = "일일 목표 횟수")
private int targetCount;
@Schema(description = "미션 상태 (ACTIVE, COMPLETED, PENDING)")
private String status;
@Schema(description = "오늘 완료 여부")
private boolean completedToday;
@Schema(description = "오늘 완료한 횟수")
private int completedCount;
@Schema(description = "연속 달성 일수")
private int streakDays;
@Schema(description = "다음 알림 시간")
private String nextReminderTime;
/**
* 미션 완료율을 계산합니다.
*/
public double getCompletionRate() {
if (targetCount == 0) return 0.0;
return Math.min(100.0, (double) completedCount / targetCount * 100.0);
}
/**
* 목표 달성 여부를 확인합니다.
*/
public boolean isTargetAchieved() {
return completedCount >= targetCount;
}
}
@@ -0,0 +1,35 @@
package com.healthsync.goal.dto;
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 GoalSetupResponse {
@Schema(description = "목표 ID")
private String goalId;
@Schema(description = "선택된 미션 목록")
private List<SelectedMission> selectedMissions;
@Schema(description = "응답 메시지")
private String message;
@Schema(description = "설정 완료 시간")
private String setupCompletedAt;
}
@@ -0,0 +1,45 @@
package com.healthsync.goal.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 Mission {
@Schema(description = "미션 ID")
private String missionId;
@Schema(description = "미션 제목")
private String title;
@Schema(description = "미션 설명")
private String description;
@Schema(description = "미션 카테고리")
private String category;
@Schema(description = "난이도")
private String difficulty;
@Schema(description = "건강 효과")
private String healthBenefit;
@Schema(description = "직업군 관련성")
private String occupationRelevance;
@Schema(description = "예상 소요시간(분)")
private int estimatedTimeMinutes;
}
@@ -0,0 +1,59 @@
package com.healthsync.goal.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Min;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
/**
* 미션 완료 요청 DTO 클래스입니다.
*
* @author healthsync-team
* @version 1.0
*/
@Builder
@Data
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "미션 완료 요청")
public class MissionCompleteRequest {
@NotBlank(message = "회원 시리얼 번호는 필수입니다.")
@Schema(description = "회원 시리얼 번호")
private String memberSerialNumber;
@NotNull(message = "완료 여부는 필수입니다.")
@Schema(description = "완료 여부")
private boolean completed;
@Schema(description = "완료 시간")
private String completedAt;
@Schema(description = "메모")
private String notes;
// ✅ 새로 추가할 필드들
@Schema(description = "증가할 완료 횟수 (점진적 완료용)", example = "1")
@Min(value = 1, message = "증가 횟수는 1 이상이어야 합니다")
@Builder.Default
private Integer incrementCount = 1;
@Schema(description = "완료 유형 - INCREMENT: 점진적 완료, FULL: 전체 완료",
example = "INCREMENT", allowableValues = {"INCREMENT", "FULL"})
@Builder.Default
private String completionType = "INCREMENT";
// ✅ 새로 추가할 메서드들
public boolean isIncrementMode() {
return !completed && "INCREMENT".equals(completionType);
}
public boolean isFullCompleteMode() {
return completed || "FULL".equals(completionType);
}
}
@@ -0,0 +1,58 @@
package com.healthsync.goal.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 MissionCompleteResponse {
@Schema(description = "응답 메시지")
private String message;
@Schema(description = "처리 상태")
private String status;
@Schema(description = "성취 메시지")
private String achievementMessage;
@Schema(description = "새로운 연속 달성 일수")
private int newStreakDays;
@Schema(description = "총 완료 횟수")
private int totalCompletedCount;
@Schema(description = "획득 포인트")
private int earnedPoints;
@Schema(description = "현재 완료 횟수 (오늘)", example = "4")
private Integer currentCount;
@Schema(description = "목표 횟수 (오늘)", example = "8")
private Integer targetCount;
@Schema(description = "목표 달성 여부", example = "false")
private Boolean isTargetAchieved;
@Schema(description = "달성률", example = "50.0")
private Double achievementRate;
@Schema(description = "완료 유형", example = "INCREMENT")
private String completionType;
@Schema(description = "축하 메시지 (목표 달성 시)", example = "🎉 오늘 목표를 달성했어요!")
private String celebrationMessage;
}
@@ -0,0 +1,59 @@
package com.healthsync.goal.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* AI API에서 조회한 미션 상세 정보 응답 DTO입니다.
*
* @author healthsync-team
* @version 1.0
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class MissionDetailResponse {
/**
* 미션 ID
*/
private String missionId;
/**
* 미션 제목
*/
private String title;
/**
* 미션 설명
*/
private String description;
/**
* 미션 카테고리
*/
private String category;
/**
* 난이도
*/
private String difficulty;
/**
* 건강 효과
*/
private String healthBenefit;
/**
* 직업군 관련성
*/
private String occupationRelevance;
/**
* 예상 소요 시간 (분)
*/
private Integer estimatedTimeMinutes;
}
@@ -0,0 +1,31 @@
package com.healthsync.goal.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* AI API에 미션 상세 정보 요청을 위한 DTO입니다.
*
* @author healthsync-team
* @version 1.0
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class MissionDetailsRequest {
/**
* 사용자 ID
*/
private String userId;
/**
* 조회할 미션 ID 목록
*/
private List<String> missionIds;
}
@@ -0,0 +1,49 @@
package com.healthsync.goal.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
import java.util.Map;
/**
* 미션 이력 응답 DTO 클래스입니다.
*
* @author healthsync-team
* @version 1.0
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "미션 이력 응답")
public class MissionHistoryResponse {
@Schema(description = "전체 달성률 (%)")
private double totalAchievementRate;
@Schema(description = "기간 내 달성률 (%)")
private double periodAchievementRate;
@Schema(description = "최고 연속 달성 일수")
private int bestStreak;
@Schema(description = "미션별 통계")
private List<MissionStats> missionStats;
// @Schema(description = "차트 데이터")
// private Object chartData;
@Schema(description = "차트 데이터")
private Map<String, Object> chartData;
@Schema(description = "조회 기간")
private Period period;
@Schema(description = "인사이트")
private List<String> insights;
}
@@ -0,0 +1,33 @@
package com.healthsync.goal.dto;
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 클래스입니다.
* 원래는 Intelligence Service에 있었지만 Mock 처리를 위해 추가.
*
* @author healthsync-team
* @version 1.0
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "미션 추천 응답")
public class MissionRecommendationResponse {
@Schema(description = "추천 미션 목록")
private List<Mission> missions;
@Schema(description = "추천 이유")
private String recommendationReason;
@Schema(description = "총 추천 미션 수")
private int totalRecommended;
}
@@ -0,0 +1,33 @@
package com.healthsync.goal.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import jakarta.validation.constraints.NotBlank;
import java.util.List;
/**
* 미션 재설정 요청 DTO 클래스입니다.
*
* @author healthsync-team
* @version 1.0
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "미션 재설정 요청")
public class MissionResetRequest {
@NotBlank(message = "회원 시리얼 번호는 필수입니다.")
@Schema(description = "회원 시리얼 번호")
private String memberSerialNumber;
@NotBlank(message = "재설정 이유는 필수입니다.")
@Schema(description = "재설정 이유")
private String reason;
@Schema(description = "현재 미션 ID 목록")
private List<String> currentMissionIds;
}
@@ -0,0 +1,32 @@
package com.healthsync.goal.dto;
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 MissionResetResponse {
@Schema(description = "응답 메시지")
private String message;
@Schema(description = "새로운 추천 미션 목록")
private List<RecommendedMission> newRecommendations;
@Schema(description = "재설정 완료 시간")
private String resetCompletedAt;
}
@@ -0,0 +1,37 @@
// goal-service/src/main/java/com/healthsync/goal/dto/MissionSelectionRequest.java
package com.healthsync.goal.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Size;
import jakarta.validation.Valid;
import java.util.List;
/**
* 미션 선택 요청 DTO 클래스입니다.
* 선택된 미션의 상세 정보를 포함합니다.
*
* @author healthsync-team
* @version 1.0
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "미션 선택 요청")
public class MissionSelectionRequest {
@NotBlank(message = "회원 시리얼 번호는 필수입니다.")
@Schema(description = "회원 시리얼 번호")
private String memberSerialNumber;
@NotEmpty(message = "선택된 미션 목록은 필수입니다.")
@Size(min = 1, max = 5, message = "1개에서 5개까지 미션을 선택할 수 있습니다.")
@Valid
@Schema(description = "선택된 미션 상세 정보 목록")
private List<SelectedMissionDetail> selectedMissionIds;
}
@@ -0,0 +1,36 @@
package com.healthsync.goal.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 MissionStats {
@Schema(description = "미션 ID")
private String missionId;
@Schema(description = "미션 제목")
private String title;
@Schema(description = "달성률 (%)")
private double achievementRate;
@Schema(description = "완료 일수")
private int completedDays;
@Schema(description = "전체 일수")
private int totalDays;
}
@@ -0,0 +1,27 @@
package com.healthsync.goal.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 Period {
@Schema(description = "시작일")
private String startDate;
@Schema(description = "종료일")
private String endDate;
}
@@ -0,0 +1,33 @@
package com.healthsync.goal.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 RecommendedMission {
@Schema(description = "미션 ID")
private String missionId;
@Schema(description = "미션 제목")
private String title;
@Schema(description = "미션 설명")
private String description;
@Schema(description = "미션 카테고리")
private String category;
}
@@ -0,0 +1,33 @@
package com.healthsync.goal.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 SelectedMission {
@Schema(description = "미션 ID")
private String missionId;
@Schema(description = "미션 제목")
private String title;
@Schema(description = "미션 설명")
private String description;
@Schema(description = "시작일")
private String startDate;
}
@@ -0,0 +1,54 @@
package com.healthsync.goal.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 jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Max;
/**
* 선택된 미션의 상세 정보 DTO 클래스입니다.
*
* @author healthsync-team
* @version 1.0
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "선택된 미션 상세 정보")
public class SelectedMissionDetail {
@NotBlank(message = "미션 제목은 필수입니다.")
@Schema(description = "미션 제목", example = "목 스트레칭 (좌우 각 15초)")
private String title;
@NotNull(message = "일일 목표 횟수는 필수입니다.")
@Min(value = 1, message = "일일 목표 횟수는 최소 1회입니다.")
@Max(value = 20, message = "일일 목표 횟수는 최대 20회입니다.")
@Schema(description = "일일 목표 횟수", example = "3")
private Integer daily_target_count;
@NotBlank(message = "미션 사유는 필수입니다.")
@Schema(description = "미션 선정 사유", example = "장시간 모니터 사용으로 인한 목 긴장 완화 및 거북목 예방")
private String reason;
/**
* 미션 제목에서 고유 ID를 생성합니다.
* @return 생성된 미션 ID
*/
public String generateMissionId() {
if (title == null) return null;
// 제목을 기반으로 간단한 ID 생성 (실제로는 더 정교한 로직 필요)
return title.replaceAll("[^가-힣a-zA-Z0-9]", "_")
.toLowerCase()
.replaceAll("_+", "_")
.replaceAll("^_|_$", "");
}
}
@@ -0,0 +1,42 @@
package com.healthsync.goal.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 UserProfile {
@Schema(description = "사용자 ID")
private String userId;
@Schema(description = "이름")
private String name;
@Schema(description = "나이")
private int age;
@Schema(description = "성별")
private String gender;
@Schema(description = "직업")
private String occupation;
@Schema(description = "이메일")
private String email;
@Schema(description = "전화번호")
private String phone;
}
@@ -0,0 +1,88 @@
package com.healthsync.goal.infrastructure.adapters;
import com.healthsync.goal.dto.ActiveMissionsResponse;
import com.healthsync.goal.dto.MissionHistoryResponse;
import com.healthsync.goal.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 ActiveMissionsResponse getActiveMissions(String userId) {
try {
String cacheKey = "active_missions:" + userId;
//return (ActiveMissionsResponse) redisTemplate.opsForValue().get(cacheKey);
return null;
} catch (Exception e) {
log.warn("활성 미션 캐시 조회 실패: userId={}, error={}", userId, e.getMessage());
return null;
}
}
@Override
public void cacheActiveMissions(String userId, ActiveMissionsResponse response) {
try {
String cacheKey = "active_missions:" + userId;
//redisTemplate.opsForValue().set(cacheKey, response, Duration.ofMinutes(30));
log.info("활성 미션 캐시 저장: userId={}", userId);
} catch (Exception e) {
log.warn("활성 미션 캐시 저장 실패: userId={}, error={}", userId, e.getMessage());
}
}
@Override
public MissionHistoryResponse getMissionHistory(String cacheKey) {
try {
//return (MissionHistoryResponse) redisTemplate.opsForValue().get(cacheKey);
return null;
} catch (Exception e) {
log.warn("미션 이력 캐시 조회 실패: key={}, error={}", cacheKey, e.getMessage());
return null;
}
}
@Override
public void cacheMissionHistory(String cacheKey, MissionHistoryResponse response) {
try {
//redisTemplate.opsForValue().set(cacheKey, response, Duration.ofHours(1));
log.info("미션 이력 캐시 저장: key={}", cacheKey);
} catch (Exception e) {
log.warn("미션 이력 캐시 저장 실패: key={}, error={}", cacheKey, e.getMessage());
}
}
@Override
public void invalidateUserMissionCache(String userId) {
try {
String activeMissionKey = "active_missions:" + userId;
String historyKeyPattern = "mission_history:" + userId + ":*";
// 활성 미션 캐시 삭제
//redisTemplate.delete(activeMissionKey);
// 미션 이력 캐시 삭제 (패턴 매칭)
//redisTemplate.delete(redisTemplate.keys(historyKeyPattern));
log.info("사용자 미션 캐시 무효화 완료: userId={}", userId);
} catch (Exception e) {
log.warn("사용자 미션 캐시 무효화 실패: userId={}, error={}", userId, e.getMessage());
}
}
}
@@ -0,0 +1,82 @@
package com.healthsync.goal.infrastructure.adapters;
import com.healthsync.goal.infrastructure.ports.EventPublisherPort;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* 이벤트 발행을 담당하는 어댑터 클래스입니다.
* 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 publishGoalSetEvent(String memberSerialNumber, List<String> missionIds) {
try {
log.info("목표 설정 이벤트 발행: memberSerialNumber={}, missionCount={}", memberSerialNumber, missionIds.size());
// 실제 구현에서는 이벤트 브로커에 발행
// GoalSetEvent event = GoalSetEvent.builder()
// .memberSerialNumber(memberSerialNumber)
// .missionIds(missionIds)
// .timestamp(LocalDateTime.now())
// .build();
// serviceBusTemplate.send("goal-set-topic", event);
log.info("목표 설정 이벤트 발행 완료: memberSerialNumber={}", memberSerialNumber);
} catch (Exception e) {
log.error("목표 설정 이벤트 발행 실패: memberSerialNumber={}, error={}", memberSerialNumber, e.getMessage(), e);
}
}
@Override
public void publishMissionCompleteEvent(String memberSerialNumber, String missionId, int streakDays) {
try {
log.info("미션 완료 이벤트 발행: memberSerialNumber={}, missionId={}, streakDays={}", memberSerialNumber, missionId, streakDays);
// 실제 구현에서는 이벤트 브로커에 발행
// MissionCompleteEvent event = MissionCompleteEvent.builder()
// .memberSerialNumber(memberSerialNumber)
// .missionId(missionId)
// .streakDays(streakDays)
// .timestamp(LocalDateTime.now())
// .build();
// serviceBusTemplate.send("mission-complete-topic", event);
log.info("미션 완료 이벤트 발행 완료: memberSerialNumber={}, missionId={}", memberSerialNumber, missionId);
} catch (Exception e) {
log.error("미션 완료 이벤트 발행 실패: memberSerialNumber={}, missionId={}, error={}", memberSerialNumber, missionId, e.getMessage(), e);
}
}
@Override
public void publishMissionResetEvent(String memberSerialNumber, String resetReason) {
try {
log.info("미션 재설정 이벤트 발행: memberSerialNumber={}, reason={}", memberSerialNumber, resetReason);
// 실제 구현에서는 이벤트 브로커에 발행
// MissionResetEvent event = MissionResetEvent.builder()
// .memberSerialNumber(memberSerialNumber)
// .resetReason(resetReason)
// .timestamp(LocalDateTime.now())
// .build();
// serviceBusTemplate.send("mission-reset-topic", event);
log.info("미션 재설정 이벤트 발행 완료: memberSerialNumber={}", memberSerialNumber);
} catch (Exception e) {
log.error("미션 재설정 이벤트 발행 실패: memberSerialNumber={}, error={}", memberSerialNumber, e.getMessage(), e);
}
}
}
@@ -0,0 +1,86 @@
package com.healthsync.goal.infrastructure.adapters;
import com.healthsync.goal.domain.ports.IntelligenceServicePort;
import com.healthsync.goal.dto.CelebrationRequest;
import com.healthsync.goal.dto.CelebrationResponse;
import com.healthsync.goal.dto.RecommendedMission;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
import java.util.List;
/**
* HealthSync_Intelligence Python Service와의 연동을 담당하는 어댑터
*
* 🐍 Python API 스펙:
* - 엔드포인트: POST /api/intelligence/missions/celebrate
* - 요청: CelebrationRequest { userId: long, missionId: long }
* - 응답: CelebrationResponse { congratsMessage: str }
*
* @author healthsync-team
* @version 1.0
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class IntelligenceServiceAdapter implements IntelligenceServicePort {
private final RestTemplate restTemplate;
/*@Value("${healthsync.intelligence.base-url:http://healthsync-intelligence:8080}") */
@Value("${services.intelligence-service.url:http://localhost:8083}")
private String intelligenceServiceBaseUrl;
@Override
public CelebrationResponse celebrateMissionAchievement(CelebrationRequest request) {
log.info("🎉 [CELEBRATION_API] Python 미션 달성 축하 요청: userId={}, missionId={}",
request.getUserId(), request.getMissionId());
try {
// 🔧 정확한 Python API 엔드포인트 경로
String url = intelligenceServiceBaseUrl + "/api/intelligence/missions/celebrate";
log.info("🔗 [CELEBRATION_API] 호출 URL: {}", url); // ← 이 줄 추가
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<CelebrationRequest> requestEntity = new HttpEntity<>(request, headers);
ResponseEntity<CelebrationResponse> response = restTemplate.exchange(
url,
HttpMethod.POST,
requestEntity,
CelebrationResponse.class
);
CelebrationResponse celebrationResponse = response.getBody();
log.info("✅ [CELEBRATION_API] Python 축하 메시지 수신 완료: userId={}, message={}",
request.getUserId(), celebrationResponse != null ? celebrationResponse.getCongratsMessage() : "null");
return celebrationResponse;
} catch (Exception e) {
log.error("❌ [CELEBRATION_API] Python 축하 API 호출 실패: userId={}, error={}", request.getUserId(), e.getMessage(), e);
// 🔧 Fallback: Python API 호출 실패시 기본 축하 메시지 반환
return CelebrationResponse.builder()
.congratsMessage("🎉 목표를 달성하셨습니다! 훌륭해요! 건강한 습관을 만들어가고 계시네요! 💪✨")
.build();
}
}
@Override
public List<RecommendedMission> getNewMissionRecommendations(String memberSerialNumber, String resetReason) {
// 기존 메서드 구현 유지
return List.of();
}
}
@@ -0,0 +1,100 @@
package com.healthsync.goal.infrastructure.adapters;
import com.healthsync.goal.dto.UserProfile;
import com.healthsync.goal.infrastructure.ports.UserServicePort;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
/**
* User Service Mock Adapter 클래스입니다.
* User Service가 분리되어 Mock 데이터를 제공합니다.
*
* @author healthsync-team
* @version 1.0
*/
@Slf4j
@Component
public class UserServiceAdapter implements UserServicePort {
@Value("${services.user-service.url:http://localhost:8081}")
private String userServiceUrl;
@Override
public UserProfile getUserProfile(String memberSerialNumber) {
log.info("🔍 [MOCK_USER] Mock 사용자 프로필 조회: memberSerialNumber={}", memberSerialNumber);
// ✅ Mock 사용자 프로필 생성 (User Service 없이도 동작)
UserProfile profile = UserProfile.builder()
.userId(memberSerialNumber)
.name(generateMockName(memberSerialNumber))
.age(generateMockAge(memberSerialNumber))
.gender(generateMockGender(memberSerialNumber))
.occupation(generateMockOccupation(memberSerialNumber))
.email(generateMockEmail(memberSerialNumber))
.phone("010-1234-5678")
.build();
log.info("✅ [MOCK_USER] Mock 사용자 프로필 생성 완료: memberSerialNumber={}, name={}, occupation={}",
memberSerialNumber, profile.getName(), profile.getOccupation());
return profile;
}
@Override
public void validateUserExists(String memberSerialNumber) {
log.info("🔍 [MOCK_USER] Mock 사용자 존재 확인: memberSerialNumber={}", memberSerialNumber);
// ✅ Mock 검증 - 기본적인 유효성만 체크
if (memberSerialNumber == null || memberSerialNumber.trim().isEmpty()) {
log.error("❌ [MOCK_USER] 회원 시리얼 번호가 비어있음: memberSerialNumber={}", memberSerialNumber);
throw new IllegalArgumentException("회원 시리얼 번호가 비어있습니다.");
}
if (memberSerialNumber.length() < 3) {
log.error("❌ [MOCK_USER] 회원 시리얼 번호가 너무 짧음: memberSerialNumber={}", memberSerialNumber);
throw new IllegalArgumentException("회원 시리얼 번호는 3자 이상이어야 합니다.");
}
log.info("✅ [MOCK_USER] Mock 사용자 검증 완료: memberSerialNumber={}", memberSerialNumber);
}
/**
* ✅ memberSerialNumber 기반으로 Mock 이름 생성
*/
private String generateMockName(String memberSerialNumber) {
String[] names = {"김철수", "이영희", "박민수", "정수진", "홍길동", "김영수", "이민정", "박지혜"};
int index = Math.abs(memberSerialNumber.hashCode()) % names.length;
return names[index];
}
/**
* ✅ memberSerialNumber 기반으로 Mock 나이 생성
*/
private int generateMockAge(String memberSerialNumber) {
// 25-45세 사이로 생성
return 25 + (Math.abs(memberSerialNumber.hashCode()) % 21);
}
/**
* ✅ memberSerialNumber 기반으로 Mock 성별 생성
*/
private String generateMockGender(String memberSerialNumber) {
return Math.abs(memberSerialNumber.hashCode()) % 2 == 0 ? "남성" : "여성";
}
/**
* ✅ memberSerialNumber 기반으로 Mock 직업 생성
*/
private String generateMockOccupation(String memberSerialNumber) {
String[] occupations = {"개발자", "디자이너", "마케터", "영업", "기획자", "의사", "교사", "공무원"};
int index = Math.abs(memberSerialNumber.hashCode()) % occupations.length;
return occupations[index];
}
/**
* ✅ memberSerialNumber 기반으로 Mock 이메일 생성
*/
private String generateMockEmail(String memberSerialNumber) {
return "user" + memberSerialNumber + "@healthsync.com";
}
}
@@ -0,0 +1,80 @@
package com.healthsync.goal.infrastructure.entities;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
* DDL의 mission_completion_history 테이블과 정확히 매핑되는 엔티티입니다.
* DDL에서 completion_id가 자동증가가 아니므로 수동 할당합니다.
*
* @author healthsync-team
* @version 1.0
*/
@Entity
@Table(name = "mission_completion_history", schema = "goal_service")
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class MissionCompletionHistoryEntity {
@Id
// ✅ DDL에 맞춰 @GeneratedValue 제거 (수동 할당)
@Column(name = "completion_id")
private Long completionId;
@Column(name = "mission_id", nullable = false)
private Long missionId;
@Column(name = "member_serial_number", nullable = false)
private Long memberSerialNumber;
@Column(name = "completion_date", nullable = false)
private LocalDate completionDate;
@Column(name = "daily_target_count", nullable = false)
private Integer dailyTargetCount;
@Column(name = "daily_completed_count", nullable = false)
private Integer dailyCompletedCount;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;
/**
* 오늘 완료된 미션인지 확인합니다.
*/
public boolean isCompletedToday() {
return LocalDate.now().equals(completionDate);
}
/**
* 목표 달성 여부를 확인합니다.
*/
public boolean isTargetAchieved() {
return dailyCompletedCount >= dailyTargetCount;
}
/**
* 달성률을 계산합니다.
*/
public double getAchievementRate() {
if (dailyTargetCount == 0) return 0.0;
return (double) dailyCompletedCount / dailyTargetCount * 100.0;
}
@PrePersist
protected void onCreate() {
if (this.createdAt == null) {
this.createdAt = LocalDateTime.now();
}
}
}
@@ -0,0 +1,81 @@
package com.healthsync.goal.infrastructure.entities;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
* DDL의 user_mission_goal 테이블과 정확히 매핑되는 엔티티입니다.
* DDL에서 mission_id가 자동증가가 아니므로 수동 할당합니다.
*
* @author healthsync-team
* @version 1.0
*/
@Entity
@Table(name = "user_mission_goal", schema = "goal_service")
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class UserMissionGoalEntity {
@Id
// ✅ DDL에 맞춰 @GeneratedValue 제거 (수동 할당)
@Column(name = "mission_id")
private Long missionId;
@Column(name = "member_serial_number", nullable = false)
private Long memberSerialNumber;
@Column(name = "performance_date", nullable = false)
private LocalDate performanceDate;
@Column(name = "mission_name", nullable = false, length = 100)
private String missionName;
@Column(name = "mission_description", length = 200)
private String missionDescription;
@Column(name = "daily_target_count", nullable = false)
private Integer dailyTargetCount;
@Column(name = "is_active", nullable = false)
private Boolean isActive;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;
/**
* 미션을 비활성화합니다.
*/
public void deactivate() {
this.isActive = false;
}
/**
* 미션이 활성 상태인지 확인합니다.
*/
public boolean isActive() {
return Boolean.TRUE.equals(this.isActive);
}
@PrePersist
protected void onCreate() {
if (this.createdAt == null) {
this.createdAt = LocalDateTime.now();
}
if (this.isActive == null) {
this.isActive = true;
}
if (this.dailyTargetCount == null) {
this.dailyTargetCount = 1;
}
}
}
@@ -0,0 +1,53 @@
package com.healthsync.goal.infrastructure.ports;
import com.healthsync.goal.dto.ActiveMissionsResponse;
import com.healthsync.goal.dto.MissionHistoryResponse;
/**
* 캐시 처리를 위한 포트 인터페이스입니다.
* Clean Architecture의 Domain 계층에서 정의합니다.
*
* @author healthsync-team
* @version 1.0
*/
public interface CachePort {
/**
* 활성 미션 캐시를 조회합니다.
*
* @param memberSerialNumber 회원 시리얼 번호
* @return 활성 미션 응답 (캐시 미스 시 null)
*/
ActiveMissionsResponse getActiveMissions(String memberSerialNumber);
/**
* 활성 미션을 캐시에 저장합니다.
*
* @param memberSerialNumber 회원 시리얼 번호
* @param response 활성 미션 응답
*/
void cacheActiveMissions(String memberSerialNumber, ActiveMissionsResponse response);
/**
* 사용자 미션 캐시를 무효화합니다.
*
* @param memberSerialNumber 회원 시리얼 번호
*/
void invalidateUserMissionCache(String memberSerialNumber);
/**
* 미션 이력 캐시를 조회합니다.
*
* @param cacheKey 캐시 키
* @return 미션 이력 응답 (캐시 미스 시 null)
*/
MissionHistoryResponse getMissionHistory(String cacheKey);
/**
* 미션 이력을 캐시에 저장합니다.
*
* @param cacheKey 캐시 키
* @param response 미션 이력 응답
*/
void cacheMissionHistory(String cacheKey, MissionHistoryResponse response);
}
@@ -0,0 +1,38 @@
package com.healthsync.goal.infrastructure.ports;
import java.util.List;
/**
* 이벤트 발행을 위한 포트 인터페이스입니다.
* Clean Architecture의 Domain 계층에서 정의합니다.
*
* @author healthsync-team
* @version 1.0
*/
public interface EventPublisherPort {
/**
* 목표 설정 이벤트를 발행합니다.
*
* @param memberSerialNumber 회원 시리얼 번호
* @param selectedMissionIds 선택된 미션 ID 목록
*/
void publishGoalSetEvent(String memberSerialNumber, List<String> selectedMissionIds);
/**
* 미션 완료 이벤트를 발행합니다.
*
* @param memberSerialNumber 회원 시리얼 번호
* @param missionId 미션 ID
* @param streakDays 연속 달성 일수
*/
void publishMissionCompleteEvent(String memberSerialNumber, String missionId, int streakDays);
/**
* 미션 재설정 이벤트를 발행합니다.
*
* @param memberSerialNumber 회원 시리얼 번호
* @param resetReason 재설정 이유
*/
void publishMissionResetEvent(String memberSerialNumber, String resetReason);
}
@@ -0,0 +1,33 @@
package com.healthsync.goal.domain.ports;
import com.healthsync.goal.dto.CelebrationRequest;
import com.healthsync.goal.dto.CelebrationResponse;
import com.healthsync.goal.dto.RecommendedMission;
import java.util.List;
/**
* Intelligence Service 연동을 위한 Domain Port
*
* @author healthsync-team
* @version 1.0
*/
public interface IntelligenceServicePort {
/**
* 🎯 미션 달성 축하 API를 호출합니다. (Python API)
*
* @param request 축하 요청 (userId: long, missionId: long)
* @return 축하 응답 (congratsMessage: str)
*/
CelebrationResponse celebrateMissionAchievement(CelebrationRequest request);
/**
* 새로운 미션 추천을 요청합니다.
*
* @param memberSerialNumber 회원 시리얼 번호
* @param resetReason 재설정 사유
* @return 추천 미션 목록
*/
List<RecommendedMission> getNewMissionRecommendations(String memberSerialNumber, String resetReason);
}
@@ -0,0 +1,28 @@
package com.healthsync.goal.infrastructure.ports;
import com.healthsync.goal.dto.UserProfile;
/**
* User Service와의 통신을 위한 포트 인터페이스입니다.
* Clean Architecture의 Domain 계층에서 정의합니다.
*
* @author healthsync-team
* @version 1.0
*/
public interface UserServicePort {
/**
* 사용자 프로필을 조회합니다.
*
* @param memberSerialNumber 회원 시리얼 번호
* @return 사용자 프로필
*/
UserProfile getUserProfile(String memberSerialNumber);
/**
* 사용자 존재 여부를 검증합니다.
*
* @param memberSerialNumber 회원 시리얼 번호
*/
void validateUserExists(String memberSerialNumber);
}
@@ -0,0 +1,573 @@
// goal-service/src/main/java/com/healthsync/goal/infrastructure/repositories/GoalRepositoryImpl.java
package com.healthsync.goal.infrastructure.repositories;
import com.healthsync.goal.domain.repositories.GoalRepository;
import com.healthsync.goal.dto.*;
import com.healthsync.goal.infrastructure.entities.UserMissionGoalEntity;
import com.healthsync.goal.infrastructure.entities.MissionCompletionHistoryEntity;
import com.healthsync.goal.infrastructure.utils.UserIdValidator;
import com.healthsync.goal.infrastructure.services.IdGeneratorService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
/**
* 목표 데이터 저장소 구현체입니다.
* DDL 구조에 완전히 맞춰 수정됨 + SelectedMissionDetail 처리 추가.
*
* @author healthsync-team
* @version 1.0
*/
@Slf4j
@Repository
@RequiredArgsConstructor
@Transactional
public class GoalRepositoryImpl implements GoalRepository {
private final UserMissionGoalJpaRepository userMissionGoalJpaRepository;
private final MissionCompletionJpaRepository missionCompletionJpaRepository;
private final IdGeneratorService idGeneratorService;
@Override
public String saveGoalSettings(MissionSelectionRequest request) {
log.info("🎯 [GOAL_SETTINGS] 미션 목표 설정 저장 시작: memberSerialNumber={}, selectedMissionCount={}",
request.getMemberSerialNumber(), request.getSelectedMissionIds().size());
try {
Long memberSerialNumber = UserIdValidator.parseMemberSerialNumber(request.getMemberSerialNumber(), "saveGoalSettings");
// ✅ SelectedMissionDetail에서 미션 ID 목록 추출
List<String> missionIdList = request.getSelectedMissionIds().stream()
.map(this::extractMissionIdFromDetail)
.collect(Collectors.toList());
// ✅ Mock Intelligence Service에서 선택된 미션들의 상세 정보 조회 (필요시)
// List<MissionDetailResponse> missionDetails = intelligenceServicePort
// .getMissionDetails(request.getMemberSerialNumber(), missionIdList);
// ✅ 선택된 미션들을 DDL 구조에 맞춰 저장
List<UserMissionGoalEntity> savedMissions = request.getSelectedMissionIds().stream()
.map(selectedMissionDetail -> {
// ✅ DDL에 맞춰 ID를 수동 생성
Long generatedMissionId = idGeneratorService.generateMissionId();
// ✅ SelectedMissionDetail에서 직접 정보 사용
String missionTitle = selectedMissionDetail.getTitle();
String missionDescription = generateMissionDescription(selectedMissionDetail);
int dailyTargetCount = selectedMissionDetail.getDaily_target_count();
UserMissionGoalEntity userMissionGoal = UserMissionGoalEntity.builder()
.missionId(generatedMissionId)
.memberSerialNumber(memberSerialNumber)
.performanceDate(LocalDate.now())
.missionName(missionTitle)
.missionDescription(missionDescription)
.dailyTargetCount(dailyTargetCount)
.isActive(true)
.createdAt(LocalDateTime.now())
.build();
log.debug("✅ Creating mission goal: missionId={}, title={}, description={}, dailyTarget={}",
generatedMissionId, missionTitle, missionDescription, dailyTargetCount);
return userMissionGoalJpaRepository.save(userMissionGoal);
})
.toList();
String goalId = "GOAL_SAVED_" + System.currentTimeMillis();
log.info("✅ [GOAL_SETTINGS] 미션 목표 설정 저장 완료: memberSerialNumber={}, goalId={}, savedCount={}",
request.getMemberSerialNumber(), goalId, savedMissions.size());
return goalId;
} catch (IllegalArgumentException e) {
log.error("❌ [GOAL_SETTINGS] 미션 목표 설정 저장 실패: memberSerialNumber={}, 오류={}", request.getMemberSerialNumber(), e.getMessage());
throw e;
} catch (Exception e) {
log.error("❌ [GOAL_SETTINGS] 미션 목표 설정 저장 중 예상치 못한 오류: memberSerialNumber={}, 오류={}", request.getMemberSerialNumber(), e.getMessage(), e);
throw new RuntimeException("미션 목표 설정 중 오류가 발생했습니다.", e);
}
}
@Override
public List<DailyMission> findActiveMissionsByUserId(String memberSerialNumber) {
log.info("🔍 [ACTIVE_MISSIONS] 활성 미션 조회 시작: memberSerialNumber={}", memberSerialNumber);
try {
Long memberSerialNumberLong = UserIdValidator.parseMemberSerialNumber(memberSerialNumber, "findActiveMissionsByUserId");
// ✅ 실제 메서드명 사용
List<UserMissionGoalEntity> activeMissions = userMissionGoalJpaRepository
.findActiveByMemberSerialNumber(memberSerialNumberLong);
List<DailyMission> dailyMissions = activeMissions.stream()
.map(this::convertToDailyMission)
.toList();
log.info("✅ [ACTIVE_MISSIONS] 활성 미션 조회 완료: memberSerialNumber={}, missionCount={}", memberSerialNumber, dailyMissions.size());
return dailyMissions;
} catch (Exception e) {
log.error("❌ [ACTIVE_MISSIONS] 활성 미션 조회 중 오류: memberSerialNumber={}, 오류={}", memberSerialNumber, e.getMessage(), e);
throw new RuntimeException("활성 미션 조회 중 오류가 발생했습니다.", e);
}
}
@Override
public void deactivateCurrentMissions(String memberSerialNumber) {
log.info("⏹️ [DEACTIVATE_MISSIONS] 미션 비활성화 시작: memberSerialNumber={}", memberSerialNumber);
try {
Long memberSerialNumberLong = UserIdValidator.parseMemberSerialNumber(memberSerialNumber, "deactivateCurrentMissions");
// ✅ 실제 구현 방식: 엔티티 조회 → deactivate() 호출 → saveAll()
List<UserMissionGoalEntity> activeMissions = userMissionGoalJpaRepository
.findActiveByMemberSerialNumber(memberSerialNumberLong);
activeMissions.forEach(UserMissionGoalEntity::deactivate);
userMissionGoalJpaRepository.saveAll(activeMissions);
log.info("✅ [DEACTIVATE_MISSIONS] 미션 비활성화 완료: memberSerialNumber={}, updatedCount={}", memberSerialNumber, activeMissions.size());
} catch (Exception e) {
log.error("❌ [DEACTIVATE_MISSIONS] 미션 비활성화 중 오류: memberSerialNumber={}, 오류={}", memberSerialNumber, e.getMessage(), e);
throw new RuntimeException("미션 비활성화 중 오류가 발생했습니다.", e);
}
}
@Override
public void recordMissionCompletion(String missionId, MissionCompleteRequest request) {
log.info("📝 [MISSION_COMPLETION] 미션 점진적 완료 기록 시작: memberSerialNumber={}, missionId={}",
request.getMemberSerialNumber(), missionId);
try {
Long memberSerialNumber = UserIdValidator.parseMemberSerialNumber(request.getMemberSerialNumber(), "recordMissionCompletion");
Long missionIdLong = Long.parseLong(missionId);
// ✅ 1. 미션 정보 조회
Optional<UserMissionGoalEntity> missionOpt = userMissionGoalJpaRepository
.findByMissionIdAndMemberSerialNumber(missionIdLong, memberSerialNumber);
if (missionOpt.isEmpty()) {
throw new IllegalArgumentException("미션을 찾을 수 없습니다: " + missionId);
}
UserMissionGoalEntity mission = missionOpt.get();
LocalDate today = LocalDate.now();
// ✅ 2. 오늘 완료 기록이 있는지 확인
Optional<MissionCompletionHistoryEntity> existingOpt = missionCompletionJpaRepository
.findByMissionIdAndMemberSerialNumberAndCompletionDate(missionIdLong, memberSerialNumber, today);
if (existingOpt.isPresent()) {
// ✅ 3-A. 기존 기록이 있으면 daily_completed_count +1 증가
MissionCompletionHistoryEntity existing = existingOpt.get();
// 목표 초과 방지
if (existing.getDailyCompletedCount() >= existing.getDailyTargetCount()) {
log.warn("⚠️ [MISSION_COMPLETION] 이미 목표를 달성한 미션: memberSerialNumber={}, missionId={}, current={}/{}",
request.getMemberSerialNumber(), missionId,
existing.getDailyCompletedCount(), existing.getDailyTargetCount());
return;
}
// +1 증가
int newCompletedCount = existing.getDailyCompletedCount() + 1;
existing.setDailyCompletedCount(newCompletedCount);
missionCompletionJpaRepository.save(existing);
log.info("✅ [MISSION_COMPLETION] 기존 기록 업데이트: memberSerialNumber={}, missionId={}, count={}/{}",
request.getMemberSerialNumber(), missionId, newCompletedCount, existing.getDailyTargetCount());
} else {
// ✅ 3-B. 기존 기록이 없으면 새로 생성 (daily_completed_count = 1로 시작)
Long generatedCompletionId = idGeneratorService.generateCompletionId();
MissionCompletionHistoryEntity newCompletion = MissionCompletionHistoryEntity.builder()
.completionId(generatedCompletionId)
.missionId(missionIdLong)
.memberSerialNumber(memberSerialNumber)
.completionDate(today)
.dailyTargetCount(mission.getDailyTargetCount()) // ✅ user_mission_goal에서 가져옴
.dailyCompletedCount(1) // ✅ 첫 호출시 1로 시작
.createdAt(LocalDateTime.now())
.build();
missionCompletionJpaRepository.save(newCompletion);
log.info("✅ [MISSION_COMPLETION] 새로운 기록 생성: memberSerialNumber={}, missionId={}, completionId={}, count=1/{}",
request.getMemberSerialNumber(), missionId, generatedCompletionId, mission.getDailyTargetCount());
}
} catch (IllegalArgumentException e) {
log.error("❌ [MISSION_COMPLETION] 미션 완료 기록 실패: memberSerialNumber={}, missionId={}, 오류={}",
request.getMemberSerialNumber(), missionId, e.getMessage());
throw e;
} catch (Exception e) {
log.error("❌ [MISSION_COMPLETION] 미션 완료 기록 중 예상치 못한 오류: memberSerialNumber={}, missionId={}, 오류={}",
request.getMemberSerialNumber(), missionId, e.getMessage(), e);
throw new RuntimeException("미션 완료 기록 중 오류가 발생했습니다.", e);
}
}
@Override
public int getTotalCompletedCount(String memberSerialNumber, String missionId) {
log.info("📊 [TOTAL_COUNT] 총 완료 횟수 조회 시작: memberSerialNumber={}, missionId={}", memberSerialNumber, missionId);
try {
Long memberSerialNumberLong = UserIdValidator.parseMemberSerialNumber(memberSerialNumber, "getTotalCompletedCount");
Long missionIdLong = Long.parseLong(missionId);
int count = missionCompletionJpaRepository
.countByMemberSerialNumberAndMissionId(memberSerialNumberLong, missionIdLong);
log.info("✅ [TOTAL_COUNT] 총 완료 횟수 조회 완료: memberSerialNumber={}, missionId={}, count={}", memberSerialNumber, missionId, count);
return count;
} catch (Exception e) {
log.error("❌ [TOTAL_COUNT] 총 완료 횟수 조회 중 오류: memberSerialNumber={}, missionId={}, 오류={}", memberSerialNumber, missionId, e.getMessage(), e);
throw new RuntimeException("총 완료 횟수 조회 중 오류가 발생했습니다.", e);
}
}
@Override
public List<MissionStats> findMissionHistoryByPeriod(String memberSerialNumber, String startDate, String endDate, String missionIds) {
log.info("📊 [MISSION_HISTORY] 미션 이력 조회 시작: memberSerialNumber={}, period={}-{}", memberSerialNumber, startDate, endDate);
try {
Long memberSerialNumberLong = UserIdValidator.parseMemberSerialNumber(memberSerialNumber, "findMissionHistoryByPeriod");
// 실제 데이터베이스 조회
List<MissionCompletionHistoryEntity> completionHistory =
missionCompletionJpaRepository.findByMemberSerialNumberAndDateRange(
memberSerialNumberLong,
LocalDate.parse(startDate),
LocalDate.parse(endDate)
);
// MissionStats로 변환 - 실제 미션 이름 조회 포함
List<MissionStats> missionStats = completionHistory.stream()
.collect(Collectors.groupingBy(MissionCompletionHistoryEntity::getMissionId))
.entrySet().stream()
.map(entry -> {
Long missionId = entry.getKey();
List<MissionCompletionHistoryEntity> missions = entry.getValue();
// 실제 미션 이름 조회
String missionTitle = getMissionNameById(missionId);
return MissionStats.builder()
.missionId(missionId.toString())
.title(missionTitle) // 실제 DB에서 조회한 미션 이름
.completedDays(missions.size())
.totalDays(calculateTotalDaysInPeriod(startDate, endDate))
.achievementRate(calculateAchievementRate(missions.size(), calculateTotalDaysInPeriod(startDate, endDate)))
.build();
})
.collect(Collectors.toList());
log.info("✅ [MISSION_HISTORY] 미션 이력 조회 완료: memberSerialNumber={}, count={}", memberSerialNumber, missionStats.size());
return missionStats;
} catch (Exception e) {
log.error("❌ [MISSION_HISTORY] 미션 이력 조회 중 오류: memberSerialNumber={}, 오류={}", memberSerialNumber, e.getMessage(), e);
throw new RuntimeException("미션 이력 조회 중 오류가 발생했습니다.", e);
}
}
/**
* 미션 ID로 실제 미션 이름을 조회합니다.
*
* @param missionId 미션 ID
* @return 미션 이름
*/
private String getMissionNameById(Long missionId) {
try {
Optional<UserMissionGoalEntity> missionEntity = userMissionGoalJpaRepository.findByMissionId(missionId);
if (missionEntity.isPresent()) {
String missionName = missionEntity.get().getMissionName();
log.debug("🔍 [MISSION_NAME] 미션 이름 조회 성공: missionId={}, name={}", missionId, missionName);
return missionName;
} else {
log.warn("⚠️ [MISSION_NAME] 미션을 찾을 수 없음: missionId={}", missionId);
return "미션 #" + missionId; // fallback
}
} catch (Exception e) {
log.error("❌ [MISSION_NAME] 미션 이름 조회 중 오류: missionId={}, 오류={}", missionId, e.getMessage());
return "미션 #" + missionId; // fallback
}
}
private int calculateTotalDaysInPeriod(String startDate, String endDate) {
return (int) ChronoUnit.DAYS.between(LocalDate.parse(startDate), LocalDate.parse(endDate)) + 1;
}
private double calculateAchievementRate(int completedDays, int totalDays) {
return totalDays > 0 ? (double) completedDays / totalDays * 100.0 : 0.0;
}
// === Private Helper Methods ===
/**
* SelectedMissionDetail에서 미션 ID를 추출합니다.
* 제목을 기반으로 고유한 ID를 생성합니다.
*/
private String extractMissionIdFromDetail(SelectedMissionDetail detail) {
if (detail == null || detail.getTitle() == null) {
return "mission_unknown_" + System.currentTimeMillis();
}
// 제목을 기반으로 간단한 ID 생성
return detail.getTitle()
.replaceAll("[^가-힣a-zA-Z0-9]", "_")
.toLowerCase()
.replaceAll("_+", "_")
.replaceAll("^_|_$", "");
}
/**
* SelectedMissionDetail로부터 미션 설명을 생성합니다.
*/
private String generateMissionDescription(SelectedMissionDetail detail) {
return String.format("%s (일일 %d회) - %s",
detail.getTitle(),
detail.getDaily_target_count(),
detail.getReason());
}
/**
* ✅ UserMissionGoalEntity를 DailyMission으로 변환하는 헬퍼 메서드
*/
private DailyMission convertToDailyMission(UserMissionGoalEntity entity) {
// 오늘 완료 상태 조회
LocalDate today = LocalDate.now();
List<MissionCompletionHistoryEntity> todayCompletions = missionCompletionJpaRepository
.findByMemberSerialNumberAndCompletionDate(entity.getMemberSerialNumber(), today);
// 이 미션의 오늘 완료 기록만 필터링
List<MissionCompletionHistoryEntity> thisMissionCompletions = todayCompletions.stream()
.filter(completion -> completion.getMissionId().equals(entity.getMissionId()))
.toList();
// 오늘 완료 여부
boolean completedToday = !thisMissionCompletions.isEmpty();
// 오늘 완료한 총 횟수
int completedCount = thisMissionCompletions.stream()
.mapToInt(MissionCompletionHistoryEntity::getDailyCompletedCount)
.sum();
// 연속 달성 일수 계산
int streakDays = calculateStreakDays(entity.getMemberSerialNumber(), entity.getMissionId());
// 미션 상태 결정
String status;
if (completedToday && completedCount >= entity.getDailyTargetCount()) {
status = "COMPLETED";
} else if (completedToday) {
status = "PARTIAL";
} else {
status = "PENDING";
}
return DailyMission.builder()
.missionId(entity.getMissionId().toString())
.title(entity.getMissionName())
.description(entity.getMissionDescription())
.targetCount(entity.getDailyTargetCount())
.status(status)
.completedToday(completedToday)
.completedCount(completedCount)
.streakDays(streakDays)
.nextReminderTime("09:00")
.build();
}
/**
* ✅ 연속 달성 일수를 계산하는 헬퍼 메서드
*/
private int calculateStreakDays(Long memberSerialNumber, Long missionId) {
// 간단한 구현: 실제로는 연속성 확인 로직 필요
List<MissionCompletionHistoryEntity> recentCompletions = missionCompletionJpaRepository
.findByMemberSerialNumberAndMissionId(memberSerialNumber, missionId);
return Math.min(recentCompletions.size(), 7); // 최대 7일로 제한
}
@Override
public UserMissionGoalEntity findMissionByIdAndUser(String missionId, String memberSerialNumber) {
log.info("📋 [FIND_MISSION] 미션 조회: missionId={}, memberSerialNumber={}", missionId, memberSerialNumber);
try {
Long missionIdLong = Long.parseLong(missionId);
Long memberSerialNumberLong = UserIdValidator.parseMemberSerialNumber(memberSerialNumber, "findMissionByIdAndUser");
return userMissionGoalJpaRepository
.findByMissionIdAndMemberSerialNumber(missionIdLong, memberSerialNumberLong)
.orElse(null);
} catch (Exception e) {
log.error("❌ [FIND_MISSION] 미션 조회 오류: missionId={}, memberSerialNumber={}, 오류={}",
missionId, memberSerialNumber, e.getMessage(), e);
throw new RuntimeException("미션 조회 중 오류가 발생했습니다.", e);
}
}
@Override
public MissionCompletionHistoryEntity findOrCreateTodayCompletion(String missionId, String memberSerialNumber, Integer dailyTargetCount) {
log.info("📋 [TODAY_COMPLETION] 오늘 완료 기록 조회/생성: missionId={}, memberSerialNumber={}", missionId, memberSerialNumber);
try {
Long missionIdLong = Long.parseLong(missionId);
Long memberSerialNumberLong = UserIdValidator.parseMemberSerialNumber(memberSerialNumber, "findOrCreateTodayCompletion");
LocalDate today = LocalDate.now();
// 오늘 완료 기록 조회
Optional<MissionCompletionHistoryEntity> existingOpt = missionCompletionJpaRepository
.findByMissionIdAndMemberSerialNumberAndCompletionDate(missionIdLong, memberSerialNumberLong, today);
if (existingOpt.isPresent()) {
return existingOpt.get();
}
// 새로운 완료 기록 생성
Long newCompletionId = idGeneratorService.generateCompletionId();
MissionCompletionHistoryEntity newCompletion = MissionCompletionHistoryEntity.builder()
.completionId(newCompletionId)
.missionId(missionIdLong)
.memberSerialNumber(memberSerialNumberLong)
.completionDate(today)
.dailyTargetCount(dailyTargetCount)
.dailyCompletedCount(0)
.createdAt(LocalDateTime.now())
.build();
return missionCompletionJpaRepository.save(newCompletion);
} catch (Exception e) {
log.error("❌ [TODAY_COMPLETION] 완료 기록 조회/생성 오류: missionId={}, memberSerialNumber={}, 오류={}",
missionId, memberSerialNumber, e.getMessage(), e);
throw new RuntimeException("완료 기록 조회/생성 중 오류가 발생했습니다.", e);
}
}
@Override
public void saveMissionCompletion(MissionCompletionHistoryEntity completion) {
log.info("💾 [SAVE_COMPLETION] 완료 기록 저장: completionId={}, count={}/{}",
completion.getCompletionId(), completion.getDailyCompletedCount(), completion.getDailyTargetCount());
try {
missionCompletionJpaRepository.save(completion);
log.info("✅ [SAVE_COMPLETION] 완료 기록 저장 성공: completionId={}", completion.getCompletionId());
} catch (Exception e) {
log.error("❌ [SAVE_COMPLETION] 완료 기록 저장 오류: completionId={}, 오류={}",
completion.getCompletionId(), e.getMessage(), e);
throw new RuntimeException("완료 기록 저장 중 오류가 발생했습니다.", e);
}
}
@Override
public int calculateStreakDays(String memberSerialNumber, String missionId) {
log.info("📊 [STREAK_DAYS] 연속 달성일수 계산: memberSerialNumber={}, missionId={}", memberSerialNumber, missionId);
try {
Long memberSerialNumberLong = UserIdValidator.parseMemberSerialNumber(memberSerialNumber, "calculateStreakDays");
Long missionIdLong = Long.parseLong(missionId);
LocalDate endDate = LocalDate.now();
LocalDate startDate = endDate.minusDays(30);
List<MissionCompletionHistoryEntity> recentCompletions = missionCompletionJpaRepository
.findByMissionIdAndMemberSerialNumberAndCompletionDateBetweenOrderByCompletionDateDesc(
missionIdLong, memberSerialNumberLong, startDate, endDate);
int streakDays = 0;
LocalDate checkDate = LocalDate.now();
while (true) {
boolean foundCompleted = false;
for (MissionCompletionHistoryEntity completion : recentCompletions) {
if (completion.getCompletionDate().equals(checkDate) && completion.isTargetAchieved()) {
streakDays++;
foundCompleted = true;
break;
}
}
if (!foundCompleted || checkDate.isBefore(startDate)) {
break;
}
checkDate = checkDate.minusDays(1);
}
log.info("✅ [STREAK_DAYS] 연속 달성일수 계산 완료: memberSerialNumber={}, missionId={}, streakDays={}",
memberSerialNumber, missionId, streakDays);
return streakDays;
} catch (Exception e) {
log.error("❌ [STREAK_DAYS] 연속 달성일수 계산 오류: memberSerialNumber={}, missionId={}, 오류={}",
memberSerialNumber, missionId, e.getMessage(), e);
return 0;
}
}
/**
* 🎯 오늘 해당 미션의 목표를 달성했는지 확인합니다.
*
* @param memberSerialNumber 회원 시리얼 번호
* @param missionId 미션 ID
* @return 목표 달성 여부
*/
@Override
public boolean isTodayTargetAchieved(String memberSerialNumber, String missionId) {
log.info("🎯 [TARGET_CHECK] 오늘 목표 달성 여부 확인: memberSerialNumber={}, missionId={}",
memberSerialNumber, missionId);
try {
Long memberSerialNumberLong = UserIdValidator.parseMemberSerialNumber(memberSerialNumber, "isTodayTargetAchieved");
Long missionIdLong = Long.parseLong(missionId);
LocalDate today = LocalDate.now();
// 오늘 해당 미션의 완료 이력 조회
List<MissionCompletionHistoryEntity> todayCompletions = missionCompletionJpaRepository
.findByMemberSerialNumberAndMissionIdAndCompletionDate(memberSerialNumberLong, missionIdLong, today);
if (todayCompletions.isEmpty()) {
log.info("📝 [TARGET_CHECK] 오늘 완료 이력 없음: memberSerialNumber={}, missionId={}",
memberSerialNumber, missionId);
return false;
}
// 가장 최근 완료 이력 확인
MissionCompletionHistoryEntity latestCompletion = todayCompletions.get(0);
boolean isAchieved = latestCompletion.getDailyCompletedCount() >= latestCompletion.getDailyTargetCount();
log.info("✅ [TARGET_CHECK] 목표 달성 여부: memberSerialNumber={}, missionId={}, completed={}/{}, achieved={}",
memberSerialNumber, missionId,
latestCompletion.getDailyCompletedCount(),
latestCompletion.getDailyTargetCount(),
isAchieved);
return isAchieved;
} catch (Exception e) {
log.error("❌ [TARGET_CHECK] 목표 달성 여부 확인 중 오류: memberSerialNumber={}, missionId={}, error={}",
memberSerialNumber, missionId, e.getMessage(), e);
return false;
}
}
}
@@ -0,0 +1,90 @@
package com.healthsync.goal.infrastructure.repositories;
import com.healthsync.goal.infrastructure.entities.MissionCompletionHistoryEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
/**
* 미션 완료 기록을 위한 JPA 리포지토리입니다.
* DDL의 mission_completion_history 테이블과 매핑됩니다.
*
* @author healthsync-team
* @version 1.0
*/
@Repository
public interface MissionCompletionJpaRepository extends JpaRepository<MissionCompletionHistoryEntity, Long> {
/**
* 회원 시리얼번호와 미션 ID로 완료 이력 조회 (DDL 맞춤)
*/
@Query("SELECT mch FROM MissionCompletionHistoryEntity mch WHERE mch.memberSerialNumber = :memberSerialNumber AND mch.missionId = :missionId ORDER BY mch.completionDate DESC")
List<MissionCompletionHistoryEntity> findByMemberSerialNumberAndMissionId(@Param("memberSerialNumber") Long memberSerialNumber, @Param("missionId") Long missionId);
/**
* 특정 날짜의 완료 이력 조회 (DDL 맞춤)
*/
@Query("SELECT mch FROM MissionCompletionHistoryEntity mch WHERE mch.memberSerialNumber = :memberSerialNumber AND mch.completionDate = :completionDate")
List<MissionCompletionHistoryEntity> findByMemberSerialNumberAndCompletionDate(@Param("memberSerialNumber") Long memberSerialNumber, @Param("completionDate") LocalDate completionDate);
/**
* 회원 시리얼번호와 미션 ID, 특정 날짜의 완료 여부 확인 (DDL 맞춤)
*/
boolean existsByMemberSerialNumberAndMissionIdAndCompletionDate(Long memberSerialNumber, Long missionId, LocalDate completionDate);
/**
* 회원 시리얼번호와 미션 ID로 완료 횟수 조회 (DDL 맞춤)
*/
int countByMemberSerialNumberAndMissionId(Long memberSerialNumber, Long missionId);
/**
* 미션 ID, 회원 시리얼 번호, 완료 날짜로 완료 기록을 조회합니다.
*/
@Query("SELECT mch FROM MissionCompletionHistoryEntity mch WHERE mch.missionId = :missionId AND mch.memberSerialNumber = :memberSerialNumber AND mch.completionDate = :completionDate")
Optional<MissionCompletionHistoryEntity> findByMissionIdAndMemberSerialNumberAndCompletionDate(
@Param("missionId") Long missionId,
@Param("memberSerialNumber") Long memberSerialNumber,
@Param("completionDate") LocalDate completionDate);
/**
* 미션 ID와 회원 시리얼 번호로 특정 기간의 완료 기록을 날짜 내림차순으로 조회합니다.
*/
@Query("SELECT mch FROM MissionCompletionHistoryEntity mch WHERE mch.missionId = :missionId AND mch.memberSerialNumber = :memberSerialNumber AND mch.completionDate BETWEEN :startDate AND :endDate ORDER BY mch.completionDate DESC")
List<MissionCompletionHistoryEntity> findByMissionIdAndMemberSerialNumberAndCompletionDateBetweenOrderByCompletionDateDesc(
@Param("missionId") Long missionId,
@Param("memberSerialNumber") Long memberSerialNumber,
@Param("startDate") LocalDate startDate,
@Param("endDate") LocalDate endDate);
/**
* 기간별 미션 완료 이력 조회 (findMissionHistoryByPeriod에서 사용)
*/
@Query("SELECT mch FROM MissionCompletionHistoryEntity mch " +
"WHERE mch.memberSerialNumber = :memberSerialNumber " +
"AND mch.completionDate BETWEEN :startDate AND :endDate " +
"ORDER BY mch.completionDate DESC")
List<MissionCompletionHistoryEntity> findByMemberSerialNumberAndDateRange(
@Param("memberSerialNumber") Long memberSerialNumber,
@Param("startDate") LocalDate startDate,
@Param("endDate") LocalDate endDate
);
/**
* 특정 회원의 특정 미션에 대한 특정 날짜의 완료 이력을 조회합니다.
*
* @param memberSerialNumber 회원 시리얼 번호
* @param missionId 미션 ID
* @param completionDate 완료 날짜
* @return 완료 이력 목록
*/
List<MissionCompletionHistoryEntity> findByMemberSerialNumberAndMissionIdAndCompletionDate(
Long memberSerialNumber, Long missionId, LocalDate completionDate);
}
@@ -0,0 +1,45 @@
package com.healthsync.goal.infrastructure.repositories;
import com.healthsync.goal.infrastructure.entities.UserMissionGoalEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
/**
* DDL의 user_mission_goal 테이블을 위한 JPA 리포지토리입니다.
*
* @author healthsync-team
* @version 1.0
*/
@Repository
public interface UserMissionGoalJpaRepository extends JpaRepository<UserMissionGoalEntity, Long> {
/**
* 회원 시리얼 번호로 활성 미션 목표 조회
*/
@Query("SELECT umg FROM UserMissionGoalEntity umg WHERE umg.memberSerialNumber = :memberSerialNumber AND umg.isActive = true")
List<UserMissionGoalEntity> findActiveByMemberSerialNumber(@Param("memberSerialNumber") Long memberSerialNumber);
/**
* 특정 날짜의 미션 목표 조회
*/
@Query("SELECT umg FROM UserMissionGoalEntity umg WHERE umg.memberSerialNumber = :memberSerialNumber AND umg.performanceDate = :performanceDate AND umg.isActive = true")
List<UserMissionGoalEntity> findByMemberSerialNumberAndPerformanceDate(@Param("memberSerialNumber") Long memberSerialNumber, @Param("performanceDate") LocalDate performanceDate);
/**
* 미션 ID로 조회 (명시적 쿼리 추가)
*/
@Query("SELECT umg FROM UserMissionGoalEntity umg WHERE umg.missionId = :missionId")
Optional<UserMissionGoalEntity> findByMissionId(@Param("missionId") Long missionId);
/**
* 미션 ID와 회원 시리얼 번호로 미션을 조회합니다.
*/
@Query("SELECT umg FROM UserMissionGoalEntity umg WHERE umg.missionId = :missionId AND umg.memberSerialNumber = :memberSerialNumber")
Optional<UserMissionGoalEntity> findByMissionIdAndMemberSerialNumber(@Param("missionId") Long missionId, @Param("memberSerialNumber") Long memberSerialNumber);
}
@@ -0,0 +1,49 @@
package com.healthsync.goal.infrastructure.services;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.concurrent.atomic.AtomicLong;
/**
* DDL에서 자동증가가 없는 ID를 생성하는 서비스입니다.
*
* @author healthsync-team
* @version 1.0
*/
@Slf4j
@Service
public class IdGeneratorService {
private static final AtomicLong MISSION_ID_COUNTER = new AtomicLong(0);
private static final AtomicLong COMPLETION_ID_COUNTER = new AtomicLong(0);
/**
* 새로운 미션 ID를 생성합니다.
* 현재 시간(밀리초) + 증가값으로 유니크한 ID 생성
*/
public Long generateMissionId() {
long timestamp = Instant.now().toEpochMilli();
long counter = MISSION_ID_COUNTER.incrementAndGet();
long id = timestamp * 1000 + (counter % 1000);
log.debug("Generated mission ID: {}", id);
return id;
}
/**
* 개선된 완료 이력 ID 생성 (Snowflake 방식 참고)
*/
public Long generateCompletionId() {
long timestamp = Instant.now().toEpochMilli();
long counter = COMPLETION_ID_COUNTER.incrementAndGet();
// ✅ 더 안전한 방식: 타임스탬프 + 시퀀스
long id = (timestamp << 12) + (counter & 0xFFF); // 12비트 시퀀스
log.debug("Generated completion ID: {} (timestamp: {}, counter: {})",
id, timestamp, counter & 0xFFF);
return id;
}
}
@@ -0,0 +1,69 @@
package com.healthsync.goal.infrastructure.utils;
import lombok.extern.slf4j.Slf4j;
/**
* member_serial_number 검증 및 변환 유틸리티 클래스입니다.
*
* @author healthsync-team
* @version 1.0
*/
@Slf4j
public class UserIdValidator {
/**
* memberSerialNumber 문자열을 Long으로 안전하게 변환합니다.
*
* @param memberSerialNumber 회원 시리얼 번호 문자열
* @param methodName 호출한 메서드명 (로깅용)
* @return 변환된 Long 값
* @throws IllegalArgumentException 변환 실패 시
*/
public static Long parseMemberSerialNumber(String memberSerialNumber, String methodName) {
if (memberSerialNumber == null || memberSerialNumber.trim().isEmpty()) {
log.error("❌ [MEMBER_SERIAL_VALIDATION] 메서드: {}, 오류: memberSerialNumber가 null 또는 빈 문자열입니다", methodName);
throw new IllegalArgumentException("회원 시리얼 번호가 유효하지 않습니다: null 또는 빈 값");
}
try {
Long parsedMemberSerialNumber = Long.parseLong(memberSerialNumber.trim());
log.debug("✅ [MEMBER_SERIAL_VALIDATION] 메서드: {}, memberSerialNumber 변환 성공: {} -> {}", methodName, memberSerialNumber, parsedMemberSerialNumber);
return parsedMemberSerialNumber;
} catch (NumberFormatException e) {
log.error("❌ [MEMBER_SERIAL_VALIDATION] 메서드: {}, 포맷 오류: 입력값='{}', 오류메시지='{}'",
methodName, memberSerialNumber, e.getMessage());
throw new IllegalArgumentException(
String.format("회원 시리얼 번호 형식이 올바르지 않습니다. 입력값: '%s', 숫자만 입력 가능합니다.", memberSerialNumber), e);
}
}
/**
* memberSerialNumber가 유효한 숫자 형식인지 검증합니다.
*
* @param memberSerialNumber 검증할 회원 시리얼 번호
* @return 유효하면 true, 아니면 false
*/
public static boolean isValidMemberSerialNumber(String memberSerialNumber) {
if (memberSerialNumber == null || memberSerialNumber.trim().isEmpty()) {
return false;
}
try {
Long.parseLong(memberSerialNumber.trim());
return true;
} catch (NumberFormatException e) {
return false;
}
}
// 기존 메서드와의 호환성을 위해 유지 (deprecated)
@Deprecated
public static Long parseUserId(String userId, String methodName) {
return parseMemberSerialNumber(userId, methodName);
}
@Deprecated
public static boolean isValidUserId(String userId) {
return isValidMemberSerialNumber(userId);
}
}
@@ -0,0 +1,138 @@
package com.healthsync.goal.interface_adapters.controllers;
import com.healthsync.common.dto.ApiResponse;
import com.healthsync.goal.application_services.GoalUseCase;
import com.healthsync.goal.dto.*;
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/goals")
@RequiredArgsConstructor
@Tag(name = "목표 관리", description = "건강 목표 설정 및 미션 관리 API")
public class GoalController {
private final GoalUseCase goalUseCase;
/**
* 미션을 선택하고 목표를 설정합니다.
* 이제 미션의 상세 정보(제목, 일일 목표 횟수, 사유)를 함께 받습니다.
*
* @param request 미션 선택 요청 (상세 정보 포함)
* @return 목표 설정 결과
*/
@PostMapping("/missions/select")
@Operation(summary = "미션 선택 및 목표 설정",
description = "사용자가 선택한 미션의 상세 정보로 건강 목표를 설정합니다. " +
"미션 제목, 일일 목표 횟수, 선정 사유를 모두 포함해야 합니다.")
public ResponseEntity<ApiResponse<GoalSetupResponse>> selectMissions(@Valid @RequestBody MissionSelectionRequest request) {
log.info("미션 선택 및 목표 설정 요청: memberSerialNumber={}, missionCount={}",
request.getMemberSerialNumber(), request.getSelectedMissionIds().size());
// 각 미션의 상세 정보 로깅
request.getSelectedMissionIds().forEach(mission ->
log.info("선택된 미션: title={}, daily_target={}, reason={}",
mission.getTitle(), mission.getDaily_target_count(), mission.getReason())
);
GoalSetupResponse response = goalUseCase.selectMissions(request);
log.info("미션 선택 및 목표 설정 완료: memberSerialNumber={}, goalId={}",
request.getMemberSerialNumber(), response.getGoalId());
return ResponseEntity.ok(ApiResponse.success("목표 설정이 완료되었습니다.", response));
}
/**
* 설정된 활성 미션을 조회합니다.
*
* @param memberSerialNumber 회원 시리얼 번호
* @return 활성 미션 목록
*/
@GetMapping("/missions/active")
@Operation(summary = "설정된 목표 조회", description = "사용자의 현재 활성 미션과 진행 상황을 조회합니다")
public ResponseEntity<ApiResponse<ActiveMissionsResponse>> getActiveMissions(@RequestParam String memberSerialNumber) {
log.info("활성 미션 조회 요청: memberSerialNumber={}", memberSerialNumber);
ActiveMissionsResponse response = goalUseCase.getActiveMissions(memberSerialNumber);
log.info("활성 미션 조회 완료: memberSerialNumber={}, totalMissions={}", memberSerialNumber, response.getTotalMissions());
return ResponseEntity.ok(ApiResponse.success("활성 미션 조회가 완료되었습니다.", response));
}
/**
* 미션 완료를 처리합니다.
*
* @param missionId 미션 ID
* @param request 미션 완료 요청
* @return 미션 완료 결과
*/
@PutMapping("/missions/{missionId}/complete")
@Operation(summary = "미션 완료 처리", description = "사용자의 미션 완료를 기록하고 성과를 업데이트합니다")
public ResponseEntity<ApiResponse<MissionCompleteResponse>> completeMission(
@PathVariable String missionId,
@Valid @RequestBody MissionCompleteRequest request) {
log.info("미션 완료 처리 요청: memberSerialNumber={}, missionId={}", request.getMemberSerialNumber(), missionId);
MissionCompleteResponse response = goalUseCase.completeMission(missionId, request);
log.info("미션 완료 처리 완료: memberSerialNumber={}, missionId={}, streakDays={}",
request.getMemberSerialNumber(), missionId, response.getNewStreakDays());
return ResponseEntity.ok(ApiResponse.success("미션 완료가 기록되었습니다.", response));
}
/**
* 미션 달성 이력을 조회합니다.
*
* @param memberSerialNumber 회원 시리얼 번호
* @param startDate 시작일
* @param endDate 종료일
* @param missionIds 미션 ID 목록
* @return 미션 달성 이력
*/
@GetMapping("/missions/history")
@Operation(summary = "미션 달성 이력 조회", description = "지정한 기간의 미션 달성 이력과 통계를 조회합니다")
public ResponseEntity<ApiResponse<MissionHistoryResponse>> getMissionHistory(
@RequestParam String memberSerialNumber,
@RequestParam(required = false) String startDate,
@RequestParam(required = false) String endDate,
@RequestParam(required = false) String missionIds) {
log.info("미션 이력 조회 요청: memberSerialNumber={}, period={} to {}", memberSerialNumber, startDate, endDate);
MissionHistoryResponse response = goalUseCase.getMissionHistory(memberSerialNumber, startDate, endDate, missionIds);
log.info("미션 이력 조회 완료: memberSerialNumber={}, achievementRate={}", memberSerialNumber, response.getTotalAchievementRate());
return ResponseEntity.ok(ApiResponse.success("미션 이력 조회가 완료되었습니다.", response));
}
/**
* 미션을 재설정합니다.
*
* @param request 미션 재설정 요청
* @return 미션 재설정 결과
*/
@PostMapping("/missions/reset")
@Operation(summary = "목표 재설정", description = "현재 미션을 중단하고 새로운 미션으로 재설정합니다")
public ResponseEntity<ApiResponse<MissionResetResponse>> resetMissions(@Valid @RequestBody MissionResetRequest request) {
log.info("미션 재설정 요청: memberSerialNumber={}, reason={}", request.getMemberSerialNumber(), request.getReason());
MissionResetResponse response = goalUseCase.resetMissions(request);
log.info("미션 재설정 완료: memberSerialNumber={}, newRecommendationCount={}",
request.getMemberSerialNumber(), response.getNewRecommendations().size());
return ResponseEntity.ok(ApiResponse.success("미션 재설정이 완료되었습니다.", response));
}
}
@@ -0,0 +1,105 @@
server:
port: ${SERVER_PORT:8084} # 🔧 포트도 8084로 수정 (8082는 health-service)
spring:
application:
name: goal-service
# main:
# allow-bean-definition-overriding: true
# ✅ docker-compose.yml의 환경변수명 그대로 사용
datasource:
url: ${DB_URL:jdbc:postgresql://localhost:5432/healthsync}
username: ${DB_USERNAME:postgres}
password: ${DB_PASSWORD:postgres}
driver-class-name: org.postgresql.Driver
hikari:
maximum-pool-size: 10
minimum-idle: 2
connection-timeout: 30000
idle-timeout: 600000
max-lifetime: 1800000
jpa:
hibernate:
ddl-auto: ${JPA_DDL_AUTO:validate}
show-sql: ${JPA_SHOW_SQL:true}
properties:
hibernate:
dialect: org.hibernate.dialect.PostgreSQLDialect
format_sql: true
default_schema: goal_service
data:
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6380} # 🔧 Azure Redis SSL 포트
password: ${REDIS_PASSWORD:HUezXQsxbphIeBy8FV9JDA3WaZDwOozGEAzCaByUk40=}
timeout: 2000ms
ssl:
enabled: ${REDIS_SSL_ENABLED:true} # 🔧 SSL 활성화
lettuce:
pool:
max-active: 8
max-idle: 8
min-idle: 0
springdoc:
swagger-ui:
enabled: true # Swagger UI 활성화
path: /swagger-ui.html # Swagger UI 접근 경로
disable-swagger-default-url: false # 기본 Swagger URL 사용
operations-sorter: method # API 메소드별 정렬
tags-sorter: alpha # 태그 알파벳 순 정렬
doc-expansion: none # 문서 확장 방식 (none/list/full)
api-docs:
enabled: true # API 문서 생성 활성화
path: /v3/api-docs # OpenAPI 3.0 JSON 문서 경로
show-actuator: true # Actuator 엔드포인트 포함
packages-to-scan: com.healthsync.goal.interface_adapters.controllers # 스캔할 패키지 명시
services:
user-service:
url: ${USER_SERVICE_URL:http://localhost:8081}
timeout: ${USER_SERVICE_TIMEOUT:30}
# 🆕 Intelligence Service 설정 추가
intelligence-service:
url: ${INTELLIGENCE_SERVICE_URL:http://localhost:8083}
timeout: ${INTELLIGENCE_SERVICE_TIMEOUT:30}
# 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.goal: DEBUG # 🔧 DEBUG로 변경
org.springframework.web: DEBUG # 🔧 DEBUG로 변경
org.springframework.web.servlet.mvc.method.annotation: DEBUG # 🔧 매핑 정보 확인
org.springframework.data.redis: DEBUG # Spring Data Redis 전체
org.springframework.data.redis.connection: TRACE # Redis 연결 관련
org.springframework.data.redis.core: DEBUG # RedisTemplate 관련
pattern:
console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"
# ✅ management는 logging과 같은 레벨이어야 함 (logging 하위가 아님)
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
endpoint:
health:
show-details: always
prometheus:
metrics:
export:
enabled: true