From 409d7abdc67761571b27c84c13d854f9f6aae623 Mon Sep 17 00:00:00 2001 From: P82288200 Date: Fri, 20 Jun 2025 05:42:24 +0000 Subject: [PATCH] feat : initial commit --- .gitignore | 65 ++ Dockerfile.backup | 26 + README.md | 148 +++++ api-gateway/build.gradle | 43 ++ .../gateway/GatewayApplication.java | 19 + .../gateway/config/GatewayConfig.java | 43 ++ .../src/main/resources/application.yml | 116 ++++ build.gradle | 60 ++ common/build.gradle | 8 + .../common/config/JpaAuditingConfig.java | 19 + .../healthsync/common/config/RedisConfig.java | 41 ++ .../common/config/SwaggerConfig.java | 40 ++ .../common/constants/ErrorCode.java | 39 ++ .../common/constants/SuccessCode.java | 26 + .../healthsync/common/dto/ApiResponse.java | 85 +++ .../healthsync/common/dto/ErrorResponse.java | 27 + .../healthsync/common/entity/BaseEntity.java | 60 ++ .../exception/AuthenticationException.java | 22 + .../common/exception/BusinessException.java | 26 + .../exception/ExternalApiException.java | 18 + .../exception/GlobalExceptionHandler.java | 52 ++ .../GlobalExceptionHandler.java.backup | 71 ++ .../exception/MissionNotFoundException.java | 22 + .../exception/UserNotFoundException.java | 22 + .../common/exception/ValidationException.java | 14 + .../com/healthsync/common/util/DateUtil.java | 89 +++ .../com/healthsync/common/util/JwtUtil.java | 156 +++++ deployment/container/Dockerfile | 24 + deployment/container/Dockerfile.backup | 120 ++++ docker-compose.yml | 150 +++++ goal-service/build.gradle | 41 ++ .../goal/GoalServiceApplication.java | 28 + .../application_services/GoalUseCase.java | 612 ++++++++++++++++++ .../goal/config/GoalSecurityConfig.java | 73 +++ .../goal/config/SwaggerAccessController.java | 88 +++ .../goal/config/WebClientConfig.java | 26 + .../domain/repositories/GoalRepository.java | 99 +++ .../domain/services/GoalDomainService.java | 120 ++++ .../healthsync/goal/dto/AchievementStats.java | 36 ++ .../goal/dto/ActiveMissionsResponse.java | 44 ++ .../goal/dto/CelebrationRequest.java | 27 + .../goal/dto/CelebrationResponse.java | 24 + .../healthsync/goal/dto/CompletionData.java | 36 ++ .../com/healthsync/goal/dto/DailyMission.java | 64 ++ .../goal/dto/GoalSetupResponse.java | 35 + .../java/com/healthsync/goal/dto/Mission.java | 45 ++ .../goal/dto/MissionCompleteRequest.java | 59 ++ .../goal/dto/MissionCompleteResponse.java | 58 ++ .../goal/dto/MissionDetailResponse.java | 59 ++ .../goal/dto/MissionDetailsRequest.java | 31 + .../goal/dto/MissionHistoryResponse.java | 49 ++ .../dto/MissionRecommendationResponse.java | 33 + .../goal/dto/MissionResetRequest.java | 33 + .../goal/dto/MissionResetResponse.java | 32 + .../goal/dto/MissionSelectionRequest.java | 37 ++ .../com/healthsync/goal/dto/MissionStats.java | 36 ++ .../java/com/healthsync/goal/dto/Period.java | 27 + .../goal/dto/RecommendedMission.java | 33 + .../healthsync/goal/dto/SelectedMission.java | 33 + .../goal/dto/SelectedMissionDetail.java | 54 ++ .../com/healthsync/goal/dto/UserProfile.java | 42 ++ .../infrastructure/adapters/CacheAdapter.java | 88 +++ .../adapters/EventPublisherAdapter.java | 82 +++ .../adapters/IntelligenceServiceAdapter.java | 86 +++ .../adapters/UserServiceAdapter.java | 100 +++ .../MissionCompletionHistoryEntity.java | 80 +++ .../entities/UserMissionGoalEntity.java | 81 +++ .../goal/infrastructure/ports/CachePort.java | 53 ++ .../ports/EventPublisherPort.java | 38 ++ .../ports/IntelligenceServicePort.java | 33 + .../infrastructure/ports/UserServicePort.java | 28 + .../repositories/GoalRepositoryImpl.java | 573 ++++++++++++++++ .../MissionCompletionJpaRepository.java | 90 +++ .../UserMissionGoalJpaRepository.java | 45 ++ .../services/IdGeneratorService.java | 49 ++ .../infrastructure/utils/UserIdValidator.java | 69 ++ .../controllers/GoalController.java | 138 ++++ .../src/main/resources/application.yml | 105 +++ gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43705 bytes gradle/wrapper/gradle-wrapper.properties | 8 + gradlew | 251 +++++++ gradlew.bat | 94 +++ health-service/build.gradle | 21 + .../healthsync/HealthServiceApplication.java | 11 + .../healthsync/common/dto/CusApiResponse.java | 42 ++ .../common/exception/CustomException.java | 11 + .../exception/GlobalExceptionHandler.java | 42 ++ .../common/response/ResponseHelper.java | 36 ++ .../com/healthsync/common/util/JwtUtil.java | 85 +++ .../health/config/HealthJwtConfig.java | 91 +++ .../health/config/HealthSecurityConfig.java | 73 +++ .../health/config/HealthSwaggerConfig.java | 54 ++ .../health/config/ObjectMapperConfig.java | 17 + .../controller/HealthCheckupController.java | 364 +++++++++++ .../health/domain/HealthCheck/Gender.java | 35 + .../domain/HealthCheck/HealthCheckup.java | 201 ++++++ .../domain/HealthCheck/HealthCheckupRaw.java | 159 +++++ .../domain/HealthCheck/HealthNormalRange.java | 49 ++ .../health/domain/Oauth/JobCategory.java | 36 ++ .../health/domain/Oauth/RefreshToken.java | 42 ++ .../healthsync/health/domain/Oauth/User.java | 71 ++ .../health/domain/Oauth/UserRole.java | 5 + .../HealthCheck/HealthCheckupResponse.java | 159 +++++ .../HealthCheck/HealthCheckupSyncResult.java | 38 ++ .../dto/HealthCheck/HealthProfileDto.java | 230 +++++++ .../HealthProfileHistoryResponse.java | 93 +++ .../health/dto/Oauth/OAuth2UserInfo.java | 31 + .../health/dto/Oauth/TokenRefreshRequest.java | 19 + .../health/dto/Oauth/TokenResponse.java | 23 + .../health/dto/UserProfile/LoginResponse.java | 30 + .../dto/UserProfile/UserProfileResponse.java | 50 ++ .../dto/UserProfile/UserUpdateRequest.java | 58 ++ .../exception/AuthenticationException.java | 13 + .../exception/TokenExpiredException.java | 13 + .../exception/UserNotFoundException.java | 13 + .../entity/HealthCheckupEntity.java | 307 +++++++++ .../entity/HealthCheckupRawEntity.java | 269 ++++++++ .../entity/HealthNormalRangeEntity.java | 107 +++ .../entity/OccupationTypeEntity.java | 55 ++ .../repository/entity/RefreshTokenEntity.java | 75 +++ .../health/repository/entity/UserEntity.java | 113 ++++ .../jpa/HealthCheckupRawRepository.java | 134 ++++ .../jpa/HealthCheckupRepository.java | 131 ++++ .../jpa/HealthNormalRangeRepository.java | 40 ++ .../jpa/OccupationTypeRepository.java | 24 + .../jpa/RefreshTokenRepository.java | 25 + .../health/repository/jpa/UserRepository.java | 90 +++ .../HealthProfile/HealthProfileService.java | 74 +++ .../HealthProfileServiceImpl.java | 317 +++++++++ .../RealisticHealthMockDataGenerator.java | 590 +++++++++++++++++ .../service/Oauth/RefreshTokenService.java | 13 + .../Oauth/RefreshTokenServiceImpl.java | 69 ++ .../service/UserProfile/UserService.java | 30 + .../service/UserProfile/UserServiceImpl.java | 164 +++++ .../health/util/HealthDataConverter.java | 184 ++++++ .../src/main/resources/application.yml | 114 ++++ .../configmap/api-gateway-config.yaml | 12 + .../manifest/configmap/common-config.yaml | 13 + .../configmap/goal-service-config.yaml | 8 + .../configmap/health-service-config.yaml | 8 + .../intelligence-service-config.yaml | 8 + .../configmap/motivator-service-config.yaml | 8 + .../configmap/user-service-config.yaml | 13 + .../deployment/api-gateway-deployment.yaml | 55 ++ .../deployment/goal-service-deployment.yaml | 55 ++ .../deployment/health-service-deployment.yaml | 55 ++ .../intelligence-service-deployment.yaml | 78 +++ .../motivator-service-deployment.yaml | 57 ++ .../deployment/user-service-deployment.yaml | 77 +++ .../manifest/ingress/healthsync-ingress.yaml | 63 ++ .../manifest/secret/common-secret.yaml | 11 + .../manifest/secret/google-oauth-secret.yaml | 13 + .../manifest/secret/intelligence-secret.yaml | 11 + .../manifest/secret/servicebus-secret.yaml | 8 + .../deployment/manifest/service/services.yaml | 89 +++ motivator-service/build.gradle | 39 ++ .../MotivatorServiceApplication.java | 25 + .../MotivationUseCase.java | 216 +++++++ .../config/MotivatorSecurityConfig.java | 65 ++ .../repositories/NotificationRepository.java | 43 ++ .../BatchProcessingDomainService.java | 285 ++++++++ .../MessageGenerationDomainService.java | 137 ++++ .../services/UserAnalysisDomainService.java | 150 +++++ .../dto/BatchNotificationRequest.java | 37 ++ .../dto/BatchNotificationResponse.java | 36 ++ .../motivator/dto/BatchProcessingResult.java | 33 + .../motivator/dto/DailyProgress.java | 33 + .../motivator/dto/EncouragementRequest.java | 33 + .../motivator/dto/EncouragementResponse.java | 36 ++ .../motivator/dto/MissionStatus.java | 27 + .../motivator/dto/ProgressAnalysis.java | 59 ++ .../motivator/dto/UserMissionStatus.java | 35 + .../dto/UserNotificationContext.java | 41 ++ .../motivator/enums/EngagementLevel.java | 25 + .../motivator/enums/MotivationType.java | 30 + .../motivator/enums/UrgencyLevel.java | 25 + .../infrastructure/adapters/CacheAdapter.java | 56 ++ .../adapters/ClaudeApiAdapter.java | 71 ++ .../adapters/EventPublisherAdapter.java | 41 ++ .../adapters/GoalServiceAdapter.java | 75 +++ .../batch/NotificationBatchJob.java | 103 +++ .../entities/NotificationLogEntity.java | 104 +++ .../infrastructure/ports/CachePort.java | 37 ++ .../infrastructure/ports/ClaudeApiPort.java | 19 + .../ports/EventPublisherPort.java | 19 + .../infrastructure/ports/GoalServicePort.java | 31 + .../NotificationLogJpaRepository.java | 35 + .../NotificationRepositoryImpl.java | 56 ++ .../controllers/NotificationController.java | 69 ++ .../src/main/resources/application.yml | 76 +++ scripts/init-db.sql | 23 + settings.gradle | 9 + user-service/build.gradle | 22 + .../healthsync/UserServiceApplication.java | 11 + .../healthsync/common/dto/ApiResponse.java | 42 ++ .../common/exception/CustomException.java | 11 + .../exception/GlobalExceptionHandler.java | 42 ++ .../common/response/ResponseHelper.java | 36 ++ .../com/healthsync/common/util/JwtUtil.java | 85 +++ .../healthsync/user/config/OAuth2Config.java | 8 + .../config/OAuth2LoginSuccessHandler.java | 123 ++++ .../user/config/ObjectMapperConfig.java | 17 + .../healthsync/user/config/UserJwtConfig.java | 92 +++ .../user/config/UserSecurityConfig.java | 94 +++ .../user/config/UserSwaggerConfig.java | 54 ++ .../user/controller/AuthController.java | 227 +++++++ .../user/controller/UserController.java | 132 ++++ .../user/domain/HealthCheck/Gender.java | 35 + .../domain/HealthCheck/HealthCheckupRaw.java | 159 +++++ .../domain/HealthCheck/HealthNormalRange.java | 49 ++ .../user/domain/Oauth/JobCategory.java | 36 ++ .../user/domain/Oauth/RefreshToken.java | 42 ++ .../healthsync/user/domain/Oauth/User.java | 71 ++ .../user/domain/Oauth/UserRole.java | 5 + .../HealthCheckupHistoryResponse.java | 32 + .../HealthProfileSummaryResponse.java | 47 ++ .../user/dto/Oauth/OAuth2UserInfo.java | 31 + .../user/dto/Oauth/TokenRefreshRequest.java | 19 + .../user/dto/Oauth/TokenResponse.java | 23 + .../user/dto/UserProfile/LoginResponse.java | 30 + .../user/dto/UserProfile/OccupationDto.java | 43 ++ .../dto/UserProfile/UserProfileResponse.java | 50 ++ .../dto/UserProfile/UserUpdateRequest.java | 58 ++ .../exception/AuthenticationException.java | 13 + .../user/exception/TokenExpiredException.java | 13 + .../user/exception/UserNotFoundException.java | 13 + .../entity/HealthCheckupRawEntity.java | 269 ++++++++ .../entity/HealthNormalRangeEntity.java | 107 +++ .../entity/OccupationTypeEntity.java | 55 ++ .../repository/entity/RefreshTokenEntity.java | 75 +++ .../user/repository/entity/UserEntity.java | 109 ++++ .../jpa/HealthCheckupRawRepository.java | 49 ++ .../jpa/HealthNormalRangeRepository.java | 40 ++ .../jpa/OccupationTypeRepository.java | 24 + .../jpa/RefreshTokenRepository.java | 25 + .../user/repository/jpa/UserRepository.java | 86 +++ .../HealthProfile/HealthProfileService.java | 41 ++ .../HealthProfileServiceImpl.java | 121 ++++ .../user/service/Oauth/JwtTokenService.java | 57 ++ .../user/service/Oauth/OAuth2UserService.java | 61 ++ .../service/Oauth/RefreshTokenService.java | 13 + .../Oauth/RefreshTokenServiceImpl.java | 69 ++ .../user/service/UserProfile/UserService.java | 38 ++ .../service/UserProfile/UserServiceImpl.java | 183 ++++++ .../src/main/resources/application.yml | 110 ++++ 245 files changed, 17069 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile.backup create mode 100644 README.md create mode 100644 api-gateway/build.gradle create mode 100644 api-gateway/src/main/java/com/healthsync/gateway/GatewayApplication.java create mode 100644 api-gateway/src/main/java/com/healthsync/gateway/config/GatewayConfig.java create mode 100644 api-gateway/src/main/resources/application.yml create mode 100644 build.gradle create mode 100644 common/build.gradle create mode 100644 common/src/main/java/com/healthsync/common/config/JpaAuditingConfig.java create mode 100644 common/src/main/java/com/healthsync/common/config/RedisConfig.java create mode 100644 common/src/main/java/com/healthsync/common/config/SwaggerConfig.java create mode 100644 common/src/main/java/com/healthsync/common/constants/ErrorCode.java create mode 100644 common/src/main/java/com/healthsync/common/constants/SuccessCode.java create mode 100644 common/src/main/java/com/healthsync/common/dto/ApiResponse.java create mode 100644 common/src/main/java/com/healthsync/common/dto/ErrorResponse.java create mode 100644 common/src/main/java/com/healthsync/common/entity/BaseEntity.java create mode 100644 common/src/main/java/com/healthsync/common/exception/AuthenticationException.java create mode 100644 common/src/main/java/com/healthsync/common/exception/BusinessException.java create mode 100644 common/src/main/java/com/healthsync/common/exception/ExternalApiException.java create mode 100644 common/src/main/java/com/healthsync/common/exception/GlobalExceptionHandler.java create mode 100644 common/src/main/java/com/healthsync/common/exception/GlobalExceptionHandler.java.backup create mode 100644 common/src/main/java/com/healthsync/common/exception/MissionNotFoundException.java create mode 100644 common/src/main/java/com/healthsync/common/exception/UserNotFoundException.java create mode 100644 common/src/main/java/com/healthsync/common/exception/ValidationException.java create mode 100644 common/src/main/java/com/healthsync/common/util/DateUtil.java create mode 100644 common/src/main/java/com/healthsync/common/util/JwtUtil.java create mode 100644 deployment/container/Dockerfile create mode 100644 deployment/container/Dockerfile.backup create mode 100644 docker-compose.yml create mode 100644 goal-service/build.gradle create mode 100644 goal-service/src/main/java/com/healthsync/goal/GoalServiceApplication.java create mode 100644 goal-service/src/main/java/com/healthsync/goal/application_services/GoalUseCase.java create mode 100644 goal-service/src/main/java/com/healthsync/goal/config/GoalSecurityConfig.java create mode 100644 goal-service/src/main/java/com/healthsync/goal/config/SwaggerAccessController.java create mode 100644 goal-service/src/main/java/com/healthsync/goal/config/WebClientConfig.java create mode 100644 goal-service/src/main/java/com/healthsync/goal/domain/repositories/GoalRepository.java create mode 100644 goal-service/src/main/java/com/healthsync/goal/domain/services/GoalDomainService.java create mode 100644 goal-service/src/main/java/com/healthsync/goal/dto/AchievementStats.java create mode 100644 goal-service/src/main/java/com/healthsync/goal/dto/ActiveMissionsResponse.java create mode 100644 goal-service/src/main/java/com/healthsync/goal/dto/CelebrationRequest.java create mode 100644 goal-service/src/main/java/com/healthsync/goal/dto/CelebrationResponse.java create mode 100644 goal-service/src/main/java/com/healthsync/goal/dto/CompletionData.java create mode 100644 goal-service/src/main/java/com/healthsync/goal/dto/DailyMission.java create mode 100644 goal-service/src/main/java/com/healthsync/goal/dto/GoalSetupResponse.java create mode 100644 goal-service/src/main/java/com/healthsync/goal/dto/Mission.java create mode 100644 goal-service/src/main/java/com/healthsync/goal/dto/MissionCompleteRequest.java create mode 100644 goal-service/src/main/java/com/healthsync/goal/dto/MissionCompleteResponse.java create mode 100644 goal-service/src/main/java/com/healthsync/goal/dto/MissionDetailResponse.java create mode 100644 goal-service/src/main/java/com/healthsync/goal/dto/MissionDetailsRequest.java create mode 100644 goal-service/src/main/java/com/healthsync/goal/dto/MissionHistoryResponse.java create mode 100644 goal-service/src/main/java/com/healthsync/goal/dto/MissionRecommendationResponse.java create mode 100644 goal-service/src/main/java/com/healthsync/goal/dto/MissionResetRequest.java create mode 100644 goal-service/src/main/java/com/healthsync/goal/dto/MissionResetResponse.java create mode 100644 goal-service/src/main/java/com/healthsync/goal/dto/MissionSelectionRequest.java create mode 100644 goal-service/src/main/java/com/healthsync/goal/dto/MissionStats.java create mode 100644 goal-service/src/main/java/com/healthsync/goal/dto/Period.java create mode 100644 goal-service/src/main/java/com/healthsync/goal/dto/RecommendedMission.java create mode 100644 goal-service/src/main/java/com/healthsync/goal/dto/SelectedMission.java create mode 100644 goal-service/src/main/java/com/healthsync/goal/dto/SelectedMissionDetail.java create mode 100644 goal-service/src/main/java/com/healthsync/goal/dto/UserProfile.java create mode 100644 goal-service/src/main/java/com/healthsync/goal/infrastructure/adapters/CacheAdapter.java create mode 100644 goal-service/src/main/java/com/healthsync/goal/infrastructure/adapters/EventPublisherAdapter.java create mode 100644 goal-service/src/main/java/com/healthsync/goal/infrastructure/adapters/IntelligenceServiceAdapter.java create mode 100644 goal-service/src/main/java/com/healthsync/goal/infrastructure/adapters/UserServiceAdapter.java create mode 100644 goal-service/src/main/java/com/healthsync/goal/infrastructure/entities/MissionCompletionHistoryEntity.java create mode 100644 goal-service/src/main/java/com/healthsync/goal/infrastructure/entities/UserMissionGoalEntity.java create mode 100644 goal-service/src/main/java/com/healthsync/goal/infrastructure/ports/CachePort.java create mode 100644 goal-service/src/main/java/com/healthsync/goal/infrastructure/ports/EventPublisherPort.java create mode 100644 goal-service/src/main/java/com/healthsync/goal/infrastructure/ports/IntelligenceServicePort.java create mode 100644 goal-service/src/main/java/com/healthsync/goal/infrastructure/ports/UserServicePort.java create mode 100644 goal-service/src/main/java/com/healthsync/goal/infrastructure/repositories/GoalRepositoryImpl.java create mode 100644 goal-service/src/main/java/com/healthsync/goal/infrastructure/repositories/MissionCompletionJpaRepository.java create mode 100644 goal-service/src/main/java/com/healthsync/goal/infrastructure/repositories/UserMissionGoalJpaRepository.java create mode 100644 goal-service/src/main/java/com/healthsync/goal/infrastructure/services/IdGeneratorService.java create mode 100644 goal-service/src/main/java/com/healthsync/goal/infrastructure/utils/UserIdValidator.java create mode 100644 goal-service/src/main/java/com/healthsync/goal/interface_adapters/controllers/GoalController.java create mode 100644 goal-service/src/main/resources/application.yml create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 health-service/build.gradle create mode 100644 health-service/src/main/java/com/healthsync/HealthServiceApplication.java create mode 100644 health-service/src/main/java/com/healthsync/common/dto/CusApiResponse.java create mode 100644 health-service/src/main/java/com/healthsync/common/exception/CustomException.java create mode 100644 health-service/src/main/java/com/healthsync/common/exception/GlobalExceptionHandler.java create mode 100644 health-service/src/main/java/com/healthsync/common/response/ResponseHelper.java create mode 100644 health-service/src/main/java/com/healthsync/common/util/JwtUtil.java create mode 100644 health-service/src/main/java/com/healthsync/health/config/HealthJwtConfig.java create mode 100644 health-service/src/main/java/com/healthsync/health/config/HealthSecurityConfig.java create mode 100644 health-service/src/main/java/com/healthsync/health/config/HealthSwaggerConfig.java create mode 100644 health-service/src/main/java/com/healthsync/health/config/ObjectMapperConfig.java create mode 100644 health-service/src/main/java/com/healthsync/health/controller/HealthCheckupController.java create mode 100644 health-service/src/main/java/com/healthsync/health/domain/HealthCheck/Gender.java create mode 100644 health-service/src/main/java/com/healthsync/health/domain/HealthCheck/HealthCheckup.java create mode 100644 health-service/src/main/java/com/healthsync/health/domain/HealthCheck/HealthCheckupRaw.java create mode 100644 health-service/src/main/java/com/healthsync/health/domain/HealthCheck/HealthNormalRange.java create mode 100644 health-service/src/main/java/com/healthsync/health/domain/Oauth/JobCategory.java create mode 100644 health-service/src/main/java/com/healthsync/health/domain/Oauth/RefreshToken.java create mode 100644 health-service/src/main/java/com/healthsync/health/domain/Oauth/User.java create mode 100644 health-service/src/main/java/com/healthsync/health/domain/Oauth/UserRole.java create mode 100644 health-service/src/main/java/com/healthsync/health/dto/HealthCheck/HealthCheckupResponse.java create mode 100644 health-service/src/main/java/com/healthsync/health/dto/HealthCheck/HealthCheckupSyncResult.java create mode 100644 health-service/src/main/java/com/healthsync/health/dto/HealthCheck/HealthProfileDto.java create mode 100644 health-service/src/main/java/com/healthsync/health/dto/HealthCheck/HealthProfileHistoryResponse.java create mode 100644 health-service/src/main/java/com/healthsync/health/dto/Oauth/OAuth2UserInfo.java create mode 100644 health-service/src/main/java/com/healthsync/health/dto/Oauth/TokenRefreshRequest.java create mode 100644 health-service/src/main/java/com/healthsync/health/dto/Oauth/TokenResponse.java create mode 100644 health-service/src/main/java/com/healthsync/health/dto/UserProfile/LoginResponse.java create mode 100644 health-service/src/main/java/com/healthsync/health/dto/UserProfile/UserProfileResponse.java create mode 100644 health-service/src/main/java/com/healthsync/health/dto/UserProfile/UserUpdateRequest.java create mode 100644 health-service/src/main/java/com/healthsync/health/exception/AuthenticationException.java create mode 100644 health-service/src/main/java/com/healthsync/health/exception/TokenExpiredException.java create mode 100644 health-service/src/main/java/com/healthsync/health/exception/UserNotFoundException.java create mode 100644 health-service/src/main/java/com/healthsync/health/repository/entity/HealthCheckupEntity.java create mode 100644 health-service/src/main/java/com/healthsync/health/repository/entity/HealthCheckupRawEntity.java create mode 100644 health-service/src/main/java/com/healthsync/health/repository/entity/HealthNormalRangeEntity.java create mode 100644 health-service/src/main/java/com/healthsync/health/repository/entity/OccupationTypeEntity.java create mode 100644 health-service/src/main/java/com/healthsync/health/repository/entity/RefreshTokenEntity.java create mode 100644 health-service/src/main/java/com/healthsync/health/repository/entity/UserEntity.java create mode 100644 health-service/src/main/java/com/healthsync/health/repository/jpa/HealthCheckupRawRepository.java create mode 100644 health-service/src/main/java/com/healthsync/health/repository/jpa/HealthCheckupRepository.java create mode 100644 health-service/src/main/java/com/healthsync/health/repository/jpa/HealthNormalRangeRepository.java create mode 100644 health-service/src/main/java/com/healthsync/health/repository/jpa/OccupationTypeRepository.java create mode 100644 health-service/src/main/java/com/healthsync/health/repository/jpa/RefreshTokenRepository.java create mode 100644 health-service/src/main/java/com/healthsync/health/repository/jpa/UserRepository.java create mode 100644 health-service/src/main/java/com/healthsync/health/service/HealthProfile/HealthProfileService.java create mode 100644 health-service/src/main/java/com/healthsync/health/service/HealthProfile/HealthProfileServiceImpl.java create mode 100644 health-service/src/main/java/com/healthsync/health/service/HealthProfile/RealisticHealthMockDataGenerator.java create mode 100644 health-service/src/main/java/com/healthsync/health/service/Oauth/RefreshTokenService.java create mode 100644 health-service/src/main/java/com/healthsync/health/service/Oauth/RefreshTokenServiceImpl.java create mode 100644 health-service/src/main/java/com/healthsync/health/service/UserProfile/UserService.java create mode 100644 health-service/src/main/java/com/healthsync/health/service/UserProfile/UserServiceImpl.java create mode 100644 health-service/src/main/java/com/healthsync/health/util/HealthDataConverter.java create mode 100644 health-service/src/main/resources/application.yml create mode 100644 healthsync/deployment/manifest/configmap/api-gateway-config.yaml create mode 100644 healthsync/deployment/manifest/configmap/common-config.yaml create mode 100644 healthsync/deployment/manifest/configmap/goal-service-config.yaml create mode 100644 healthsync/deployment/manifest/configmap/health-service-config.yaml create mode 100644 healthsync/deployment/manifest/configmap/intelligence-service-config.yaml create mode 100644 healthsync/deployment/manifest/configmap/motivator-service-config.yaml create mode 100644 healthsync/deployment/manifest/configmap/user-service-config.yaml create mode 100644 healthsync/deployment/manifest/deployment/api-gateway-deployment.yaml create mode 100644 healthsync/deployment/manifest/deployment/goal-service-deployment.yaml create mode 100644 healthsync/deployment/manifest/deployment/health-service-deployment.yaml create mode 100644 healthsync/deployment/manifest/deployment/intelligence-service-deployment.yaml create mode 100644 healthsync/deployment/manifest/deployment/motivator-service-deployment.yaml create mode 100644 healthsync/deployment/manifest/deployment/user-service-deployment.yaml create mode 100644 healthsync/deployment/manifest/ingress/healthsync-ingress.yaml create mode 100644 healthsync/deployment/manifest/secret/common-secret.yaml create mode 100644 healthsync/deployment/manifest/secret/google-oauth-secret.yaml create mode 100644 healthsync/deployment/manifest/secret/intelligence-secret.yaml create mode 100644 healthsync/deployment/manifest/secret/servicebus-secret.yaml create mode 100644 healthsync/deployment/manifest/service/services.yaml create mode 100644 motivator-service/build.gradle create mode 100644 motivator-service/src/main/java/com/healthsync/motivator/MotivatorServiceApplication.java create mode 100644 motivator-service/src/main/java/com/healthsync/motivator/application_services/MotivationUseCase.java create mode 100644 motivator-service/src/main/java/com/healthsync/motivator/config/MotivatorSecurityConfig.java create mode 100644 motivator-service/src/main/java/com/healthsync/motivator/domain/repositories/NotificationRepository.java create mode 100644 motivator-service/src/main/java/com/healthsync/motivator/domain/services/BatchProcessingDomainService.java create mode 100644 motivator-service/src/main/java/com/healthsync/motivator/domain/services/MessageGenerationDomainService.java create mode 100644 motivator-service/src/main/java/com/healthsync/motivator/domain/services/UserAnalysisDomainService.java create mode 100644 motivator-service/src/main/java/com/healthsync/motivator/dto/BatchNotificationRequest.java create mode 100644 motivator-service/src/main/java/com/healthsync/motivator/dto/BatchNotificationResponse.java create mode 100644 motivator-service/src/main/java/com/healthsync/motivator/dto/BatchProcessingResult.java create mode 100644 motivator-service/src/main/java/com/healthsync/motivator/dto/DailyProgress.java create mode 100644 motivator-service/src/main/java/com/healthsync/motivator/dto/EncouragementRequest.java create mode 100644 motivator-service/src/main/java/com/healthsync/motivator/dto/EncouragementResponse.java create mode 100644 motivator-service/src/main/java/com/healthsync/motivator/dto/MissionStatus.java create mode 100644 motivator-service/src/main/java/com/healthsync/motivator/dto/ProgressAnalysis.java create mode 100644 motivator-service/src/main/java/com/healthsync/motivator/dto/UserMissionStatus.java create mode 100644 motivator-service/src/main/java/com/healthsync/motivator/dto/UserNotificationContext.java create mode 100644 motivator-service/src/main/java/com/healthsync/motivator/enums/EngagementLevel.java create mode 100644 motivator-service/src/main/java/com/healthsync/motivator/enums/MotivationType.java create mode 100644 motivator-service/src/main/java/com/healthsync/motivator/enums/UrgencyLevel.java create mode 100644 motivator-service/src/main/java/com/healthsync/motivator/infrastructure/adapters/CacheAdapter.java create mode 100644 motivator-service/src/main/java/com/healthsync/motivator/infrastructure/adapters/ClaudeApiAdapter.java create mode 100644 motivator-service/src/main/java/com/healthsync/motivator/infrastructure/adapters/EventPublisherAdapter.java create mode 100644 motivator-service/src/main/java/com/healthsync/motivator/infrastructure/adapters/GoalServiceAdapter.java create mode 100644 motivator-service/src/main/java/com/healthsync/motivator/infrastructure/batch/NotificationBatchJob.java create mode 100644 motivator-service/src/main/java/com/healthsync/motivator/infrastructure/entities/NotificationLogEntity.java create mode 100644 motivator-service/src/main/java/com/healthsync/motivator/infrastructure/ports/CachePort.java create mode 100644 motivator-service/src/main/java/com/healthsync/motivator/infrastructure/ports/ClaudeApiPort.java create mode 100644 motivator-service/src/main/java/com/healthsync/motivator/infrastructure/ports/EventPublisherPort.java create mode 100644 motivator-service/src/main/java/com/healthsync/motivator/infrastructure/ports/GoalServicePort.java create mode 100644 motivator-service/src/main/java/com/healthsync/motivator/infrastructure/repositories/NotificationLogJpaRepository.java create mode 100644 motivator-service/src/main/java/com/healthsync/motivator/infrastructure/repositories/NotificationRepositoryImpl.java create mode 100644 motivator-service/src/main/java/com/healthsync/motivator/interface_adapters/controllers/NotificationController.java create mode 100644 motivator-service/src/main/resources/application.yml create mode 100644 scripts/init-db.sql create mode 100644 settings.gradle create mode 100644 user-service/build.gradle create mode 100644 user-service/src/main/java/com/healthsync/UserServiceApplication.java create mode 100644 user-service/src/main/java/com/healthsync/common/dto/ApiResponse.java create mode 100644 user-service/src/main/java/com/healthsync/common/exception/CustomException.java create mode 100644 user-service/src/main/java/com/healthsync/common/exception/GlobalExceptionHandler.java create mode 100644 user-service/src/main/java/com/healthsync/common/response/ResponseHelper.java create mode 100644 user-service/src/main/java/com/healthsync/common/util/JwtUtil.java create mode 100644 user-service/src/main/java/com/healthsync/user/config/OAuth2Config.java create mode 100644 user-service/src/main/java/com/healthsync/user/config/OAuth2LoginSuccessHandler.java create mode 100644 user-service/src/main/java/com/healthsync/user/config/ObjectMapperConfig.java create mode 100644 user-service/src/main/java/com/healthsync/user/config/UserJwtConfig.java create mode 100644 user-service/src/main/java/com/healthsync/user/config/UserSecurityConfig.java create mode 100644 user-service/src/main/java/com/healthsync/user/config/UserSwaggerConfig.java create mode 100644 user-service/src/main/java/com/healthsync/user/controller/AuthController.java create mode 100644 user-service/src/main/java/com/healthsync/user/controller/UserController.java create mode 100644 user-service/src/main/java/com/healthsync/user/domain/HealthCheck/Gender.java create mode 100644 user-service/src/main/java/com/healthsync/user/domain/HealthCheck/HealthCheckupRaw.java create mode 100644 user-service/src/main/java/com/healthsync/user/domain/HealthCheck/HealthNormalRange.java create mode 100644 user-service/src/main/java/com/healthsync/user/domain/Oauth/JobCategory.java create mode 100644 user-service/src/main/java/com/healthsync/user/domain/Oauth/RefreshToken.java create mode 100644 user-service/src/main/java/com/healthsync/user/domain/Oauth/User.java create mode 100644 user-service/src/main/java/com/healthsync/user/domain/Oauth/UserRole.java create mode 100644 user-service/src/main/java/com/healthsync/user/dto/HealthCheck/HealthCheckupHistoryResponse.java create mode 100644 user-service/src/main/java/com/healthsync/user/dto/HealthCheck/HealthProfileSummaryResponse.java create mode 100644 user-service/src/main/java/com/healthsync/user/dto/Oauth/OAuth2UserInfo.java create mode 100644 user-service/src/main/java/com/healthsync/user/dto/Oauth/TokenRefreshRequest.java create mode 100644 user-service/src/main/java/com/healthsync/user/dto/Oauth/TokenResponse.java create mode 100644 user-service/src/main/java/com/healthsync/user/dto/UserProfile/LoginResponse.java create mode 100644 user-service/src/main/java/com/healthsync/user/dto/UserProfile/OccupationDto.java create mode 100644 user-service/src/main/java/com/healthsync/user/dto/UserProfile/UserProfileResponse.java create mode 100644 user-service/src/main/java/com/healthsync/user/dto/UserProfile/UserUpdateRequest.java create mode 100644 user-service/src/main/java/com/healthsync/user/exception/AuthenticationException.java create mode 100644 user-service/src/main/java/com/healthsync/user/exception/TokenExpiredException.java create mode 100644 user-service/src/main/java/com/healthsync/user/exception/UserNotFoundException.java create mode 100644 user-service/src/main/java/com/healthsync/user/repository/entity/HealthCheckupRawEntity.java create mode 100644 user-service/src/main/java/com/healthsync/user/repository/entity/HealthNormalRangeEntity.java create mode 100644 user-service/src/main/java/com/healthsync/user/repository/entity/OccupationTypeEntity.java create mode 100644 user-service/src/main/java/com/healthsync/user/repository/entity/RefreshTokenEntity.java create mode 100644 user-service/src/main/java/com/healthsync/user/repository/entity/UserEntity.java create mode 100644 user-service/src/main/java/com/healthsync/user/repository/jpa/HealthCheckupRawRepository.java create mode 100644 user-service/src/main/java/com/healthsync/user/repository/jpa/HealthNormalRangeRepository.java create mode 100644 user-service/src/main/java/com/healthsync/user/repository/jpa/OccupationTypeRepository.java create mode 100644 user-service/src/main/java/com/healthsync/user/repository/jpa/RefreshTokenRepository.java create mode 100644 user-service/src/main/java/com/healthsync/user/repository/jpa/UserRepository.java create mode 100644 user-service/src/main/java/com/healthsync/user/service/HealthProfile/HealthProfileService.java create mode 100644 user-service/src/main/java/com/healthsync/user/service/HealthProfile/HealthProfileServiceImpl.java create mode 100644 user-service/src/main/java/com/healthsync/user/service/Oauth/JwtTokenService.java create mode 100644 user-service/src/main/java/com/healthsync/user/service/Oauth/OAuth2UserService.java create mode 100644 user-service/src/main/java/com/healthsync/user/service/Oauth/RefreshTokenService.java create mode 100644 user-service/src/main/java/com/healthsync/user/service/Oauth/RefreshTokenServiceImpl.java create mode 100644 user-service/src/main/java/com/healthsync/user/service/UserProfile/UserService.java create mode 100644 user-service/src/main/java/com/healthsync/user/service/UserProfile/UserServiceImpl.java create mode 100644 user-service/src/main/resources/application.yml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fad819b --- /dev/null +++ b/.gitignore @@ -0,0 +1,65 @@ +# Gradle +.gradle/ +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +# IDE +.idea/ +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +# VS Code +.vscode/ + +# Eclipse +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +# NetBeans +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Environment variables +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# Database +*.db +*.sqlite + +# Docker +docker-compose.override.yml +docker-compose.backup.yml \ No newline at end of file diff --git a/Dockerfile.backup b/Dockerfile.backup new file mode 100644 index 0000000..9f8fe1f --- /dev/null +++ b/Dockerfile.backup @@ -0,0 +1,26 @@ +# 빌드 스테이지 +FROM gradle:8.5-jdk17 AS build + +WORKDIR /home/gradle/src +COPY --chown=gradle:gradle . . + +# 빌드 인수로 서비스 이름 받기 +ARG SERVICE_NAME +RUN gradle :${SERVICE_NAME}:build --no-daemon + +# 실행 스테이지 +FROM eclipse-temurin:17-jre + +WORKDIR /app + +# 빌드 인수 다시 선언 (멀티스테이지에서 필요) +ARG SERVICE_NAME + +# 해당 서비스의 JAR 파일 복사 +COPY --from=build /home/gradle/src/${SERVICE_NAME}/build/libs/*.jar app.jar + +# 서비스별 포트 설정 (기본값) +EXPOSE 8080 + +# 애플리케이션 실행 +ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..ee1f026 --- /dev/null +++ b/README.md @@ -0,0 +1,148 @@ +# HealthSync Backend Services + +AI 기반 개인형 맞춤 건강관리 서비스의 백엔드 시스템입니다. + +## 🏗️ 아키텍처 + +### 마이크로서비스 구성 +- **API Gateway** (Port: 8080) - 통합 진입점 및 라우팅 +- **User Service** (Port: 8081) - 사용자 관리 및 인증 +- **Health Service** (Port: 8082) - 건강검진 데이터 관리 +- **Intelligence Service** (Port: 8083) - AI 분석 및 채팅 +- **Goal Service** (Port: 8084) - 목표 설정 및 미션 관리 +- **Motivator Service** (Port: 8085) - 동기부여 메시지 및 알림 + +### 기술 스택 +- **Framework**: Spring Boot 3.4.0, Spring WebMVC +- **Language**: Java 21 +- **Build Tool**: Gradle +- **Database**: PostgreSQL 15 +- **Cache**: Redis 7 +- **Architecture**: Clean Architecture +- **Documentation**: OpenAPI 3 (Swagger) + +## 🚀 실행 방법 + +### 1. Prerequisites +- Java 21+ +- Docker & Docker Compose +- Claude API Key (선택사항) + +### 2. Docker Compose로 실행 +```bash +# 환경변수 설정 (선택사항) +export CLAUDE_API_KEY=your_claude_api_key + +# 서비스 시작 +docker-compose up -d + +# 로그 확인 +docker-compose logs -f +``` + +### 3. 개발 환경에서 실행 +```bash +# 의존성 데이터베이스 시작 +docker-compose up postgres redis -d + +# 각 서비스 개별 실행 +./gradlew :api-gateway:bootRun +./gradlew :user-service:bootRun +./gradlew :health-service:bootRun +./gradlew :intelligence-service:bootRun +./gradlew :goal-service:bootRun +./gradlew :motivator-service:bootRun +``` + +## 📡 API 엔드포인트 + +### API Gateway (http://localhost:8080) +모든 서비스의 통합 진입점 + +### 주요 API 경로 +- `POST /api/auth/login` - Google SSO 로그인 +- `POST /api/users/register` - 회원가입 +- `POST /api/health/checkup/sync` - 건강검진 연동 +- `GET /api/intelligence/health/diagnosis` - AI 건강 진단 +- `POST /api/intelligence/missions/recommend` - AI 미션 추천 +- `POST /api/goals/missions/select` - 미션 선택 +- `GET /api/goals/missions/active` - 활성 미션 조회 +- `POST /api/motivator/notifications/encouragement` - 독려 메시지 + +### API 문서 +각 서비스의 Swagger UI에서 상세 API 문서를 확인할 수 있습니다: +- API Gateway: http://localhost:8080/swagger-ui.html +- User Service: http://localhost:8081/swagger-ui.html +- Health Service: http://localhost:8082/swagger-ui.html +- Intelligence Service: http://localhost:8083/swagger-ui.html +- Goal Service: http://localhost:8084/swagger-ui.html +- Motivator Service: http://localhost:8085/swagger-ui.html + +## 🧪 테스트 + +```bash +# 전체 테스트 실행 +./gradlew test + +# 특정 서비스 테스트 +./gradlew :user-service:test +``` + +## 📊 모니터링 + +### Health Check +- API Gateway: http://localhost:8080/actuator/health +- 각 서비스: http://localhost:808x/actuator/health + +### Metrics +- Prometheus metrics: http://localhost:808x/actuator/prometheus + +## 🛠️ 개발 가이드 + +### Clean Architecture 패턴 +각 서비스는 Clean Architecture 패턴을 따릅니다: +- `interface-adapters/controllers` - API 컨트롤러 +- `application-services` - 유스케이스 +- `domain/services` - 도메인 서비스 +- `domain/repositories` - 리포지토리 인터페이스 +- `infrastructure` - 외부 의존성 구현 + +### 코딩 컨벤션 +- Java 21 문법 활용 +- Lombok 사용으로 boilerplate 코드 최소화 +- 모든 클래스에 JavaDoc 작성 +- 로깅을 통한 추적성 확보 + +## 🔒 보안 + +- JWT 기반 인증 +- CORS 설정 +- Input validation +- SQL injection 방지 + +## 📝 프로젝트 구조 + +``` +healthsync-backend/ +├── api-gateway/ # API Gateway +├── user-service/ # 사용자 서비스 +├── health-service/ # 건강 서비스 +├── intelligence-service/ # AI 서비스 +├── goal-service/ # 목표 서비스 +├── motivator-service/ # 동기부여 서비스 +├── common/ # 공통 라이브러리 +├── docker-compose.yml # Docker 구성 +└── scripts/ # 초기화 스크립트 +``` + +## 🤝 기여 방법 + +1. 이슈 등록 +2. 브랜치 생성 (`git checkout -b feature/amazing-feature`) +3. 커밋 (`git commit -m 'Add amazing feature'`) +4. 푸시 (`git push origin feature/amazing-feature`) +5. Pull Request 생성 + +## 📄 라이선스 + +이 프로젝트는 MIT 라이선스를 따릅니다. \ No newline at end of file diff --git a/api-gateway/build.gradle b/api-gateway/build.gradle new file mode 100644 index 0000000..7265805 --- /dev/null +++ b/api-gateway/build.gradle @@ -0,0 +1,43 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.4.0' + id 'io.spring.dependency-management' version '1.1.4' +} + +group = 'com.healthsync' +version = '1.0.0' + +java { + sourceCompatibility = '17' +} + +configurations { + compileOnly { + extendsFrom annotationProcessor + } +} + +dependencies { + implementation project(':common') + implementation 'org.springframework.cloud:spring-cloud-starter-gateway' + implementation 'org.springframework.cloud:spring-cloud-starter-loadbalancer' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation 'org.springdoc:springdoc-openapi-starter-webflux-ui:2.7.0' + + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + + testImplementation 'org.springframework.boot:spring-boot-starter-test' +} + +dependencyManagement { + imports { + mavenBom 'org.springframework.cloud:spring-cloud-dependencies:2023.0.0' + } +} + +tasks.named('test') { + useJUnitPlatform() +} + diff --git a/api-gateway/src/main/java/com/healthsync/gateway/GatewayApplication.java b/api-gateway/src/main/java/com/healthsync/gateway/GatewayApplication.java new file mode 100644 index 0000000..556078a --- /dev/null +++ b/api-gateway/src/main/java/com/healthsync/gateway/GatewayApplication.java @@ -0,0 +1,19 @@ +package com.healthsync.gateway; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * API Gateway의 메인 애플리케이션 클래스입니다. + * 모든 서비스로의 요청을 라우팅하는 역할을 담당합니다. + * + * @author healthsync-team + * @version 1.0 + */ +@SpringBootApplication +public class GatewayApplication { + + public static void main(String[] args) { + SpringApplication.run(GatewayApplication.class, args); + } +} diff --git a/api-gateway/src/main/java/com/healthsync/gateway/config/GatewayConfig.java b/api-gateway/src/main/java/com/healthsync/gateway/config/GatewayConfig.java new file mode 100644 index 0000000..f711e05 --- /dev/null +++ b/api-gateway/src/main/java/com/healthsync/gateway/config/GatewayConfig.java @@ -0,0 +1,43 @@ +package com.healthsync.gateway.config; + +import org.springframework.cloud.gateway.filter.GlobalFilter; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; +import reactor.core.publisher.Mono; + +/** + * Gateway 설정을 관리하는 클래스입니다. + * + * @author healthsync-team + * @version 1.0 + */ +@Configuration +public class GatewayConfig { + + /** + * 요청/응답 로깅을 위한 글로벌 필터를 설정합니다. + * + * @return 로깅 글로벌 필터 + */ + @Bean + @Order(-1) + public GlobalFilter loggingFilter() { + return (exchange, chain) -> { + String requestPath = exchange.getRequest().getPath().value(); + String requestMethod = exchange.getRequest().getMethod().name(); + + long startTime = System.currentTimeMillis(); + + return chain.filter(exchange).then( + Mono.fromRunnable(() -> { + long endTime = System.currentTimeMillis(); + long duration = endTime - startTime; + + System.out.printf("Gateway: %s %s - %dms%n", + requestMethod, requestPath, duration); + }) + ); + }; + } +} diff --git a/api-gateway/src/main/resources/application.yml b/api-gateway/src/main/resources/application.yml new file mode 100644 index 0000000..647dae9 --- /dev/null +++ b/api-gateway/src/main/resources/application.yml @@ -0,0 +1,116 @@ +spring: + application: + name: api-gateway + security : + enabled: false + + # Gateway는 Reactive 웹 애플리케이션으로 설정 + main: + web-application-type: reactive + + autoconfigure: + exclude: + - org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration + - org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration + - org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration + - org.springframework.boot.autoconfigure.data.redis.RedisReactiveAutoConfiguration + # 보안 관련 모든 자동 설정 제외 + - org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration + - org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration + - org.springframework.boot.autoconfigure.security.reactive.ReactiveSecurityAutoConfiguration + - org.springframework.boot.autoconfigure.security.reactive.ReactiveUserDetailsServiceAutoConfiguration + # Actuator 보안 자동 설정 제외 추가 + - org.springframework.boot.actuate.autoconfigure.security.servlet.ManagementWebSecurityAutoConfiguration + - org.springframework.boot.actuate.autoconfigure.security.reactive.ReactiveManagementWebSecurityAutoConfiguration + + # Spring Cloud 설정 (통합) + cloud: + # 호환성 체크 비활성화 + compatibility-verifier: + enabled: false + + # Gateway 설정 + gateway: + routes: + # User Service 라우팅 + - id: user-service + uri: ${USER_SERVICE_URL:http://localhost:8081} + predicates: + - Path=/api/auth/**, /api/user/**, /login/oauth2/code/**, /oauth2/authorization/** + filters: + - RewritePath=/api/(?!oauth2)(?.*), /${segment} + + # Health Service 라우팅 + - id: health-service + uri: ${HEALTH_SERVICE_URL:http://localhost:8082} + predicates: + - Path=/api/health/** + filters: + - RewritePath=/api/(?.*), /$\{segment} + + # Intelligence Service 라우팅 + - id: intelligence-service + uri: ${INTELLIGENCE_SERVICE_URL:http://localhost:8083} + predicates: + - Path=/api/intelligence/** + filters: + - RewritePath=/api/(?.*), /$\{segment} + + # Goal Service 라우팅 + - id: goal-service + uri: ${GOAL_SERVICE_URL:http://localhost:8084} + predicates: + - Path=/api/goals/** + filters: + - RewritePath=/api/(?.*), /$\{segment} + + # Motivator Service 라우팅 + - id: motivator-service + uri: ${MOTIVATOR_SERVICE_URL:http://localhost:8085} + predicates: + - Path=/api/motivator/** + filters: + - RewritePath=/api/(?.*), /$\{segment} + + # CORS 설정 + globalcors: + cors-configurations: + '[/**]': + allowedOriginPatterns: "*" + allowedMethods: + - GET + - POST + - PUT + - DELETE + - OPTIONS + allowedHeaders: "*" + allowCredentials: true + + default-filters: + - DedupeResponseHeader=Access-Control-Allow-Credentials Access-Control-Allow-Origin + +# 서버 포트 설정 +server: + port: ${SERVER_PORT:8080} + +# 모니터링 엔드포인트 +management: + endpoints: + web: + exposure: + include: health,info + endpoint: + health: + show-details: always + +# 로깅 설정 +logging: + level: + org.springframework.cloud.gateway: ${GATEWAY_LOG_LEVEL:TRACE} + reactor.netty: ${NETTY_LOG_LEVEL:TRACE} + reactor.netty.http.client: TRACE + reactor.netty.http.server: TRACE + org.apache.http: TRACE + org.apache.http.wire: TRACE # HTTP 와이어 레벨 로깅 (실제 HTTP 메시지) + pattern: + console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n" \ No newline at end of file diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..e9ef20d --- /dev/null +++ b/build.gradle @@ -0,0 +1,60 @@ +plugins { + id 'org.springframework.boot' version '3.4.0' apply false + id 'io.spring.dependency-management' version '1.1.4' apply false + id 'java' +} + +subprojects { + apply plugin: 'java' + apply plugin: 'org.springframework.boot' + apply plugin: 'io.spring.dependency-management' + + group = 'com.healthsync' + version = '0.0.1-SNAPSHOT' + + java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + repositories { + mavenCentral() + } + + dependencies { + implementation 'org.springframework.boot:spring-boot-starter' + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + + runtimeOnly 'org.postgresql:postgresql' + + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0' + + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' + + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + + implementation 'org.springframework.boot:spring-boot-starter-webflux' + + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.security:spring-security-test' + + implementation 'org.springframework.boot:spring-boot-starter-actuator' + } + + tasks.named('test') { + useJUnitPlatform() + } + + tasks.withType(JavaCompile) { + options.release = 17 + options.encoding = 'UTF-8' + } +} diff --git a/common/build.gradle b/common/build.gradle new file mode 100644 index 0000000..16c675d --- /dev/null +++ b/common/build.gradle @@ -0,0 +1,8 @@ +bootJar { + enabled = false +} + +jar { + enabled = true + archiveClassifier = '' +} \ No newline at end of file diff --git a/common/src/main/java/com/healthsync/common/config/JpaAuditingConfig.java b/common/src/main/java/com/healthsync/common/config/JpaAuditingConfig.java new file mode 100644 index 0000000..acb7a15 --- /dev/null +++ b/common/src/main/java/com/healthsync/common/config/JpaAuditingConfig.java @@ -0,0 +1,19 @@ +// common/src/main/java/com/healthsync/common/config/JpaAuditingConfig.java +package com.healthsync.common.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +/** + * JPA Auditing 설정을 활성화하는 클래스입니다. + * BaseEntity의 @CreatedDate, @LastModifiedDate 어노테이션이 동작하도록 합니다. + * + * @author healthsync-team + * @version 1.0 + */ +@Configuration +@EnableJpaAuditing +public class JpaAuditingConfig { + // JPA Auditing 기능을 활성화합니다. + // BaseEntity의 생성일시, 수정일시가 자동으로 설정됩니다. +} \ No newline at end of file diff --git a/common/src/main/java/com/healthsync/common/config/RedisConfig.java b/common/src/main/java/com/healthsync/common/config/RedisConfig.java new file mode 100644 index 0000000..261432e --- /dev/null +++ b/common/src/main/java/com/healthsync/common/config/RedisConfig.java @@ -0,0 +1,41 @@ +package com.healthsync.common.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +/** + * Redis 설정을 관리하는 클래스입니다. + * + * @author healthsync-team + * @version 1.0 + */ +@Configuration +public class RedisConfig { + + /** + * RedisTemplate 빈을 생성합니다. + * JSON 직렬화를 통해 객체 저장을 지원합니다. + * + * @param connectionFactory Redis 연결 팩토리 + * @return RedisTemplate + */ + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(connectionFactory); + + // Key는 String 직렬화 + template.setKeySerializer(new StringRedisSerializer()); + template.setHashKeySerializer(new StringRedisSerializer()); + + // Value는 JSON 직렬화 + template.setValueSerializer(new GenericJackson2JsonRedisSerializer()); + template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer()); + + return template; + } +} diff --git a/common/src/main/java/com/healthsync/common/config/SwaggerConfig.java b/common/src/main/java/com/healthsync/common/config/SwaggerConfig.java new file mode 100644 index 0000000..97b3ba4 --- /dev/null +++ b/common/src/main/java/com/healthsync/common/config/SwaggerConfig.java @@ -0,0 +1,40 @@ +package com.healthsync.common.config; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Swagger API 문서화 설정을 관리하는 클래스입니다. + * + * @author healthsync-team + * @version 1.0 + */ +@Configuration +public class SwaggerConfig { + + /** + * OpenAPI 설정을 생성합니다. + * JWT 인증을 포함한 API 문서를 제공합니다. + * + * @return OpenAPI + */ + @Bean + public OpenAPI openAPI() { + return new OpenAPI() + .info(new Info() + .title("HealthSync API") + .description("AI 기반 개인형 맞춤 건강관리 서비스 API") + .version("1.0.0")) + .addSecurityItem(new SecurityRequirement().addList("Bearer Authentication")) + .components(new io.swagger.v3.oas.models.Components() + .addSecuritySchemes("Bearer Authentication", + new SecurityScheme() + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT"))); + } +} diff --git a/common/src/main/java/com/healthsync/common/constants/ErrorCode.java b/common/src/main/java/com/healthsync/common/constants/ErrorCode.java new file mode 100644 index 0000000..9c7480a --- /dev/null +++ b/common/src/main/java/com/healthsync/common/constants/ErrorCode.java @@ -0,0 +1,39 @@ +package com.healthsync.common.constants; + +/** + * 시스템에서 사용되는 에러 코드 상수 클래스입니다. + * + * @author healthsync-team + * @version 1.0 + */ +public class ErrorCode { + + // 인증 관련 + public static final String AUTHENTICATION_FAILED = "AUTH_001"; + public static final String INVALID_TOKEN = "AUTH_002"; + public static final String TOKEN_EXPIRED = "AUTH_003"; + + // 사용자 관련 + public static final String USER_NOT_FOUND = "USER_001"; + public static final String USER_ALREADY_EXISTS = "USER_002"; + public static final String INVALID_USER_DATA = "USER_003"; + + // 건강 데이터 관련 + public static final String HEALTH_DATA_NOT_FOUND = "HEALTH_001"; + public static final String HEALTH_SYNC_FAILED = "HEALTH_002"; + public static final String FILE_UPLOAD_FAILED = "HEALTH_003"; + + // AI 관련 + public static final String AI_SERVICE_UNAVAILABLE = "AI_001"; + public static final String AI_ANALYSIS_FAILED = "AI_002"; + public static final String CLAUDE_API_ERROR = "AI_003"; + + // 목표 관련 + public static final String GOAL_NOT_FOUND = "GOAL_001"; + public static final String MISSION_NOT_FOUND = "GOAL_002"; + public static final String INVALID_MISSION_STATUS = "GOAL_003"; + + // 외부 서비스 관련 + public static final String EXTERNAL_SERVICE_ERROR = "EXT_001"; + public static final String GOOGLE_OAUTH_ERROR = "EXT_002"; +} diff --git a/common/src/main/java/com/healthsync/common/constants/SuccessCode.java b/common/src/main/java/com/healthsync/common/constants/SuccessCode.java new file mode 100644 index 0000000..3057b27 --- /dev/null +++ b/common/src/main/java/com/healthsync/common/constants/SuccessCode.java @@ -0,0 +1,26 @@ +package com.healthsync.common.constants; + +/** + * 성공 응답 메시지 상수 클래스입니다. + * + * @author healthsync-team + * @version 1.0 + */ +public class SuccessCode { + + // 인증 관련 + public static final String LOGIN_SUCCESS = "로그인이 완료되었습니다."; + public static final String LOGOUT_SUCCESS = "로그아웃이 완료되었습니다."; + + // 사용자 관련 + public static final String USER_REGISTRATION_SUCCESS = "회원가입이 완료되었습니다."; + public static final String PROFILE_UPDATE_SUCCESS = "프로필이 업데이트되었습니다."; + + // 건강 데이터 관련 + public static final String HEALTH_SYNC_SUCCESS = "건강검진 데이터 연동이 완료되었습니다."; + public static final String FILE_UPLOAD_SUCCESS = "파일 업로드가 완료되었습니다."; + + // 목표 관련 + public static final String MISSION_COMPLETE_SUCCESS = "미션이 완료되었습니다."; + public static final String GOAL_SETUP_SUCCESS = "목표 설정이 완료되었습니다."; +} diff --git a/common/src/main/java/com/healthsync/common/dto/ApiResponse.java b/common/src/main/java/com/healthsync/common/dto/ApiResponse.java new file mode 100644 index 0000000..a091515 --- /dev/null +++ b/common/src/main/java/com/healthsync/common/dto/ApiResponse.java @@ -0,0 +1,85 @@ +// common/src/main/java/com/healthsync/common/dto/ApiResponse.java +package com.healthsync.common.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import java.time.LocalDateTime; + +/** + * 공통 API 응답 DTO 클래스입니다. + * + * @author healthsync-team + * @version 1.0 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ApiResponse { + + private int status; + private String message; + private T data; + + @Builder.Default + private String timestamp = LocalDateTime.now().toString(); + + private String traceId; + + /** + * 성공 응답을 생성합니다. + * + * @param data 응답 데이터 + * @return ApiResponse 인스턴스 + */ + public static ApiResponse success(T data) { + return ApiResponse.builder() + .status(200) + .message("SUCCESS") + .data(data) + .timestamp(LocalDateTime.now().toString()) + .build(); + } + + /** + * 성공 응답을 생성합니다. + * + * @param message 성공 메시지 + * @param data 응답 데이터 + * @return ApiResponse 인스턴스 + */ + public static ApiResponse success(String message, T data) { + return ApiResponse.builder() + .status(200) + .message(message) + .data(data) + .timestamp(LocalDateTime.now().toString()) + .build(); + } + + /** + * 에러 응답을 생성합니다. + * + * @param message 에러 메시지 + * @return ApiResponse 인스턴스 + */ + public static ApiResponse error(String message) { + return ApiResponse.builder() + .status(500) + .message(message) + .data(null) + .timestamp(LocalDateTime.now().toString()) + .build(); + } + + /** + * 성공 여부를 반환합니다. + * + * @return 성공 여부 + */ + public boolean isSuccess() { + return status >= 200 && status < 300; + } +} \ No newline at end of file diff --git a/common/src/main/java/com/healthsync/common/dto/ErrorResponse.java b/common/src/main/java/com/healthsync/common/dto/ErrorResponse.java new file mode 100644 index 0000000..6ce6935 --- /dev/null +++ b/common/src/main/java/com/healthsync/common/dto/ErrorResponse.java @@ -0,0 +1,27 @@ +// common/src/main/java/com/healthsync/common/dto/ErrorResponse.java +package com.healthsync.common.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import java.time.LocalDateTime; + +/** + * API 에러 응답 DTO 클래스입니다. + * + * @author healthsync-team + * @version 1.0 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ErrorResponse { + + private String code; + private String message; + + @Builder.Default + private String timestamp = LocalDateTime.now().toString(); +} \ No newline at end of file diff --git a/common/src/main/java/com/healthsync/common/entity/BaseEntity.java b/common/src/main/java/com/healthsync/common/entity/BaseEntity.java new file mode 100644 index 0000000..7d128cd --- /dev/null +++ b/common/src/main/java/com/healthsync/common/entity/BaseEntity.java @@ -0,0 +1,60 @@ +// common/src/main/java/com/healthsync/common/entity/BaseEntity.java +package com.healthsync.common.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +/** + * JPA 엔티티의 공통 필드를 정의하는 기본 클래스입니다. + * + * @author healthsync-team + * @version 1.0 + */ +@Getter +@Setter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class BaseEntity { + + /** + * 생성 일시 + */ + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + /** + * 수정 일시 + */ + @LastModifiedDate + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + /** + * 엔티티 생성 전 실행되는 메서드 + */ + @PrePersist + protected void onCreate() { + LocalDateTime now = LocalDateTime.now(); + if (createdAt == null) { + createdAt = now; + } + if (updatedAt == null) { + updatedAt = now; + } + } + + /** + * 엔티티 수정 전 실행되는 메서드 + */ + @PreUpdate + protected void onUpdate() { + updatedAt = LocalDateTime.now(); + } +} \ No newline at end of file diff --git a/common/src/main/java/com/healthsync/common/exception/AuthenticationException.java b/common/src/main/java/com/healthsync/common/exception/AuthenticationException.java new file mode 100644 index 0000000..991e8b1 --- /dev/null +++ b/common/src/main/java/com/healthsync/common/exception/AuthenticationException.java @@ -0,0 +1,22 @@ +// common/src/main/java/com/healthsync/common/exception/AuthenticationException.java +package com.healthsync.common.exception; + +import com.healthsync.common.constants.ErrorCode; + +/** + * 인증 실패 시 발생하는 예외입니다. + * + * @author healthsync-team + * @version 1.0 + */ +public class AuthenticationException extends BusinessException { + + /** + * AuthenticationException 생성자 + * + * @param message 에러 메시지 + */ + public AuthenticationException(String message) { + super(ErrorCode.AUTHENTICATION_FAILED, message); + } +} \ No newline at end of file diff --git a/common/src/main/java/com/healthsync/common/exception/BusinessException.java b/common/src/main/java/com/healthsync/common/exception/BusinessException.java new file mode 100644 index 0000000..4b82c07 --- /dev/null +++ b/common/src/main/java/com/healthsync/common/exception/BusinessException.java @@ -0,0 +1,26 @@ +// common/src/main/java/com/healthsync/common/exception/BusinessException.java +package com.healthsync.common.exception; + +import lombok.Getter; + +/** + * 비즈니스 로직에서 발생하는 예외를 처리하는 클래스입니다. + * + * @author healthsync-team + * @version 1.0 + */ +@Getter +public class BusinessException extends RuntimeException { + + private final String errorCode; + + public BusinessException(String errorCode, String message) { + super(message); + this.errorCode = errorCode; + } + + public BusinessException(String errorCode, String message, Throwable cause) { + super(message, cause); + this.errorCode = errorCode; + } +} \ No newline at end of file diff --git a/common/src/main/java/com/healthsync/common/exception/ExternalApiException.java b/common/src/main/java/com/healthsync/common/exception/ExternalApiException.java new file mode 100644 index 0000000..9dc5520 --- /dev/null +++ b/common/src/main/java/com/healthsync/common/exception/ExternalApiException.java @@ -0,0 +1,18 @@ +package com.healthsync.common.exception; + +/** + * 외부 API 호출 실패 시 발생하는 예외 클래스입니다. + * + * @author healthsync-team + * @version 1.0 + */ +public class ExternalApiException extends BusinessException { + + public ExternalApiException(String message) { + super("EXTERNAL_API_ERROR", message); + } + + public ExternalApiException(String message, Throwable cause) { + super("EXTERNAL_API_ERROR", message, cause); + } +} \ No newline at end of file diff --git a/common/src/main/java/com/healthsync/common/exception/GlobalExceptionHandler.java b/common/src/main/java/com/healthsync/common/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..8e57963 --- /dev/null +++ b/common/src/main/java/com/healthsync/common/exception/GlobalExceptionHandler.java @@ -0,0 +1,52 @@ +package com.healthsync.common.exception; + +import com.healthsync.common.dto.ErrorResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(BusinessException.class) + public ResponseEntity handleBusinessException(BusinessException ex) { + log.warn("Business exception occurred: {}", ex.getMessage()); + ErrorResponse errorResponse = ErrorResponse.builder() + .code(ex.getErrorCode()) + .message(ex.getMessage()) + .build(); + return ResponseEntity.badRequest().body(errorResponse); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleValidationException(MethodArgumentNotValidException ex) { + log.warn("Validation exception occurred: {}", ex.getMessage()); + String message = "유효하지 않은 입력값입니다."; + try { + if (ex.getBindingResult().hasErrors()) { + message = ex.getBindingResult().getAllErrors().get(0).getDefaultMessage(); + } + } catch (Exception e) { + log.debug("Error getting validation message: {}", e.getMessage()); + } + ErrorResponse errorResponse = ErrorResponse.builder() + .code("VALIDATION_ERROR") + .message(message) + .build(); + return ResponseEntity.badRequest().body(errorResponse); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleException(Exception ex) { + log.error("Unexpected exception occurred", ex); + ErrorResponse errorResponse = ErrorResponse.builder() + .code("INTERNAL_SERVER_ERROR") + .message("서버 내부 오류가 발생했습니다.") + .build(); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse); + } +} diff --git a/common/src/main/java/com/healthsync/common/exception/GlobalExceptionHandler.java.backup b/common/src/main/java/com/healthsync/common/exception/GlobalExceptionHandler.java.backup new file mode 100644 index 0000000..f539e17 --- /dev/null +++ b/common/src/main/java/com/healthsync/common/exception/GlobalExceptionHandler.java.backup @@ -0,0 +1,71 @@ +// common/src/main/java/com/healthsync/common/exception/GlobalExceptionHandler.java +package com.healthsync.common.exception; + +import com.healthsync.common.dto.ErrorResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.BindException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +/** + * 전역 예외 처리 핸들러입니다. + * + * @author healthsync-team + * @version 1.0 + */ +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + /** + * 비즈니스 예외 처리 + * + * @param ex BusinessException + * @return ErrorResponse + */ + @ExceptionHandler(BusinessException.class) + public ResponseEntity handleBusinessException(BusinessException ex) { + log.warn("Business exception occurred: {}", ex.getMessage()); + ErrorResponse errorResponse = ErrorResponse.builder() + .code(ex.getErrorCode()) + .message(ex.getMessage()) + .build(); + return ResponseEntity.badRequest().body(errorResponse); + } + + /** + * 유효성 검증 예외 처리 (RequestBody) + * + * @param ex MethodArgumentNotValidException + * @return ErrorResponse + */ + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleValidationException(MethodArgumentNotValidException ex) { + log.warn("Validation exception occurred: {}", ex.getMessage()); + String message = ex.getBindingResult().getAllErrors().get(0).getDefaultMessage(); + ErrorResponse errorResponse = ErrorResponse.builder() + .code("VALIDATION_ERROR") + .message(message) + .build(); + return ResponseEntity.badRequest().body(errorResponse); + } + + /** + * 일반적인 예외 처리 + * + * @param ex Exception + * @return ErrorResponse + */ + @ExceptionHandler(Exception.class) + public ResponseEntity handleException(Exception ex) { + log.error("Unexpected exception occurred", ex); + ErrorResponse errorResponse = ErrorResponse.builder() + .code("INTERNAL_SERVER_ERROR") + .message("서버 내부 오류가 발생했습니다.") + .build(); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse); + } +} \ No newline at end of file diff --git a/common/src/main/java/com/healthsync/common/exception/MissionNotFoundException.java b/common/src/main/java/com/healthsync/common/exception/MissionNotFoundException.java new file mode 100644 index 0000000..38f09d6 --- /dev/null +++ b/common/src/main/java/com/healthsync/common/exception/MissionNotFoundException.java @@ -0,0 +1,22 @@ +// common/src/main/java/com/healthsync/common/exception/MissionNotFoundException.java +package com.healthsync.common.exception; + +import com.healthsync.common.constants.ErrorCode; + +/** + * 미션을 찾을 수 없을 때 발생하는 예외입니다. + * + * @author healthsync-team + * @version 1.0 + */ +public class MissionNotFoundException extends BusinessException { + + /** + * MissionNotFoundException 생성자 + * + * @param missionId 미션 ID + */ + public MissionNotFoundException(String missionId) { + super(ErrorCode.MISSION_NOT_FOUND, "미션을 찾을 수 없습니다: " + missionId); + } +} \ No newline at end of file diff --git a/common/src/main/java/com/healthsync/common/exception/UserNotFoundException.java b/common/src/main/java/com/healthsync/common/exception/UserNotFoundException.java new file mode 100644 index 0000000..37c83cd --- /dev/null +++ b/common/src/main/java/com/healthsync/common/exception/UserNotFoundException.java @@ -0,0 +1,22 @@ +// common/src/main/java/com/healthsync/common/exception/UserNotFoundException.java +package com.healthsync.common.exception; + +import com.healthsync.common.constants.ErrorCode; + +/** + * 사용자를 찾을 수 없을 때 발생하는 예외입니다. + * + * @author healthsync-team + * @version 1.0 + */ +public class UserNotFoundException extends BusinessException { + + /** + * UserNotFoundException 생성자 + * + * @param userId 사용자 ID + */ + public UserNotFoundException(String userId) { + super(ErrorCode.USER_NOT_FOUND, "사용자를 찾을 수 없습니다: " + userId); + } +} \ No newline at end of file diff --git a/common/src/main/java/com/healthsync/common/exception/ValidationException.java b/common/src/main/java/com/healthsync/common/exception/ValidationException.java new file mode 100644 index 0000000..5ea282b --- /dev/null +++ b/common/src/main/java/com/healthsync/common/exception/ValidationException.java @@ -0,0 +1,14 @@ +package com.healthsync.common.exception; + +/** + * 유효성 검증 실패 시 발생하는 예외 클래스입니다. + * + * @author healthsync-team + * @version 1.0 + */ +public class ValidationException extends BusinessException { + + public ValidationException(String message) { + super("VALIDATION_ERROR", message); + } +} diff --git a/common/src/main/java/com/healthsync/common/util/DateUtil.java b/common/src/main/java/com/healthsync/common/util/DateUtil.java new file mode 100644 index 0000000..e78947b --- /dev/null +++ b/common/src/main/java/com/healthsync/common/util/DateUtil.java @@ -0,0 +1,89 @@ +// common/src/main/java/com/healthsync/common/util/DateUtil.java +package com.healthsync.common.util; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; + +/** + * 날짜 관련 유틸리티 클래스입니다. + * + * @author healthsync-team + * @version 1.0 + */ +public class DateUtil { + + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + private static final DateTimeFormatter DATETIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + /** + * 현재 날짜를 문자열로 반환합니다. + * + * @return 현재 날짜 (yyyy-MM-dd 형식) + */ + public static String getCurrentDate() { + return LocalDate.now().format(DATE_FORMATTER); + } + + /** + * 현재 날짜시간을 문자열로 반환합니다. + * + * @return 현재 날짜시간 (yyyy-MM-dd HH:mm:ss 형식) + */ + public static String getCurrentDateTime() { + return LocalDateTime.now().format(DATETIME_FORMATTER); + } + + /** + * 생년월일로부터 나이를 계산합니다. + * + * @param birthDate 생년월일 (yyyy-MM-dd 형식) + * @return 나이 + */ + public static int calculateAge(String birthDate) { + LocalDate birth = LocalDate.parse(birthDate, DATE_FORMATTER); + return (int) ChronoUnit.YEARS.between(birth, LocalDate.now()); + } + + /** + * LocalDate 생년월일로부터 나이를 계산합니다. + * + * @param birthDate 생년월일 + * @return 나이 + */ + public static int calculateAge(LocalDate birthDate) { + return (int) ChronoUnit.YEARS.between(birthDate, LocalDate.now()); + } + + /** + * 두 날짜 사이의 일수를 계산합니다. + * + * @param startDate 시작 날짜 + * @param endDate 종료 날짜 + * @return 일수 + */ + public static long getDaysBetween(LocalDate startDate, LocalDate endDate) { + return ChronoUnit.DAYS.between(startDate, endDate); + } + + /** + * 문자열을 LocalDate로 변환합니다. + * + * @param dateString 날짜 문자열 (yyyy-MM-dd 형식) + * @return LocalDate + */ + public static LocalDate parseDate(String dateString) { + return LocalDate.parse(dateString, DATE_FORMATTER); + } + + /** + * LocalDate를 문자열로 변환합니다. + * + * @param date LocalDate + * @return 날짜 문자열 (yyyy-MM-dd 형식) + */ + public static String formatDate(LocalDate date) { + return date.format(DATE_FORMATTER); + } +} \ No newline at end of file diff --git a/common/src/main/java/com/healthsync/common/util/JwtUtil.java b/common/src/main/java/com/healthsync/common/util/JwtUtil.java new file mode 100644 index 0000000..beb87d7 --- /dev/null +++ b/common/src/main/java/com/healthsync/common/util/JwtUtil.java @@ -0,0 +1,156 @@ +// common/src/main/java/com/healthsync/common/util/JwtUtil.java +package com.healthsync.common.util; + +import io.jsonwebtoken.*; +import io.jsonwebtoken.security.Keys; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Date; + +/** + * JWT 토큰 유틸리티 클래스입니다. + * + * @author healthsync-team + * @version 1.0 + */ +@Slf4j +@Component +public class JwtUtil { + + private final SecretKey secretKey; + private final long accessTokenValidityInMilliseconds; + private final long refreshTokenValidityInMilliseconds; + + /** + * JwtUtil 생성자 + * + * @param secret JWT 시크릿 키 + * @param accessTokenValidityInMilliseconds 액세스 토큰 유효 시간 + * @param refreshTokenValidityInMilliseconds 리프레시 토큰 유효 시간 + */ + public JwtUtil( + @Value("${jwt.secret:healthsync-default-secret-key-for-development-only}") String secret, + @Value("${jwt.access-token.expire-length:3600000}") long accessTokenValidityInMilliseconds, + @Value("${jwt.refresh-token.expire-length:604800000}") long refreshTokenValidityInMilliseconds) { + + this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); + this.accessTokenValidityInMilliseconds = accessTokenValidityInMilliseconds; + this.refreshTokenValidityInMilliseconds = refreshTokenValidityInMilliseconds; + } + + /** + * 액세스 토큰을 생성합니다. + * + * @param userId 사용자 ID + * @return 생성된 액세스 토큰 + */ + public String generateAccessToken(String userId) { + return createToken(userId, accessTokenValidityInMilliseconds); + } + + /** + * 리프레시 토큰을 생성합니다. + * + * @param userId 사용자 ID + * @return 생성된 리프레시 토큰 + */ + public String generateRefreshToken(String userId) { + return createToken(userId, refreshTokenValidityInMilliseconds); + } + + /** + * 액세스 토큰을 생성합니다. (별칭 메서드) + * + * @param userId 사용자 ID + * @return 생성된 액세스 토큰 + */ + public String createAccessToken(String userId) { + return generateAccessToken(userId); + } + + /** + * 리프레시 토큰을 생성합니다. (별칭 메서드) + * + * @param userId 사용자 ID + * @return 생성된 리프레시 토큰 + */ + public String createRefreshToken(String userId) { + return generateRefreshToken(userId); + } + + /** + * 토큰을 생성합니다. + * + * @param userId 사용자 ID + * @param validityInMilliseconds 유효 시간(밀리초) + * @return 생성된 토큰 + */ + private String createToken(String userId, long validityInMilliseconds) { + Date now = new Date(); + Date validity = new Date(now.getTime() + validityInMilliseconds); + + return Jwts.builder() + .setSubject(userId) + .setIssuedAt(now) + .setExpiration(validity) + .signWith(secretKey, SignatureAlgorithm.HS256) + .compact(); + } + + /** + * 토큰에서 사용자 ID를 추출합니다. + * + * @param token JWT 토큰 + * @return 사용자 ID + */ + public String getUserId(String token) { + return getClaims(token).getSubject(); + } + + /** + * 토큰의 유효성을 검증합니다. + * + * @param token JWT 토큰 + * @return 유효성 여부 + */ + public boolean validateToken(String token) { + try { + getClaims(token); + return true; + } catch (JwtException | IllegalArgumentException e) { + log.error("Invalid JWT token: {}", e.getMessage()); + return false; + } + } + + /** + * 토큰에서 Claims를 추출합니다. + * + * @param token JWT 토큰 + * @return Claims + */ + private Claims getClaims(String token) { + return Jwts.parserBuilder() + .setSigningKey(secretKey) + .build() + .parseClaimsJws(token) + .getBody(); + } + + /** + * 토큰의 만료 시간을 반환합니다. + * + * @param token JWT 토큰 + * @return 만료 시간 + */ + public LocalDateTime getExpirationDate(String token) { + Date expiration = getClaims(token).getExpiration(); + return expiration.toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime(); + } +} \ No newline at end of file diff --git a/deployment/container/Dockerfile b/deployment/container/Dockerfile new file mode 100644 index 0000000..d467db7 --- /dev/null +++ b/deployment/container/Dockerfile @@ -0,0 +1,24 @@ +# Build stage +FROM openjdk:21-jdk-slim AS builder +ARG BUILD_LIB_DIR +ARG ARTIFACTORY_FILE +COPY ${BUILD_LIB_DIR}/${ARTIFACTORY_FILE} app.jar + +# Run stage +FROM openjdk:21-jdk-slim +ENV USERNAME k8s +ENV ARTIFACTORY_HOME /home/${USERNAME} +ENV JAVA_OPTS="" + +# Add a non-root user +RUN adduser --system --group ${USERNAME} && \ + mkdir -p ${ARTIFACTORY_HOME} && \ + chown ${USERNAME}:${USERNAME} ${ARTIFACTORY_HOME} + +WORKDIR ${ARTIFACTORY_HOME} +COPY --from=builder app.jar app.jar +RUN chown ${USERNAME}:${USERNAME} app.jar + +USER ${USERNAME} +ENTRYPOINT [ "sh", "-c" ] +CMD ["java ${JAVA_OPTS} -jar app.jar"] diff --git a/deployment/container/Dockerfile.backup b/deployment/container/Dockerfile.backup new file mode 100644 index 0000000..89d98d3 --- /dev/null +++ b/deployment/container/Dockerfile.backup @@ -0,0 +1,120 @@ +# HealthSync Backend 통합 Dockerfile +# 전체 멀티프로젝트를 한 번에 빌드하고 특정 서비스를 선택 실행 + +# ============================================================================= +# Build Stage: 전체 멀티프로젝트 빌드 +# ============================================================================= +FROM openjdk:21-jdk-slim AS builder + +# 빌드에 필요한 패키지 설치 +RUN apt-get update && apt-get install -y \ + curl \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /workspace + +# Gradle Wrapper 및 설정 파일 복사 +COPY gradle/ gradle/ +COPY gradlew . +COPY gradle.properties . +COPY settings.gradle . +COPY build.gradle . + +# 각 서비스 소스코드 복사 +COPY common/ common/ +COPY api-gateway/ api-gateway/ +COPY user-service/ user-service/ +COPY health-service/ health-service/ +COPY intelligence-service/ intelligence-service/ +COPY goal-service/ goal-service/ +COPY motivator-service/ motivator-service/ + +# Gradle 실행 권한 부여 +RUN chmod +x gradlew + +# 전체 프로젝트 빌드 (테스트 제외) +RUN ./gradlew clean build -x test + +# 빌드된 JAR 파일들 확인 +RUN find . -name "*.jar" -type f + +# ============================================================================= +# Runtime Stage: 실행 환경 +# ============================================================================= +FROM openjdk:21-jdk-slim + +# 런타임 사용자 생성 +RUN addgroup --system --gid 1001 healthsync && \ + adduser --system --uid 1001 --gid 1001 healthsync + +# 작업 디렉토리 설정 +WORKDIR /app + +# 빌드된 JAR 파일들 복사 +COPY --from=builder /workspace/api-gateway/build/libs/*.jar ./jars/api-gateway.jar +COPY --from=builder /workspace/user-service/build/libs/*.jar ./jars/user-service.jar +COPY --from=builder /workspace/health-service/build/libs/*.jar ./jars/health-service.jar +COPY --from=builder /workspace/intelligence-service/build/libs/*.jar ./jars/intelligence-service.jar +COPY --from=builder /workspace/goal-service/build/libs/*.jar ./jars/goal-service.jar +COPY --from=builder /workspace/motivator-service/build/libs/*.jar ./jars/motivator-service.jar + +# 실행 스크립트 생성 +RUN cat > /app/start-service.sh << 'EOF' +#!/bin/bash + +SERVICE_NAME=${SERVICE_NAME:-user-service} +JAVA_OPTS=${JAVA_OPTS:-"-Xms256m -Xmx1024m"} + +echo "Starting HealthSync ${SERVICE_NAME}..." +echo "Java Options: ${JAVA_OPTS}" + +case ${SERVICE_NAME} in + "api-gateway") + exec java ${JAVA_OPTS} -jar /app/jars/api-gateway.jar + ;; + "user-service") + exec java ${JAVA_OPTS} -jar /app/jars/user-service.jar + ;; + "health-service") + exec java ${JAVA_OPTS} -jar /app/jars/health-service.jar + ;; + "intelligence-service") + exec java ${JAVA_OPTS} -jar /app/jars/intelligence-service.jar + ;; + "goal-service") + exec java ${JAVA_OPTS} -jar /app/jars/goal-service.jar + ;; + "motivator-service") + exec java ${JAVA_OPTS} -jar /app/jars/motivator-service.jar + ;; + *) + echo "Error: Unknown service name '${SERVICE_NAME}'" + echo "Available services: api-gateway, user-service, health-service, intelligence-service, goal-service, motivator-service" + exit 1 + ;; +esac +EOF + +# 스크립트 실행 권한 부여 +RUN chmod +x /app/start-service.sh + +# 디렉토리 소유자 변경 +RUN chown -R healthsync:healthsync /app + +# 사용자 변경 +USER healthsync + +# 헬스체크 스크립트 생성 +HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ + CMD curl -f http://localhost:${SERVER_PORT:-8080}/actuator/health || exit 1 + +# 기본 포트 노출 (환경변수로 오버라이드 가능) +EXPOSE 8080 8081 8082 8083 8084 8085 + +# 환경변수 기본값 설정 +ENV SERVICE_NAME=user-service +ENV JAVA_OPTS="-Xms256m -Xmx1024m" +ENV SPRING_PROFILES_ACTIVE=docker + +# 실행 명령 +ENTRYPOINT ["/app/start-service.sh"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..73a3cd0 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,150 @@ +services: + # Redis Cache + redis: + image: redis:7-alpine + container_name: healthsync-redis + ports: + - "6379:6379" + volumes: + - redis_data:/data + networks: + - healthsync-network + + # API Gateway + api-gateway: + build: + context: . + dockerfile: Dockerfile + args: + - SERVICE_NAME=api-gateway + container_name: healthsync-gateway + ports: + - "8080:8080" + environment: + - REDIS_HOST=redis + - USER_SERVICE_URL=http://user-service:8081 + - HEALTH_SERVICE_URL=http://health-service:8082 + - INTELLIGENCE_SERVICE_URL=http://intelligence-service:8083 + - GOAL_SERVICE_URL=http://goal-service:8084 + - MOTIVATOR_SERVICE_URL=http://motivator-service:8085 + depends_on: + - redis + networks: + - healthsync-network + + # User Service + user-service: + build: + context: . + dockerfile: Dockerfile + args: + - SERVICE_NAME=user-service + container_name: healthsync-user-service + ports: + - "8081:8081" + environment: + - DB_URL=jdbc:postgresql://psql-digitalgarage-01.postgres.database.azure.com:5432/healthsync_db + - DB_USERNAME=team1tier + - DB_PASSWORD=Hi5Jessica! + - REDIS_HOST=redis-digitalgarage-01.redis.cache.windows.net + depends_on: + - redis + networks: + - healthsync-network + + # Health Service + health-service: + build: + context: . + dockerfile: Dockerfile + args: + - SERVICE_NAME=health-service + container_name: healthsync-health-service + ports: + - "8082:8082" + environment: + - DB_URL=jdbc:postgresql://psql-digitalgarage-01.postgres.database.azure.com:5432/healthsync_db + - DB_USERNAME=team1tier + - DB_PASSWORD=Hi5Jessica! + - REDIS_HOST=redis + - USER_SERVICE_URL=http://user-service:8081 + depends_on: + - redis + networks: + - healthsync-network + + # Intelligence Service + intelligence-service: + build: + context: . + dockerfile: Dockerfile + args: + - SERVICE_NAME=intelligence-service + container_name: healthsync-intelligence-service + ports: + - "8083:8083" + environment: + - DB_URL=jdbc:postgresql://psql-digitalgarage-01.postgres.database.azure.com:5432/healthsync_db + - DB_USERNAME=team1tier + - DB_PASSWORD=Hi5Jessica! + - REDIS_HOST=redis + - USER_SERVICE_URL=http://user-service:8081 + - HEALTH_SERVICE_URL=http://health-service:8082 + - CLAUDE_API_KEY=${CLAUDE_API_KEY} + depends_on: + - redis + networks: + - healthsync-network + + # Goal Service + goal-service: + build: + context: . + dockerfile: Dockerfile + args: + - SERVICE_NAME=goal-service + container_name: healthsync-goal-service + ports: + - "8084:8084" + environment: + - DB_URL=jdbc:postgresql://psql-digitalgarage-01.postgres.database.azure.com:5432/healthsync_db + - DB_USERNAME=team1tier + - DB_PASSWORD=Hi5Jessica! + - REDIS_HOST=redis + - USER_SERVICE_URL=http://user-service:8081 + #- INTELLIGENCE_SERVICE_URL=http://intelligence-service:8083 + - INTELLIGENCE_SERVICE_URL=http://team1tier.20.214.196.128.nip.io + depends_on: + - redis + networks: + - healthsync-network + + # Motivator Service + motivator-service: + build: + context: . + dockerfile: Dockerfile + args: + - SERVICE_NAME=motivator-service + container_name: healthsync-motivator-service + ports: + - "8085:8085" + environment: + - DB_URL=jdbc:postgresql://psql-digitalgarage-01.postgres.database.azure.com:5432/healthsync_db + - DB_USERNAME=team1tier + - DB_PASSWORD=Hi5Jessica! + - REDIS_HOST=redis + - GOAL_SERVICE_URL=http://goal-service:8084 + - INTELLIGENCE_SERVICE_URL=http://intelligence-service:8083 + - CLAUDE_API_KEY=${CLAUDE_API_KEY} + depends_on: + - redis + networks: + - healthsync-network + +volumes: + redis_data: + +networks: + healthsync-network: + driver: bridge \ No newline at end of file diff --git a/goal-service/build.gradle b/goal-service/build.gradle new file mode 100644 index 0000000..3c91119 --- /dev/null +++ b/goal-service/build.gradle @@ -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() +} \ No newline at end of file diff --git a/goal-service/src/main/java/com/healthsync/goal/GoalServiceApplication.java b/goal-service/src/main/java/com/healthsync/goal/GoalServiceApplication.java new file mode 100644 index 0000000..4c01ea5 --- /dev/null +++ b/goal-service/src/main/java/com/healthsync/goal/GoalServiceApplication.java @@ -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(); + } +} \ No newline at end of file diff --git a/goal-service/src/main/java/com/healthsync/goal/application_services/GoalUseCase.java b/goal-service/src/main/java/com/healthsync/goal/application_services/GoalUseCase.java new file mode 100644 index 0000000..a067ba1 --- /dev/null +++ b/goal-service/src/main/java/com/healthsync/goal/application_services/GoalUseCase.java @@ -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 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 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 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 = goalRepository.findMissionHistoryByPeriod(memberSerialNumber, startDate, endDate, missionIds); + + // 실제 데이터를 기반으로 통계 계산 + AchievementStats achievementStats = calculateStatsFromMissionData(missionStats); + + // 차트 데이터 생성 + Map chartData = goalDomainService.generateChartData(missionStats); + + // 인사이트 생성 + List 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) { + 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 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) { + if (missionStats.isEmpty()) return 0.0; + + return missionStats.stream() + .mapToDouble(MissionStats::getAchievementRate) + .average() + .orElse(0.0); + } + + private double calculatePeriodAchievementRate(List missionStats) { + // 기간별 달성률 계산 로직 + return calculateTotalAchievementRate(missionStats); + } + + private Object generateChartData(List missionStats, String startDate, String endDate) { + // 차트 데이터 생성 로직 + return new Object(); // placeholder + } + + private List generateInsights(List missionStats, double achievementRate) { + List insights = new java.util.ArrayList<>(); + + if (achievementRate >= 80) { + insights.add("훌륭한 성과를 보이고 있습니다!"); + } else if (achievementRate >= 60) { + insights.add("꾸준히 목표를 향해 나아가고 있어요."); + } else { + insights.add("조금 더 꾸준함이 필요해 보여요."); + } + + return insights; + } + + +} \ No newline at end of file diff --git a/goal-service/src/main/java/com/healthsync/goal/config/GoalSecurityConfig.java b/goal-service/src/main/java/com/healthsync/goal/config/GoalSecurityConfig.java new file mode 100644 index 0000000..e04ef53 --- /dev/null +++ b/goal-service/src/main/java/com/healthsync/goal/config/GoalSecurityConfig.java @@ -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; + } +} \ No newline at end of file diff --git a/goal-service/src/main/java/com/healthsync/goal/config/SwaggerAccessController.java b/goal-service/src/main/java/com/healthsync/goal/config/SwaggerAccessController.java new file mode 100644 index 0000000..db5dce1 --- /dev/null +++ b/goal-service/src/main/java/com/healthsync/goal/config/SwaggerAccessController.java @@ -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; + } +} \ No newline at end of file diff --git a/goal-service/src/main/java/com/healthsync/goal/config/WebClientConfig.java b/goal-service/src/main/java/com/healthsync/goal/config/WebClientConfig.java new file mode 100644 index 0000000..68d9e44 --- /dev/null +++ b/goal-service/src/main/java/com/healthsync/goal/config/WebClientConfig.java @@ -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(); + } +} \ No newline at end of file diff --git a/goal-service/src/main/java/com/healthsync/goal/domain/repositories/GoalRepository.java b/goal-service/src/main/java/com/healthsync/goal/domain/repositories/GoalRepository.java new file mode 100644 index 0000000..19aa65a --- /dev/null +++ b/goal-service/src/main/java/com/healthsync/goal/domain/repositories/GoalRepository.java @@ -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 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 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); + +} \ No newline at end of file diff --git a/goal-service/src/main/java/com/healthsync/goal/domain/services/GoalDomainService.java b/goal-service/src/main/java/com/healthsync/goal/domain/services/GoalDomainService.java new file mode 100644 index 0000000..f46076e --- /dev/null +++ b/goal-service/src/main/java/com/healthsync/goal/domain/services/GoalDomainService.java @@ -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 generateChartData(List missionStats) { // Object -> Map로 변경 + Map chartData = new HashMap<>(); + + // 미션별 달성률 데이터 + List> achievementData = missionStats.stream() + .map(stat -> { + Map 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를 반환하고 있었음 + } + + /** + * 진행 패턴을 분석하여 인사이트를 생성합니다. + * + * @param missionStats 미션 통계 목록 + * @return 인사이트 목록 + */ + public List analyzeProgressPatterns(List missionStats) { + // 실제 구현에서는 통계 데이터를 분석하여 패턴 발견 + return List.of( + "💪 운동 미션의 달성률이 생활습관 미션보다 15% 높습니다.", + "📈 최근 1주일간 미션 달성률이 20% 향상되었습니다.", + "🎯 '어깨 스트레칭' 미션이 가장 높은 달성률(95%)을 보입니다.", + "⏰ 오전에 시작하는 미션들의 달성률이 더 높은 경향을 보입니다." + ); + } +} diff --git a/goal-service/src/main/java/com/healthsync/goal/dto/AchievementStats.java b/goal-service/src/main/java/com/healthsync/goal/dto/AchievementStats.java new file mode 100644 index 0000000..01a6b4e --- /dev/null +++ b/goal-service/src/main/java/com/healthsync/goal/dto/AchievementStats.java @@ -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; +} diff --git a/goal-service/src/main/java/com/healthsync/goal/dto/ActiveMissionsResponse.java b/goal-service/src/main/java/com/healthsync/goal/dto/ActiveMissionsResponse.java new file mode 100644 index 0000000..6ed7e74 --- /dev/null +++ b/goal-service/src/main/java/com/healthsync/goal/dto/ActiveMissionsResponse.java @@ -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 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; +} \ No newline at end of file diff --git a/goal-service/src/main/java/com/healthsync/goal/dto/CelebrationRequest.java b/goal-service/src/main/java/com/healthsync/goal/dto/CelebrationRequest.java new file mode 100644 index 0000000..0abc969 --- /dev/null +++ b/goal-service/src/main/java/com/healthsync/goal/dto/CelebrationRequest.java @@ -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 값 지원) +} \ No newline at end of file diff --git a/goal-service/src/main/java/com/healthsync/goal/dto/CelebrationResponse.java b/goal-service/src/main/java/com/healthsync/goal/dto/CelebrationResponse.java new file mode 100644 index 0000000..cc0c290 --- /dev/null +++ b/goal-service/src/main/java/com/healthsync/goal/dto/CelebrationResponse.java @@ -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; +} \ No newline at end of file diff --git a/goal-service/src/main/java/com/healthsync/goal/dto/CompletionData.java b/goal-service/src/main/java/com/healthsync/goal/dto/CompletionData.java new file mode 100644 index 0000000..cf9d6a6 --- /dev/null +++ b/goal-service/src/main/java/com/healthsync/goal/dto/CompletionData.java @@ -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; +} diff --git a/goal-service/src/main/java/com/healthsync/goal/dto/DailyMission.java b/goal-service/src/main/java/com/healthsync/goal/dto/DailyMission.java new file mode 100644 index 0000000..c536d13 --- /dev/null +++ b/goal-service/src/main/java/com/healthsync/goal/dto/DailyMission.java @@ -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; + } +} \ No newline at end of file diff --git a/goal-service/src/main/java/com/healthsync/goal/dto/GoalSetupResponse.java b/goal-service/src/main/java/com/healthsync/goal/dto/GoalSetupResponse.java new file mode 100644 index 0000000..e099a7d --- /dev/null +++ b/goal-service/src/main/java/com/healthsync/goal/dto/GoalSetupResponse.java @@ -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 selectedMissions; + + @Schema(description = "응답 메시지") + private String message; + + @Schema(description = "설정 완료 시간") + private String setupCompletedAt; +} diff --git a/goal-service/src/main/java/com/healthsync/goal/dto/Mission.java b/goal-service/src/main/java/com/healthsync/goal/dto/Mission.java new file mode 100644 index 0000000..7e2a682 --- /dev/null +++ b/goal-service/src/main/java/com/healthsync/goal/dto/Mission.java @@ -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; +} diff --git a/goal-service/src/main/java/com/healthsync/goal/dto/MissionCompleteRequest.java b/goal-service/src/main/java/com/healthsync/goal/dto/MissionCompleteRequest.java new file mode 100644 index 0000000..3745e9c --- /dev/null +++ b/goal-service/src/main/java/com/healthsync/goal/dto/MissionCompleteRequest.java @@ -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); + } +} \ No newline at end of file diff --git a/goal-service/src/main/java/com/healthsync/goal/dto/MissionCompleteResponse.java b/goal-service/src/main/java/com/healthsync/goal/dto/MissionCompleteResponse.java new file mode 100644 index 0000000..3cbf074 --- /dev/null +++ b/goal-service/src/main/java/com/healthsync/goal/dto/MissionCompleteResponse.java @@ -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; + +} diff --git a/goal-service/src/main/java/com/healthsync/goal/dto/MissionDetailResponse.java b/goal-service/src/main/java/com/healthsync/goal/dto/MissionDetailResponse.java new file mode 100644 index 0000000..df1a98a --- /dev/null +++ b/goal-service/src/main/java/com/healthsync/goal/dto/MissionDetailResponse.java @@ -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; +} \ No newline at end of file diff --git a/goal-service/src/main/java/com/healthsync/goal/dto/MissionDetailsRequest.java b/goal-service/src/main/java/com/healthsync/goal/dto/MissionDetailsRequest.java new file mode 100644 index 0000000..a7e5a4c --- /dev/null +++ b/goal-service/src/main/java/com/healthsync/goal/dto/MissionDetailsRequest.java @@ -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 missionIds; +} \ No newline at end of file diff --git a/goal-service/src/main/java/com/healthsync/goal/dto/MissionHistoryResponse.java b/goal-service/src/main/java/com/healthsync/goal/dto/MissionHistoryResponse.java new file mode 100644 index 0000000..85916e1 --- /dev/null +++ b/goal-service/src/main/java/com/healthsync/goal/dto/MissionHistoryResponse.java @@ -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; + + // @Schema(description = "차트 데이터") + // private Object chartData; + + @Schema(description = "차트 데이터") + private Map chartData; + + + @Schema(description = "조회 기간") + private Period period; + + @Schema(description = "인사이트") + private List insights; +} diff --git a/goal-service/src/main/java/com/healthsync/goal/dto/MissionRecommendationResponse.java b/goal-service/src/main/java/com/healthsync/goal/dto/MissionRecommendationResponse.java new file mode 100644 index 0000000..7e36bf6 --- /dev/null +++ b/goal-service/src/main/java/com/healthsync/goal/dto/MissionRecommendationResponse.java @@ -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 missions; + + @Schema(description = "추천 이유") + private String recommendationReason; + + @Schema(description = "총 추천 미션 수") + private int totalRecommended; +} \ No newline at end of file diff --git a/goal-service/src/main/java/com/healthsync/goal/dto/MissionResetRequest.java b/goal-service/src/main/java/com/healthsync/goal/dto/MissionResetRequest.java new file mode 100644 index 0000000..e15374c --- /dev/null +++ b/goal-service/src/main/java/com/healthsync/goal/dto/MissionResetRequest.java @@ -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 currentMissionIds; +} \ No newline at end of file diff --git a/goal-service/src/main/java/com/healthsync/goal/dto/MissionResetResponse.java b/goal-service/src/main/java/com/healthsync/goal/dto/MissionResetResponse.java new file mode 100644 index 0000000..98cd3d4 --- /dev/null +++ b/goal-service/src/main/java/com/healthsync/goal/dto/MissionResetResponse.java @@ -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 newRecommendations; + + @Schema(description = "재설정 완료 시간") + private String resetCompletedAt; +} diff --git a/goal-service/src/main/java/com/healthsync/goal/dto/MissionSelectionRequest.java b/goal-service/src/main/java/com/healthsync/goal/dto/MissionSelectionRequest.java new file mode 100644 index 0000000..45aa0ac --- /dev/null +++ b/goal-service/src/main/java/com/healthsync/goal/dto/MissionSelectionRequest.java @@ -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 selectedMissionIds; +} \ No newline at end of file diff --git a/goal-service/src/main/java/com/healthsync/goal/dto/MissionStats.java b/goal-service/src/main/java/com/healthsync/goal/dto/MissionStats.java new file mode 100644 index 0000000..166645a --- /dev/null +++ b/goal-service/src/main/java/com/healthsync/goal/dto/MissionStats.java @@ -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; +} \ No newline at end of file diff --git a/goal-service/src/main/java/com/healthsync/goal/dto/Period.java b/goal-service/src/main/java/com/healthsync/goal/dto/Period.java new file mode 100644 index 0000000..a24dc55 --- /dev/null +++ b/goal-service/src/main/java/com/healthsync/goal/dto/Period.java @@ -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; +} diff --git a/goal-service/src/main/java/com/healthsync/goal/dto/RecommendedMission.java b/goal-service/src/main/java/com/healthsync/goal/dto/RecommendedMission.java new file mode 100644 index 0000000..7ceb7fb --- /dev/null +++ b/goal-service/src/main/java/com/healthsync/goal/dto/RecommendedMission.java @@ -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; +} diff --git a/goal-service/src/main/java/com/healthsync/goal/dto/SelectedMission.java b/goal-service/src/main/java/com/healthsync/goal/dto/SelectedMission.java new file mode 100644 index 0000000..3cc8e18 --- /dev/null +++ b/goal-service/src/main/java/com/healthsync/goal/dto/SelectedMission.java @@ -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; +} diff --git a/goal-service/src/main/java/com/healthsync/goal/dto/SelectedMissionDetail.java b/goal-service/src/main/java/com/healthsync/goal/dto/SelectedMissionDetail.java new file mode 100644 index 0000000..f15286c --- /dev/null +++ b/goal-service/src/main/java/com/healthsync/goal/dto/SelectedMissionDetail.java @@ -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("^_|_$", ""); + } +} \ No newline at end of file diff --git a/goal-service/src/main/java/com/healthsync/goal/dto/UserProfile.java b/goal-service/src/main/java/com/healthsync/goal/dto/UserProfile.java new file mode 100644 index 0000000..16af49f --- /dev/null +++ b/goal-service/src/main/java/com/healthsync/goal/dto/UserProfile.java @@ -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; +} \ No newline at end of file diff --git a/goal-service/src/main/java/com/healthsync/goal/infrastructure/adapters/CacheAdapter.java b/goal-service/src/main/java/com/healthsync/goal/infrastructure/adapters/CacheAdapter.java new file mode 100644 index 0000000..3f585c4 --- /dev/null +++ b/goal-service/src/main/java/com/healthsync/goal/infrastructure/adapters/CacheAdapter.java @@ -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 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()); + } + } +} diff --git a/goal-service/src/main/java/com/healthsync/goal/infrastructure/adapters/EventPublisherAdapter.java b/goal-service/src/main/java/com/healthsync/goal/infrastructure/adapters/EventPublisherAdapter.java new file mode 100644 index 0000000..61142e3 --- /dev/null +++ b/goal-service/src/main/java/com/healthsync/goal/infrastructure/adapters/EventPublisherAdapter.java @@ -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 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); + } + } +} \ No newline at end of file diff --git a/goal-service/src/main/java/com/healthsync/goal/infrastructure/adapters/IntelligenceServiceAdapter.java b/goal-service/src/main/java/com/healthsync/goal/infrastructure/adapters/IntelligenceServiceAdapter.java new file mode 100644 index 0000000..d01a09b --- /dev/null +++ b/goal-service/src/main/java/com/healthsync/goal/infrastructure/adapters/IntelligenceServiceAdapter.java @@ -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 requestEntity = new HttpEntity<>(request, headers); + + ResponseEntity 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 getNewMissionRecommendations(String memberSerialNumber, String resetReason) { + // 기존 메서드 구현 유지 + return List.of(); + } +} \ No newline at end of file diff --git a/goal-service/src/main/java/com/healthsync/goal/infrastructure/adapters/UserServiceAdapter.java b/goal-service/src/main/java/com/healthsync/goal/infrastructure/adapters/UserServiceAdapter.java new file mode 100644 index 0000000..fc59ee1 --- /dev/null +++ b/goal-service/src/main/java/com/healthsync/goal/infrastructure/adapters/UserServiceAdapter.java @@ -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"; + } +} \ No newline at end of file diff --git a/goal-service/src/main/java/com/healthsync/goal/infrastructure/entities/MissionCompletionHistoryEntity.java b/goal-service/src/main/java/com/healthsync/goal/infrastructure/entities/MissionCompletionHistoryEntity.java new file mode 100644 index 0000000..0e6acdb --- /dev/null +++ b/goal-service/src/main/java/com/healthsync/goal/infrastructure/entities/MissionCompletionHistoryEntity.java @@ -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(); + } + } +} \ No newline at end of file diff --git a/goal-service/src/main/java/com/healthsync/goal/infrastructure/entities/UserMissionGoalEntity.java b/goal-service/src/main/java/com/healthsync/goal/infrastructure/entities/UserMissionGoalEntity.java new file mode 100644 index 0000000..22567da --- /dev/null +++ b/goal-service/src/main/java/com/healthsync/goal/infrastructure/entities/UserMissionGoalEntity.java @@ -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; + } + } +} \ No newline at end of file diff --git a/goal-service/src/main/java/com/healthsync/goal/infrastructure/ports/CachePort.java b/goal-service/src/main/java/com/healthsync/goal/infrastructure/ports/CachePort.java new file mode 100644 index 0000000..911eec3 --- /dev/null +++ b/goal-service/src/main/java/com/healthsync/goal/infrastructure/ports/CachePort.java @@ -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); +} \ No newline at end of file diff --git a/goal-service/src/main/java/com/healthsync/goal/infrastructure/ports/EventPublisherPort.java b/goal-service/src/main/java/com/healthsync/goal/infrastructure/ports/EventPublisherPort.java new file mode 100644 index 0000000..9faf2ba --- /dev/null +++ b/goal-service/src/main/java/com/healthsync/goal/infrastructure/ports/EventPublisherPort.java @@ -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 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); +} \ No newline at end of file diff --git a/goal-service/src/main/java/com/healthsync/goal/infrastructure/ports/IntelligenceServicePort.java b/goal-service/src/main/java/com/healthsync/goal/infrastructure/ports/IntelligenceServicePort.java new file mode 100644 index 0000000..046fae0 --- /dev/null +++ b/goal-service/src/main/java/com/healthsync/goal/infrastructure/ports/IntelligenceServicePort.java @@ -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 getNewMissionRecommendations(String memberSerialNumber, String resetReason); +} \ No newline at end of file diff --git a/goal-service/src/main/java/com/healthsync/goal/infrastructure/ports/UserServicePort.java b/goal-service/src/main/java/com/healthsync/goal/infrastructure/ports/UserServicePort.java new file mode 100644 index 0000000..7db8296 --- /dev/null +++ b/goal-service/src/main/java/com/healthsync/goal/infrastructure/ports/UserServicePort.java @@ -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); +} \ No newline at end of file diff --git a/goal-service/src/main/java/com/healthsync/goal/infrastructure/repositories/GoalRepositoryImpl.java b/goal-service/src/main/java/com/healthsync/goal/infrastructure/repositories/GoalRepositoryImpl.java new file mode 100644 index 0000000..3874f6e --- /dev/null +++ b/goal-service/src/main/java/com/healthsync/goal/infrastructure/repositories/GoalRepositoryImpl.java @@ -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 missionIdList = request.getSelectedMissionIds().stream() + .map(this::extractMissionIdFromDetail) + .collect(Collectors.toList()); + + // ✅ Mock Intelligence Service에서 선택된 미션들의 상세 정보 조회 (필요시) + // List missionDetails = intelligenceServicePort + // .getMissionDetails(request.getMemberSerialNumber(), missionIdList); + + // ✅ 선택된 미션들을 DDL 구조에 맞춰 저장 + List 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 findActiveMissionsByUserId(String memberSerialNumber) { + log.info("🔍 [ACTIVE_MISSIONS] 활성 미션 조회 시작: memberSerialNumber={}", memberSerialNumber); + + try { + Long memberSerialNumberLong = UserIdValidator.parseMemberSerialNumber(memberSerialNumber, "findActiveMissionsByUserId"); + + // ✅ 실제 메서드명 사용 + List activeMissions = userMissionGoalJpaRepository + .findActiveByMemberSerialNumber(memberSerialNumberLong); + + List 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 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 missionOpt = userMissionGoalJpaRepository + .findByMissionIdAndMemberSerialNumber(missionIdLong, memberSerialNumber); + + if (missionOpt.isEmpty()) { + throw new IllegalArgumentException("미션을 찾을 수 없습니다: " + missionId); + } + + UserMissionGoalEntity mission = missionOpt.get(); + LocalDate today = LocalDate.now(); + + // ✅ 2. 오늘 완료 기록이 있는지 확인 + Optional 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 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 completionHistory = + missionCompletionJpaRepository.findByMemberSerialNumberAndDateRange( + memberSerialNumberLong, + LocalDate.parse(startDate), + LocalDate.parse(endDate) + ); + + // MissionStats로 변환 - 실제 미션 이름 조회 포함 + List missionStats = completionHistory.stream() + .collect(Collectors.groupingBy(MissionCompletionHistoryEntity::getMissionId)) + .entrySet().stream() + .map(entry -> { + Long missionId = entry.getKey(); + List 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 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 todayCompletions = missionCompletionJpaRepository + .findByMemberSerialNumberAndCompletionDate(entity.getMemberSerialNumber(), today); + + // 이 미션의 오늘 완료 기록만 필터링 + List 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 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 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 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 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; + } + } + + +} \ No newline at end of file diff --git a/goal-service/src/main/java/com/healthsync/goal/infrastructure/repositories/MissionCompletionJpaRepository.java b/goal-service/src/main/java/com/healthsync/goal/infrastructure/repositories/MissionCompletionJpaRepository.java new file mode 100644 index 0000000..a9e7360 --- /dev/null +++ b/goal-service/src/main/java/com/healthsync/goal/infrastructure/repositories/MissionCompletionJpaRepository.java @@ -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 { + + /** + * 회원 시리얼번호와 미션 ID로 완료 이력 조회 (DDL 맞춤) + */ + @Query("SELECT mch FROM MissionCompletionHistoryEntity mch WHERE mch.memberSerialNumber = :memberSerialNumber AND mch.missionId = :missionId ORDER BY mch.completionDate DESC") + List 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 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 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 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 findByMemberSerialNumberAndDateRange( + @Param("memberSerialNumber") Long memberSerialNumber, + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate + ); + + + /** + * 특정 회원의 특정 미션에 대한 특정 날짜의 완료 이력을 조회합니다. + * + * @param memberSerialNumber 회원 시리얼 번호 + * @param missionId 미션 ID + * @param completionDate 완료 날짜 + * @return 완료 이력 목록 + */ + List findByMemberSerialNumberAndMissionIdAndCompletionDate( + Long memberSerialNumber, Long missionId, LocalDate completionDate); + + +} \ No newline at end of file diff --git a/goal-service/src/main/java/com/healthsync/goal/infrastructure/repositories/UserMissionGoalJpaRepository.java b/goal-service/src/main/java/com/healthsync/goal/infrastructure/repositories/UserMissionGoalJpaRepository.java new file mode 100644 index 0000000..aaba384 --- /dev/null +++ b/goal-service/src/main/java/com/healthsync/goal/infrastructure/repositories/UserMissionGoalJpaRepository.java @@ -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 { + + /** + * 회원 시리얼 번호로 활성 미션 목표 조회 + */ + @Query("SELECT umg FROM UserMissionGoalEntity umg WHERE umg.memberSerialNumber = :memberSerialNumber AND umg.isActive = true") + List findActiveByMemberSerialNumber(@Param("memberSerialNumber") Long memberSerialNumber); + + /** + * 특정 날짜의 미션 목표 조회 + */ + @Query("SELECT umg FROM UserMissionGoalEntity umg WHERE umg.memberSerialNumber = :memberSerialNumber AND umg.performanceDate = :performanceDate AND umg.isActive = true") + List findByMemberSerialNumberAndPerformanceDate(@Param("memberSerialNumber") Long memberSerialNumber, @Param("performanceDate") LocalDate performanceDate); + + /** + * 미션 ID로 조회 (명시적 쿼리 추가) + */ + @Query("SELECT umg FROM UserMissionGoalEntity umg WHERE umg.missionId = :missionId") + Optional findByMissionId(@Param("missionId") Long missionId); + /** + * 미션 ID와 회원 시리얼 번호로 미션을 조회합니다. + */ + @Query("SELECT umg FROM UserMissionGoalEntity umg WHERE umg.missionId = :missionId AND umg.memberSerialNumber = :memberSerialNumber") + Optional findByMissionIdAndMemberSerialNumber(@Param("missionId") Long missionId, @Param("memberSerialNumber") Long memberSerialNumber); + +} \ No newline at end of file diff --git a/goal-service/src/main/java/com/healthsync/goal/infrastructure/services/IdGeneratorService.java b/goal-service/src/main/java/com/healthsync/goal/infrastructure/services/IdGeneratorService.java new file mode 100644 index 0000000..e24bb1c --- /dev/null +++ b/goal-service/src/main/java/com/healthsync/goal/infrastructure/services/IdGeneratorService.java @@ -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; + } +} \ No newline at end of file diff --git a/goal-service/src/main/java/com/healthsync/goal/infrastructure/utils/UserIdValidator.java b/goal-service/src/main/java/com/healthsync/goal/infrastructure/utils/UserIdValidator.java new file mode 100644 index 0000000..64b4077 --- /dev/null +++ b/goal-service/src/main/java/com/healthsync/goal/infrastructure/utils/UserIdValidator.java @@ -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); + } +} \ No newline at end of file diff --git a/goal-service/src/main/java/com/healthsync/goal/interface_adapters/controllers/GoalController.java b/goal-service/src/main/java/com/healthsync/goal/interface_adapters/controllers/GoalController.java new file mode 100644 index 0000000..2f2ce97 --- /dev/null +++ b/goal-service/src/main/java/com/healthsync/goal/interface_adapters/controllers/GoalController.java @@ -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> 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> 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> 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> 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> 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)); + } +} \ No newline at end of file diff --git a/goal-service/src/main/resources/application.yml b/goal-service/src/main/resources/application.yml new file mode 100644 index 0000000..a08da33 --- /dev/null +++ b/goal-service/src/main/resources/application.yml @@ -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 \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..9bbc975c742b298b441bfb90dbc124400a3751b9 GIT binary patch literal 43705 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 DHx0+F literal 0 HcmV?d00001 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e332c06 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,8 @@ +# gradle/wrapper/gradle-wrapper.properties +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists \ No newline at end of file diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..faf9300 --- /dev/null +++ b/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..9d21a21 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/health-service/build.gradle b/health-service/build.gradle new file mode 100644 index 0000000..996cbeb --- /dev/null +++ b/health-service/build.gradle @@ -0,0 +1,21 @@ +dependencies { + implementation project(':common') + 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-security' + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' + implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0' + + runtimeOnly 'org.postgresql:postgresql' + + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.security:spring-security-test' +} + +allprojects { + group = 'com.healthsync' + version = '1.0.0' +} diff --git a/health-service/src/main/java/com/healthsync/HealthServiceApplication.java b/health-service/src/main/java/com/healthsync/HealthServiceApplication.java new file mode 100644 index 0000000..b807492 --- /dev/null +++ b/health-service/src/main/java/com/healthsync/HealthServiceApplication.java @@ -0,0 +1,11 @@ +package com.healthsync; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class HealthServiceApplication { + public static void main(String[] args) { + SpringApplication.run(HealthServiceApplication.class, args); + } +} diff --git a/health-service/src/main/java/com/healthsync/common/dto/CusApiResponse.java b/health-service/src/main/java/com/healthsync/common/dto/CusApiResponse.java new file mode 100644 index 0000000..d3aeffe --- /dev/null +++ b/health-service/src/main/java/com/healthsync/common/dto/CusApiResponse.java @@ -0,0 +1,42 @@ +package com.healthsync.common.dto; + +public class CusApiResponse { + private boolean success; + private String message; + private T data; + private String error; + + public CusApiResponse() {} + + public CusApiResponse(boolean success, String message, T data) { + this.success = success; + this.message = message; + this.data = data; + } + + public CusApiResponse(boolean success, String message, String error) { + this.success = success; + this.message = message; + this.error = error; + } + + public static CusApiResponse success(T data, String message) { + return new CusApiResponse<>(true, message, data); + } + + public static CusApiResponse error(String message, String error) { + return new CusApiResponse<>(false, message, error); + } + + public boolean isSuccess() { return success; } + public void setSuccess(boolean success) { this.success = success; } + + public String getMessage() { return message; } + public void setMessage(String message) { this.message = message; } + + public T getData() { return data; } + public void setData(T data) { this.data = data; } + + public String getError() { return error; } + public void setError(String error) { this.error = error; } +} diff --git a/health-service/src/main/java/com/healthsync/common/exception/CustomException.java b/health-service/src/main/java/com/healthsync/common/exception/CustomException.java new file mode 100644 index 0000000..a4d969d --- /dev/null +++ b/health-service/src/main/java/com/healthsync/common/exception/CustomException.java @@ -0,0 +1,11 @@ +package com.healthsync.common.exception; + +public class CustomException extends RuntimeException { + public CustomException(String message) { + super(message); + } + + public CustomException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/health-service/src/main/java/com/healthsync/common/exception/GlobalExceptionHandler.java b/health-service/src/main/java/com/healthsync/common/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..28a9ff0 --- /dev/null +++ b/health-service/src/main/java/com/healthsync/common/exception/GlobalExceptionHandler.java @@ -0,0 +1,42 @@ +package com.healthsync.common.exception; + +import com.healthsync.common.dto.CusApiResponse; +import com.healthsync.common.response.ResponseHelper; +import com.healthsync.health.exception.AuthenticationException; +import com.healthsync.health.exception.UserNotFoundException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class); + + @ExceptionHandler(UserNotFoundException.class) + public ResponseEntity> handleUserNotFoundException(UserNotFoundException e) { + logger.error("User not found: {}", e.getMessage()); + return ResponseHelper.notFound("사용자를 찾을 수 없습니다", e.getMessage()); + } + + @ExceptionHandler(AuthenticationException.class) + public ResponseEntity> handleAuthenticationException(AuthenticationException e) { + logger.error("Authentication error: {}", e.getMessage()); + return ResponseHelper.unauthorized("인증에 실패했습니다", e.getMessage()); + } + + @ExceptionHandler(AccessDeniedException.class) + public ResponseEntity> handleAccessDeniedException(AccessDeniedException e) { + logger.error("Access denied: {}", e.getMessage()); + return ResponseHelper.forbidden("접근이 거부되었습니다", e.getMessage()); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity> handleGenericException(Exception e) { + logger.error("Unexpected error: {}", e.getMessage(), e); + return ResponseHelper.internalServerError("서버 오류가 발생했습니다", e.getMessage()); + } +} diff --git a/health-service/src/main/java/com/healthsync/common/response/ResponseHelper.java b/health-service/src/main/java/com/healthsync/common/response/ResponseHelper.java new file mode 100644 index 0000000..15834c3 --- /dev/null +++ b/health-service/src/main/java/com/healthsync/common/response/ResponseHelper.java @@ -0,0 +1,36 @@ +package com.healthsync.common.response; + +import com.healthsync.common.dto.CusApiResponse; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +public class ResponseHelper { + + public static ResponseEntity> success(T data, String message) { + return ResponseEntity.ok(CusApiResponse.success(data, message)); + } + + public static ResponseEntity> created(T data, String message) { + return ResponseEntity.status(HttpStatus.CREATED).body(CusApiResponse.success(data, message)); + } + + public static ResponseEntity> badRequest(String message, String error) { + return ResponseEntity.badRequest().body(CusApiResponse.error(message, error)); + } + + public static ResponseEntity> unauthorized(String message, String error) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(CusApiResponse.error(message, error)); + } + + public static ResponseEntity> forbidden(String message, String error) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CusApiResponse.error(message, error)); + } + + public static ResponseEntity> notFound(String message, String error) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(CusApiResponse.error(message, error)); + } + + public static ResponseEntity> internalServerError(String message, String error) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(CusApiResponse.error(message, error)); + } +} diff --git a/health-service/src/main/java/com/healthsync/common/util/JwtUtil.java b/health-service/src/main/java/com/healthsync/common/util/JwtUtil.java new file mode 100644 index 0000000..2dc330c --- /dev/null +++ b/health-service/src/main/java/com/healthsync/common/util/JwtUtil.java @@ -0,0 +1,85 @@ +package com.healthsync.common.util; + +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; + +@Component +public class JwtUtil { + + private final JwtDecoder jwtDecoder; + + public JwtUtil(JwtDecoder jwtDecoder) { + this.jwtDecoder = jwtDecoder; + } + + public Jwt parseToken(String token) { + return jwtDecoder.decode(token); + } + + public String getUserIdFromToken(String token) { + Jwt jwt = parseToken(token); + return jwt.getSubject(); + } + + public String getEmailFromToken(String token) { + Jwt jwt = parseToken(token); + return jwt.getClaimAsString("email"); + } + + public String getNameFromToken(String token) { + Jwt jwt = parseToken(token); + return jwt.getClaimAsString("name"); + } + + public String getRoleFromToken(String token) { + Jwt jwt = parseToken(token); + return jwt.getClaimAsString("role"); + } + + /** + * JWT 토큰에서 생년월일 추출 + */ + public LocalDate getBirthDateFromToken(String token) { + Jwt jwt = parseToken(token); + String birthDateStr = jwt.getClaimAsString("birthDate"); + if (birthDateStr != null) { + return LocalDate.parse(birthDateStr); + } + return null; + } + + /** + * JWT 토큰에서 생년월일 추출 (Jwt 객체 사용) + */ + public LocalDate getBirthDateFromJwt(Jwt jwt) { + String birthDateStr = jwt.getClaimAsString("birthDate"); + if (birthDateStr != null) { + return LocalDate.parse(birthDateStr); + } + return null; + } + + /** + * JWT 토큰에서 이름 추출 (Jwt 객체 사용) + */ + public String getNameFromJwt(Jwt jwt) { + return jwt.getClaimAsString("name"); + } + + /** + * JWT 토큰에서 Google ID 추출 (Jwt 객체 사용) + */ + public String getGoogleIdFromJwt(Jwt jwt) { + return jwt.getClaimAsString("googleId"); + } + + /** + * JWT 토큰에서 사용자 ID 추출 (Jwt 객체 사용) + */ + public Long getMemberSerialNumberFromJwt(Jwt jwt) { + return Long.valueOf(jwt.getSubject()); + } +} \ No newline at end of file diff --git a/health-service/src/main/java/com/healthsync/health/config/HealthJwtConfig.java b/health-service/src/main/java/com/healthsync/health/config/HealthJwtConfig.java new file mode 100644 index 0000000..20dba72 --- /dev/null +++ b/health-service/src/main/java/com/healthsync/health/config/HealthJwtConfig.java @@ -0,0 +1,91 @@ +package com.healthsync.health.config; + +import com.nimbusds.jose.jwk.JWK; +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jose.jwk.source.ImmutableJWKSet; +import com.nimbusds.jose.jwk.source.JWKSource; +import com.nimbusds.jose.proc.SecurityContext; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtEncoder; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; +import org.springframework.security.oauth2.jwt.NimbusJwtEncoder; + +import java.security.KeyFactory; +import java.security.interfaces.RSAPrivateKey; +import java.security.interfaces.RSAPublicKey; +import java.security.spec.PKCS8EncodedKeySpec; +import java.security.spec.X509EncodedKeySpec; +import java.util.Base64; + +@Configuration +public class HealthJwtConfig { + + @Value("${jwt.private-key}") + private String privateKeyString; + + @Value("${jwt.public-key}") + private String publicKeyString; + + @Bean + public RSAPrivateKey rsaPrivateKey() { + try { + // "-----BEGIN PRIVATE KEY-----"와 "-----END PRIVATE KEY-----" 제거 + String privateKeyPEM = privateKeyString + .replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replaceAll("\\s", ""); + + byte[] decoded = Base64.getDecoder().decode(privateKeyPEM); + PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(decoded); + KeyFactory keyFactory = KeyFactory.getInstance("RSA"); + + return (RSAPrivateKey) keyFactory.generatePrivate(spec); + } catch (Exception e) { + throw new RuntimeException("Unable to load RSA private key", e); + } + } + + @Bean + public RSAPublicKey rsaPublicKey() { + try { + // "-----BEGIN PUBLIC KEY-----"와 "-----END PUBLIC KEY-----" 제거 + String publicKeyPEM = publicKeyString + .replace("-----BEGIN PUBLIC KEY-----", "") + .replace("-----END PUBLIC KEY-----", "") + .replaceAll("\\s", ""); + + byte[] decoded = Base64.getDecoder().decode(publicKeyPEM); + X509EncodedKeySpec spec = new X509EncodedKeySpec(decoded); + KeyFactory keyFactory = KeyFactory.getInstance("RSA"); + + return (RSAPublicKey) keyFactory.generatePublic(spec); + } catch (Exception e) { + throw new RuntimeException("Unable to load RSA public key", e); + } + } + + @Bean + public JWKSource jwkSource(RSAPublicKey publicKey, RSAPrivateKey privateKey) { + JWK jwk = new RSAKey.Builder(publicKey) + .privateKey(privateKey) + .keyID("healthsync-key-id") + .build(); + + JWKSet jwkSet = new JWKSet(jwk); + return new ImmutableJWKSet<>(jwkSet); + } + + @Bean + public JwtEncoder jwtEncoder(JWKSource jwkSource) { + return new NimbusJwtEncoder(jwkSource); + } + + @Bean + public JwtDecoder jwtDecoder(RSAPublicKey publicKey) { + return NimbusJwtDecoder.withPublicKey(publicKey).build(); + } +} diff --git a/health-service/src/main/java/com/healthsync/health/config/HealthSecurityConfig.java b/health-service/src/main/java/com/healthsync/health/config/HealthSecurityConfig.java new file mode 100644 index 0000000..5408264 --- /dev/null +++ b/health-service/src/main/java/com/healthsync/health/config/HealthSecurityConfig.java @@ -0,0 +1,73 @@ +package com.healthsync.health.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.http.SessionCreationPolicy; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; +import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter; +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; + +@Configuration +@EnableWebSecurity +public class HealthSecurityConfig { + + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .csrf(csrf -> csrf.disable()) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + // 공개 접근 허용 + .requestMatchers( + "/", + "/swagger-ui/**", + "/swagger-ui.html", + "/v3/api-docs/**", + "/swagger-resources/**", + "/webjars/**" + ).permitAll() + .anyRequest().authenticated() + ) + + .oauth2ResourceServer(oauth2 -> oauth2 + .jwt(jwt -> jwt + .jwtAuthenticationConverter(jwtAuthenticationConverter()) + ) + ); + + return http.build(); + } + + @Bean + public JwtAuthenticationConverter jwtAuthenticationConverter() { + JwtGrantedAuthoritiesConverter authoritiesConverter = new JwtGrantedAuthoritiesConverter(); + authoritiesConverter.setAuthorityPrefix(""); + authoritiesConverter.setAuthoritiesClaimName("role"); + + JwtAuthenticationConverter converter = new JwtAuthenticationConverter(); + converter.setJwtGrantedAuthoritiesConverter(authoritiesConverter); + return converter; + } + + @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; + } +} \ No newline at end of file diff --git a/health-service/src/main/java/com/healthsync/health/config/HealthSwaggerConfig.java b/health-service/src/main/java/com/healthsync/health/config/HealthSwaggerConfig.java new file mode 100644 index 0000000..e48b0c2 --- /dev/null +++ b/health-service/src/main/java/com/healthsync/health/config/HealthSwaggerConfig.java @@ -0,0 +1,54 @@ +package com.healthsync.health.config; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.info.Contact; +import io.swagger.v3.oas.models.info.License; +import io.swagger.v3.oas.models.servers.Server; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import io.swagger.v3.oas.models.Components; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.List; + +@Configuration +public class HealthSwaggerConfig { + + @Bean + public OpenAPI healthOpenAPI() { + return new OpenAPI() + .info(apiInfo()) + .servers(List.of( + new Server().url("http://localhost:8082").description("개발 서버"), + new Server().url("https://api.healthsync.com").description("운영 서버") + )) + .addSecurityItem(new SecurityRequirement().addList("Bearer Authentication")) + .components(new Components() + .addSecuritySchemes("Bearer Authentication", + new SecurityScheme() + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT") + .description("JWT 토큰을 입력하세요. 'Bearer ' 접두사는 자동으로 추가됩니다.") + ) + ); + } + + private Info apiInfo() { + return new Info() + .title("HealthSync User API") + .description("HealthSync 사용자 관리 및 인증 API 문서") + .version("1.0.0") + .contact(new Contact() + .name("HealthSync Team") + .email("support@healthsync.com") + .url("https://healthsync.com") + ) + .license(new License() + .name("MIT License") + .url("https://opensource.org/licenses/MIT") + ); + } +} \ No newline at end of file diff --git a/health-service/src/main/java/com/healthsync/health/config/ObjectMapperConfig.java b/health-service/src/main/java/com/healthsync/health/config/ObjectMapperConfig.java new file mode 100644 index 0000000..bbe7daa --- /dev/null +++ b/health-service/src/main/java/com/healthsync/health/config/ObjectMapperConfig.java @@ -0,0 +1,17 @@ +package com.healthsync.health.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class ObjectMapperConfig { + + @Bean + public ObjectMapper objectMapper() { + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.registerModule(new JavaTimeModule()); + return objectMapper; + } +} \ No newline at end of file diff --git a/health-service/src/main/java/com/healthsync/health/controller/HealthCheckupController.java b/health-service/src/main/java/com/healthsync/health/controller/HealthCheckupController.java new file mode 100644 index 0000000..745333e --- /dev/null +++ b/health-service/src/main/java/com/healthsync/health/controller/HealthCheckupController.java @@ -0,0 +1,364 @@ +package com.healthsync.health.controller; + +import com.healthsync.health.domain.HealthCheck.HealthCheckupRaw; +import com.healthsync.health.domain.HealthCheck.HealthCheckup; +import com.healthsync.health.domain.Oauth.User; +import com.healthsync.health.dto.HealthCheck.HealthCheckupSyncResult; +import com.healthsync.health.dto.HealthCheck.HealthProfileHistoryResponse; +import com.healthsync.health.service.HealthProfile.HealthProfileService; +import com.healthsync.health.service.HealthProfile.RealisticHealthMockDataGenerator; +import com.healthsync.health.service.UserProfile.UserService; +import com.healthsync.common.util.JwtUtil; +import com.healthsync.common.dto.CusApiResponse; +import com.healthsync.common.response.ResponseHelper; +import com.healthsync.common.exception.BusinessException; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import io.swagger.v3.oas.annotations.Parameter; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.web.bind.annotation.*; +import org.springframework.beans.factory.annotation.Value; + +import java.time.LocalDate; +import java.time.Period; +import java.util.List; +import java.util.Optional; + +@RestController +@RequestMapping("/api/health") +@Tag(name = "건강 프로필", description = "건강 프로필 관리 API") +@SecurityRequirement(name = "Bearer Authentication") +public class HealthCheckupController { + + private static final Logger logger = LoggerFactory.getLogger(HealthCheckupController.class); + + private final HealthProfileService healthProfileService; + private final UserService userService; + private final JwtUtil jwtUtil; + private final RealisticHealthMockDataGenerator realisticMockGenerator; + + // Mock 데이터 생성 활성화 여부 + @Value("${health.mock.enabled:true}") + private boolean mockDataEnabled; + + public HealthCheckupController(HealthProfileService healthProfileService, + UserService userService, + JwtUtil jwtUtil, + RealisticHealthMockDataGenerator realisticMockGenerator) { + this.healthProfileService = healthProfileService; + this.userService = userService; + this.jwtUtil = jwtUtil; + this.realisticMockGenerator = realisticMockGenerator; + } + + @GetMapping("/checkup/history") + @Operation( + summary = "건강검진 이력 조회", + description = "기존 로직을 완전히 따르는 건강검진 데이터 조회:\n" + + "1. health_checkup_raw 테이블에서 데이터 조회\n" + + "2. 데이터가 없으면 Mock Raw 데이터를 health_checkup_raw에 저장\n" + + "3. 저장된 Raw 데이터를 기존 로직으로 health_checkup에 처리\n" + + "4. 응답 생성" + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "건강검진 이력 조회 성공 (실제 데이터)"), + @ApiResponse(responseCode = "200", description = "건강검진 이력 조회 성공 (Mock 데이터 생성 및 처리)"), + @ApiResponse(responseCode = "200", description = "건강검진 이력 조회 성공 (데이터 없음)"), + @ApiResponse(responseCode = "401", description = "인증 실패"), + @ApiResponse(responseCode = "400", description = "사용자 정보 부족") + }) + public ResponseEntity> getHealthCheckupHistory( + @AuthenticationPrincipal Jwt jwt, + @Parameter(description = "Mock 데이터 생성 강제 여부", example = "false") + @RequestParam(name = "forceMock", defaultValue = "false") boolean forceMock) { + + logger.info("건강검진 이력 조회 요청 - Mock 강제: {}", forceMock); + + try { + // 1. JWT에서 memberSerialNumber 추출 + Long memberSerialNumber = Long.valueOf(jwt.getSubject()); + logger.debug("회원 일련번호: {}", memberSerialNumber); + + // 2. 사용자 정보 조회 + User user = userService.findById(memberSerialNumber) + .orElseThrow(() -> new BusinessException("USER_NOT_FOUND", "사용자를 찾을 수 없습니다: " + memberSerialNumber)); + + // 3. 생년월일 검증 + LocalDate birthDate = user.getBirthDate(); + if (birthDate == null) { + logger.warn("사용자 생년월일 정보 없음 - Member Serial Number: {}", memberSerialNumber); + return ResponseHelper.badRequest("사용자 생년월일 정보가 필요합니다.", "BIRTH_DATE_REQUIRED"); + } + + // === 기존 로직 완전히 따르기 === + + // 4. health_checkup_raw 테이블에서 데이터 조회 (기존 로직 1단계) + List rawHealthProfiles = healthProfileService + .getHealthCheckupHistory5years(user.getName(), birthDate); + + // 5. Mock 강제 사용이 아니고 Raw 데이터가 있는 경우 - 기존 로직 그대로 진행 + if (!forceMock && !rawHealthProfiles.isEmpty()) { + logger.info("실제 Raw 데이터 발견 - {} 건", rawHealthProfiles.size()); + return processExistingRawData(user, birthDate, memberSerialNumber, rawHealthProfiles); + } + + // 6. Raw 데이터가 없거나 Mock 강제 사용인 경우 - 현실적인 Mock 데이터 생성 + if (mockDataEnabled || forceMock) { + logger.info("Raw 데이터 없음, 현실적인 다중 연도 Mock 데이터 생성 - Member: {}", memberSerialNumber); + + // 성별 정보 추정 + Integer genderCode = estimateGenderCode(user.getName()); + + // 🎯 실제 User 객체를 전달하여 일관된 개인정보 사용 + CusApiResponse mockResponse = + realisticMockGenerator.generateRealisticMockData( + user, // ✅ User 객체 전체 전달 + genderCode, + memberSerialNumber, + 5 // 기본 5년 데이터 생성 + ); + + logger.info("현실적인 Mock 데이터 생성 및 처리 완료 - 사용자: {} ({}세), Member: {}", + user.getName(), Period.between(user.getBirthDate(), LocalDate.now()).getYears(), memberSerialNumber); + return ResponseEntity.ok(mockResponse); + } + + // 7. Mock 데이터도 비활성화된 경우 빈 응답 + logger.info("건강검진 데이터 없음 & Mock 비활성화 - Member: {}", memberSerialNumber); + return ResponseHelper.badRequest("BUSINESS_ERROR", "BUSINESS_ERROR"); + + } catch (BusinessException e) { + logger.error("비즈니스 로직 오류: {}", e.getMessage()); + return ResponseHelper.badRequest(e.getMessage(), "BUSINESS_ERROR"); + } catch (Exception e) { + logger.error("건강검진 이력 조회 중 오류 발생", e); + return ResponseHelper.internalServerError( + "건강검진 이력 조회 중 오류가 발생했습니다.", + "HISTORY_ERROR" + ); + } + } + + /** + * 실제 Raw 데이터가 있는 경우 기존 로직으로 처리 + * + * 기존 로직: + * 1. Raw 데이터 조회됨 + * 2. 가공된 데이터 조회 및 동기화 + * 3. 응답 생성 + */ + private ResponseEntity> processExistingRawData( + User user, LocalDate birthDate, Long memberSerialNumber, List rawHealthProfiles) { + + logger.info("기존 로직으로 실제 Raw 데이터 처리 - {} 건", rawHealthProfiles.size()); + + try { + // ✅ 이 부분이 누락되어 있음 - Raw 데이터를 health_checkup에 동기화 + HealthCheckupSyncResult syncResult = healthProfileService + .syncHealthCheckupData(rawHealthProfiles, memberSerialNumber); + + logger.info("Raw 데이터 동기화 완료 - 신규: {}, 갱신: {}, 건너뜀: {}", + syncResult.getNewCount(), syncResult.getUpdatedCount(), syncResult.getSkippedCount()); + + // 기존 로직 2단계: 가공된 데이터 조회 및 동기화 + Optional processedHealthProfile = healthProfileService + .getLatestProcessedHealthCheckup(memberSerialNumber); + + // 사용자 정보 구성 + HealthProfileHistoryResponse.UserInfo userInfo = createUserInfo(user, birthDate, rawHealthProfiles); + + HealthProfileHistoryResponse response; + + if (processedHealthProfile.isPresent()) { + // 가공된 데이터가 있는 경우 + HealthCheckup processed = processedHealthProfile.get(); + HealthCheckupRaw recentHealthProfile = findCorrespondingRawData(processed, rawHealthProfiles); + + // ✅ 수정: 전체 rawHealthProfiles 사용 + response = new HealthProfileHistoryResponse(userInfo, recentHealthProfile, rawHealthProfiles); + + logger.info("가공된 데이터 응답 - Member: {}, 검진년도: {}", + memberSerialNumber, processed.getReferenceYear()); + + return ResponseHelper.success(response, "건강검진 이력 조회 성공"); + } else { + // 가공된 데이터가 없으면 Raw 데이터만 응답 + HealthCheckupRaw recentRaw = rawHealthProfiles.get(0); + response = new HealthProfileHistoryResponse(userInfo, recentRaw, rawHealthProfiles); + + logger.info("Raw 데이터 응답 - Member: {}, 검진년도: {}", + memberSerialNumber, recentRaw.getReferenceYear()); + + return ResponseHelper.success(response, "건강검진 이력 조회 성공 (Raw 데이터)"); + } + + } catch (Exception e) { + logger.error("기존 Raw 데이터 처리 중 오류 - Member: {}", memberSerialNumber, e); + + // 오류 발생 시 사용자 정보만 포함된 응답 + HealthProfileHistoryResponse.UserInfo userInfo = createUserInfo(user, birthDate, rawHealthProfiles); + HealthProfileHistoryResponse response = new HealthProfileHistoryResponse(userInfo); + + CusApiResponse apiResponse = + new CusApiResponse<>(false, "건강검진 데이터 처리 중 오류가 발생했습니다.", response); + return ResponseEntity.ok(apiResponse); + } + } + + /** + * Mock 데이터 생성용 별도 엔드포인트 (개발/테스트용) + */ + @GetMapping("/checkup/mock") + @Operation( + summary = "현실적인 Mock 건강검진 데이터 생성 (개발용)", + description = "현실적인 건강 변화 패턴을 반영한 다중 연도 Mock 데이터를 생성합니다:\n" + + "1. 개인별 건강 베이스라인 생성 (건강형/평균형/주의형)\n" + + "2. 연도별 비선형적 건강 변화 패턴 적용\n" + + "3. health_checkup_raw 테이블에 다중 연도 데이터 저장\n" + + "4. 기존 syncHealthCheckupData로 health_checkup에 처리" + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "현실적인 Mock 데이터 생성 및 처리 성공"), + @ApiResponse(responseCode = "403", description = "Mock 데이터 생성이 비활성화됨"), + @ApiResponse(responseCode = "401", description = "인증 실패") + }) + public ResponseEntity> generateMockHealthData( + @AuthenticationPrincipal Jwt jwt, + @Parameter(description = "성별 코드 (1: 남성, 2: 여성)", example = "1") + @RequestParam(name = "gender", defaultValue = "1") Integer genderCode, + @Parameter(description = "생성할 연도 수 (1~5년)", example = "3") + @RequestParam(name = "yearCount", defaultValue = "3") Integer yearCount) { + + if (!mockDataEnabled) { + logger.warn("Mock 데이터 생성이 비활성화됨"); + return ResponseHelper.forbidden("Mock 데이터 생성이 비활성화되어 있습니다.", "MOCK_DISABLED"); + } + + logger.info("현실적인 Mock 건강검진 데이터 생성 요청 - 연도 수: {}", yearCount); + + try { + // JWT에서 memberSerialNumber 추출 + Long memberSerialNumber = Long.valueOf(jwt.getSubject()); + + // 사용자 정보 조회 + User user = userService.findById(memberSerialNumber) + .orElseThrow(() -> new BusinessException("USER_NOT_FOUND", "사용자를 찾을 수 없습니다: " + memberSerialNumber)); + + // 생년월일 검증 + LocalDate birthDate = user.getBirthDate(); + if (birthDate == null) { + // 기본 생년월일 설정 (30세 기준) + birthDate = LocalDate.now().minusYears(30); + logger.warn("생년월일 정보 없음, 기본값 설정: {}", birthDate); + } + + // 현실적인 다중 연도 Mock 데이터 생성 및 처리 + CusApiResponse mockResponse = + realisticMockGenerator.generateRealisticMockData( + user, // ✅ User 객체 전체 전달 + genderCode, + memberSerialNumber, + yearCount + ); + + logger.info("현실적인 Mock 건강검진 데이터 생성 및 처리 완료 - 사용자: {} ({}세), Member: {}, 연도 수: {}", + user.getName(), Period.between(user.getBirthDate(), LocalDate.now()).getYears(), + memberSerialNumber, yearCount); + return ResponseEntity.ok(mockResponse); + + } catch (BusinessException e) { + logger.error("비즈니스 로직 오류: {}", e.getMessage()); + return ResponseHelper.badRequest(e.getMessage(), "BUSINESS_ERROR"); + } catch (Exception e) { + logger.error("현실적인 Mock 건강검진 데이터 생성 중 오류 발생", e); + return ResponseHelper.internalServerError( + "현실적인 Mock 건강검진 데이터 생성 중 오류가 발생했습니다.", + "MOCK_ERROR" + ); + } + } + + /** + * 사용자 정보 생성 + */ + private HealthProfileHistoryResponse.UserInfo createUserInfo(User user, LocalDate birthDate, + List rawData) { + // 현재 나이 계산 + int age = Period.between(birthDate, LocalDate.now()).getYears(); + + // 성별 변환 (Raw 데이터에서 추출) + String gender = "정보 없음"; + if (!rawData.isEmpty()) { + HealthCheckupRaw latestRaw = rawData.get(0); + gender = convertGenderCodeToString(latestRaw.getGenderCode()); + } + + return new HealthProfileHistoryResponse.UserInfo( + user.getName(), + age, + gender, + user.getOccupation() != null ? user.getOccupation() : "정보 없음" + ); + } + + /** + * 가공된 데이터에 해당하는 Raw 데이터 찾기 + */ + private HealthCheckupRaw findCorrespondingRawData(HealthCheckup processed, List rawDataList) { + // 같은 raw_id의 원본 Raw 데이터 찾기 + Optional correspondingRaw = rawDataList.stream() + .filter(raw -> raw.getRawId().equals(processed.getRawId())) + .findFirst(); + + if (correspondingRaw.isPresent()) { + logger.debug("해당하는 Raw 데이터 발견 - Raw ID: {}", processed.getRawId()); + return correspondingRaw.get(); + } else { + logger.debug("해당하는 Raw 데이터 없음, 최신 Raw 데이터 사용 - Raw ID: {}", processed.getRawId()); + return rawDataList.get(0); // 최신 Raw 데이터 사용 + } + } + + /** + * 성별 코드 변환 + */ + private String convertGenderCodeToString(Integer genderCode) { + if (genderCode == null) return "정보 없음"; + + switch (genderCode) { + case 1: return "남성"; + case 2: return "여성"; + default: return "정보 없음"; + } + } + + /** + * 이름을 기반으로 성별 코드 추정 (간단한 로직) + */ + private Integer estimateGenderCode(String name) { + if (name == null || name.isEmpty()) { + return 1; // 기본값: 남성 + } + + // 간단한 한국 이름 성별 추정 + String[] femaleEndings = {"영", "희", "순", "미", "정", "은", "아", "자", "경", "지", "현", "수", "혜", "윤", "민"}; + String lastName = name.substring(name.length() - 1); + + for (String ending : femaleEndings) { + if (lastName.equals(ending)) { + return 2; // 여성 + } + } + + return 1; // 기본값: 남성 + } +} \ No newline at end of file diff --git a/health-service/src/main/java/com/healthsync/health/domain/HealthCheck/Gender.java b/health-service/src/main/java/com/healthsync/health/domain/HealthCheck/Gender.java new file mode 100644 index 0000000..cf87b4b --- /dev/null +++ b/health-service/src/main/java/com/healthsync/health/domain/HealthCheck/Gender.java @@ -0,0 +1,35 @@ +package com.healthsync.health.domain.HealthCheck; + +public enum Gender { + MALE(1, "남성"), + FEMALE(2, "여성"); + + private final int code; + private final String description; + + Gender(int code, String description) { + this.code = code; + this.description = description; + } + + public int getCode() { + return code; + } + + public String getDescription() { + return description; + } + + public static Gender fromCode(Integer code) { + if (code == null) { + return null; + } + + for (Gender gender : Gender.values()) { + if (gender.code == code) { + return gender; + } + } + return null; + } +} diff --git a/health-service/src/main/java/com/healthsync/health/domain/HealthCheck/HealthCheckup.java b/health-service/src/main/java/com/healthsync/health/domain/HealthCheck/HealthCheckup.java new file mode 100644 index 0000000..eedf012 --- /dev/null +++ b/health-service/src/main/java/com/healthsync/health/domain/HealthCheck/HealthCheckup.java @@ -0,0 +1,201 @@ +package com.healthsync.health.domain.HealthCheck; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDateTime; + +public class HealthCheckup { + private Long checkupId; + private Long memberSerialNumber; + private Long rawId; + private Integer referenceYear; + private Integer age; + private Integer height; + private Integer weight; + private BigDecimal bmi; + private Integer waistCircumference; + private BigDecimal visualAcuityLeft; + private BigDecimal visualAcuityRight; + private Integer hearingLeft; + private Integer hearingRight; + private Integer systolicBp; + private Integer diastolicBp; + private Integer fastingGlucose; + private Integer totalCholesterol; + private Integer triglyceride; + private Integer hdlCholesterol; + private Integer ldlCholesterol; + private BigDecimal hemoglobin; + private Integer urineProtein; + private BigDecimal serumCreatinine; + private Integer ast; + private Integer alt; + private Integer gammaGtp; + private Integer smokingStatus; + private Integer drinkingStatus; + private LocalDateTime processedAt; + private LocalDateTime createdAt; + + public HealthCheckup() {} + + // HealthCheckupRaw에서 HealthCheckup으로 변환하는 정적 팩토리 메서드 + public static HealthCheckup fromRaw(HealthCheckupRaw rawData, Long memberSerialNumber) { + HealthCheckup checkup = new HealthCheckup(); + + checkup.memberSerialNumber = memberSerialNumber; + checkup.rawId = rawData.getRawId(); + checkup.referenceYear = rawData.getReferenceYear(); + checkup.age = rawData.getAge(); + checkup.height = rawData.getHeight(); + checkup.weight = rawData.getWeight(); + + // BMI 계산 (원천 데이터에서 계산된 값 사용) + checkup.bmi = rawData.calculateBMI(); + + checkup.waistCircumference = rawData.getWaistCircumference(); + checkup.visualAcuityLeft = rawData.getVisualAcuityLeft(); + checkup.visualAcuityRight = rawData.getVisualAcuityRight(); + checkup.hearingLeft = rawData.getHearingLeft(); + checkup.hearingRight = rawData.getHearingRight(); + checkup.systolicBp = rawData.getSystolicBp(); + checkup.diastolicBp = rawData.getDiastolicBp(); + checkup.fastingGlucose = rawData.getFastingGlucose(); + checkup.totalCholesterol = rawData.getTotalCholesterol(); + checkup.triglyceride = rawData.getTriglyceride(); + checkup.hdlCholesterol = rawData.getHdlCholesterol(); + checkup.ldlCholesterol = rawData.getLdlCholesterol(); + checkup.hemoglobin = rawData.getHemoglobin(); + checkup.urineProtein = rawData.getUrineProtein(); + checkup.serumCreatinine = rawData.getSerumCreatinine(); + checkup.ast = rawData.getAst(); + checkup.alt = rawData.getAlt(); + checkup.gammaGtp = rawData.getGammaGtp(); + checkup.smokingStatus = rawData.getSmokingStatus(); + checkup.drinkingStatus = rawData.getDrinkingStatus(); + + checkup.processedAt = LocalDateTime.now(); + checkup.createdAt = rawData.getCreatedAt(); + + return checkup; + } + + // BMI 계산 메서드 + public BigDecimal calculateBMI() { + if (height != null && weight != null && height > 0) { + double heightInM = height / 100.0; + double bmi = weight / (heightInM * heightInM); + return BigDecimal.valueOf(bmi).setScale(2, RoundingMode.HALF_UP); + } + return null; + } + + // 혈압 문자열 반환 메서드 + public String getBloodPressureString() { + if (systolicBp != null && diastolicBp != null) { + return systolicBp + "/" + diastolicBp; + } + return null; + } + + // BMI 상태 반환 메서드 + public String getBmiStatus() { + if (bmi == null) return "정보 없음"; + + double bmiValue = bmi.doubleValue(); + if (bmiValue < 18.5) return "저체중"; + else if (bmiValue < 25.0) return "정상"; + else if (bmiValue < 30.0) return "과체중"; + else return "비만"; + } + + // Getters and Setters + public Long getCheckupId() { return checkupId; } + public void setCheckupId(Long checkupId) { this.checkupId = checkupId; } + + public Long getMemberSerialNumber() { return memberSerialNumber; } + public void setMemberSerialNumber(Long memberSerialNumber) { this.memberSerialNumber = memberSerialNumber; } + + public Long getRawId() { return rawId; } + public void setRawId(Long rawId) { this.rawId = rawId; } + + public Integer getReferenceYear() { return referenceYear; } + public void setReferenceYear(Integer referenceYear) { this.referenceYear = referenceYear; } + + public Integer getAge() { return age; } + public void setAge(Integer age) { this.age = age; } + + public Integer getHeight() { return height; } + public void setHeight(Integer height) { this.height = height; } + + public Integer getWeight() { return weight; } + public void setWeight(Integer weight) { this.weight = weight; } + + public BigDecimal getBmi() { return bmi; } + public void setBmi(BigDecimal bmi) { this.bmi = bmi; } + + public Integer getWaistCircumference() { return waistCircumference; } + public void setWaistCircumference(Integer waistCircumference) { this.waistCircumference = waistCircumference; } + + public BigDecimal getVisualAcuityLeft() { return visualAcuityLeft; } + public void setVisualAcuityLeft(BigDecimal visualAcuityLeft) { this.visualAcuityLeft = visualAcuityLeft; } + + public BigDecimal getVisualAcuityRight() { return visualAcuityRight; } + public void setVisualAcuityRight(BigDecimal visualAcuityRight) { this.visualAcuityRight = visualAcuityRight; } + + public Integer getHearingLeft() { return hearingLeft; } + public void setHearingLeft(Integer hearingLeft) { this.hearingLeft = hearingLeft; } + + public Integer getHearingRight() { return hearingRight; } + public void setHearingRight(Integer hearingRight) { this.hearingRight = hearingRight; } + + public Integer getSystolicBp() { return systolicBp; } + public void setSystolicBp(Integer systolicBp) { this.systolicBp = systolicBp; } + + public Integer getDiastolicBp() { return diastolicBp; } + public void setDiastolicBp(Integer diastolicBp) { this.diastolicBp = diastolicBp; } + + public Integer getFastingGlucose() { return fastingGlucose; } + public void setFastingGlucose(Integer fastingGlucose) { this.fastingGlucose = fastingGlucose; } + + public Integer getTotalCholesterol() { return totalCholesterol; } + public void setTotalCholesterol(Integer totalCholesterol) { this.totalCholesterol = totalCholesterol; } + + public Integer getTriglyceride() { return triglyceride; } + public void setTriglyceride(Integer triglyceride) { this.triglyceride = triglyceride; } + + public Integer getHdlCholesterol() { return hdlCholesterol; } + public void setHdlCholesterol(Integer hdlCholesterol) { this.hdlCholesterol = hdlCholesterol; } + + public Integer getLdlCholesterol() { return ldlCholesterol; } + public void setLdlCholesterol(Integer ldlCholesterol) { this.ldlCholesterol = ldlCholesterol; } + + public BigDecimal getHemoglobin() { return hemoglobin; } + public void setHemoglobin(BigDecimal hemoglobin) { this.hemoglobin = hemoglobin; } + + public Integer getUrineProtein() { return urineProtein; } + public void setUrineProtein(Integer urineProtein) { this.urineProtein = urineProtein; } + + public BigDecimal getSerumCreatinine() { return serumCreatinine; } + public void setSerumCreatinine(BigDecimal serumCreatinine) { this.serumCreatinine = serumCreatinine; } + + public Integer getAst() { return ast; } + public void setAst(Integer ast) { this.ast = ast; } + + public Integer getAlt() { return alt; } + public void setAlt(Integer alt) { this.alt = alt; } + + public Integer getGammaGtp() { return gammaGtp; } + public void setGammaGtp(Integer gammaGtp) { this.gammaGtp = gammaGtp; } + + public Integer getSmokingStatus() { return smokingStatus; } + public void setSmokingStatus(Integer smokingStatus) { this.smokingStatus = smokingStatus; } + + public Integer getDrinkingStatus() { return drinkingStatus; } + public void setDrinkingStatus(Integer drinkingStatus) { this.drinkingStatus = drinkingStatus; } + + public LocalDateTime getProcessedAt() { return processedAt; } + public void setProcessedAt(LocalDateTime processedAt) { this.processedAt = processedAt; } + + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } +} \ No newline at end of file diff --git a/health-service/src/main/java/com/healthsync/health/domain/HealthCheck/HealthCheckupRaw.java b/health-service/src/main/java/com/healthsync/health/domain/HealthCheck/HealthCheckupRaw.java new file mode 100644 index 0000000..983ef8b --- /dev/null +++ b/health-service/src/main/java/com/healthsync/health/domain/HealthCheck/HealthCheckupRaw.java @@ -0,0 +1,159 @@ +package com.healthsync.health.domain.HealthCheck; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +public class HealthCheckupRaw { + private Long rawId; + private Integer referenceYear; + private LocalDate birthDate; + private String name; + private Integer regionCode; + private Integer genderCode; + private Integer age; + private Integer height; + private Integer weight; + private Integer waistCircumference; + private BigDecimal visualAcuityLeft; + private BigDecimal visualAcuityRight; + private Integer hearingLeft; + private Integer hearingRight; + private Integer systolicBp; + private Integer diastolicBp; + private Integer fastingGlucose; + private Integer totalCholesterol; + private Integer triglyceride; + private Integer hdlCholesterol; + private Integer ldlCholesterol; + private BigDecimal hemoglobin; + private Integer urineProtein; + private BigDecimal serumCreatinine; + private Integer ast; + private Integer alt; + private Integer gammaGtp; + private Integer smokingStatus; + private Integer drinkingStatus; + private LocalDateTime createdAt; + + public HealthCheckupRaw() {} + + // BMI 계산 메서드 + public BigDecimal calculateBMI() { + if (height != null && weight != null && height > 0) { + double heightInM = height / 100.0; + double bmi = weight / (heightInM * heightInM); + return BigDecimal.valueOf(bmi).setScale(1, BigDecimal.ROUND_HALF_UP); + } + return null; + } + + // 혈압 문자열 반환 메서드 + public String getBloodPressureString() { + if (systolicBp != null && diastolicBp != null) { + return systolicBp + "/" + diastolicBp; + } + return null; + } + + // Getters and Setters + public Long getRawId() { return rawId; } + public void setRawId(Long rawId) { this.rawId = rawId; } + + public Integer getReferenceYear() { return referenceYear; } + public void setReferenceYear(Integer referenceYear) { this.referenceYear = referenceYear; } + + public LocalDate getBirthDate() { return birthDate; } + public void setBirthDate(LocalDate birthDate) { this.birthDate = birthDate; } + + public String getName() { return name; } + public void setName(String name) { this.name = name; } + + public Integer getRegionCode() { return regionCode; } + public void setRegionCode(Integer regionCode) { this.regionCode = regionCode; } + + public Integer getGenderCode() { return genderCode; } + public void setGenderCode(Integer genderCode) { this.genderCode = genderCode; } + + public Integer getAge() { return age; } + public void setAge(Integer age) { this.age = age; } + + public Integer getHeight() { return height; } + public void setHeight(Integer height) { this.height = height; } + + public Integer getWeight() { return weight; } + public void setWeight(Integer weight) { this.weight = weight; } + + public Integer getWaistCircumference() { return waistCircumference; } + public void setWaistCircumference(Integer waistCircumference) { this.waistCircumference = waistCircumference; } + + public BigDecimal getVisualAcuityLeft() { return visualAcuityLeft; } + public void setVisualAcuityLeft(BigDecimal visualAcuityLeft) { this.visualAcuityLeft = visualAcuityLeft; } + + public BigDecimal getVisualAcuityRight() { return visualAcuityRight; } + public void setVisualAcuityRight(BigDecimal visualAcuityRight) { this.visualAcuityRight = visualAcuityRight; } + + public Integer getHearingLeft() { return hearingLeft; } + public void setHearingLeft(Integer hearingLeft) { this.hearingLeft = hearingLeft; } + + public Integer getHearingRight() { return hearingRight; } + public void setHearingRight(Integer hearingRight) { this.hearingRight = hearingRight; } + + public Integer getSystolicBp() { return systolicBp; } + public void setSystolicBp(Integer systolicBp) { this.systolicBp = systolicBp; } + + public Integer getDiastolicBp() { return diastolicBp; } + public void setDiastolicBp(Integer diastolicBp) { this.diastolicBp = diastolicBp; } + + public Integer getFastingGlucose() { return fastingGlucose; } + public void setFastingGlucose(Integer fastingGlucose) { this.fastingGlucose = fastingGlucose; } + + public Integer getTotalCholesterol() { return totalCholesterol; } + public void setTotalCholesterol(Integer totalCholesterol) { this.totalCholesterol = totalCholesterol; } + + public Integer getTriglyceride() { return triglyceride; } + public void setTriglyceride(Integer triglyceride) { this.triglyceride = triglyceride; } + + public Integer getHdlCholesterol() { return hdlCholesterol; } + public void setHdlCholesterol(Integer hdlCholesterol) { this.hdlCholesterol = hdlCholesterol; } + + public Integer getLdlCholesterol() { return ldlCholesterol; } + public void setLdlCholesterol(Integer ldlCholesterol) { this.ldlCholesterol = ldlCholesterol; } + + public BigDecimal getHemoglobin() { return hemoglobin; } + public void setHemoglobin(BigDecimal hemoglobin) { this.hemoglobin = hemoglobin; } + + public Integer getUrineProtein() { return urineProtein; } + public void setUrineProtein(Integer urineProtein) { this.urineProtein = urineProtein; } + + public BigDecimal getSerumCreatinine() { return serumCreatinine; } + public void setSerumCreatinine(BigDecimal serumCreatinine) { this.serumCreatinine = serumCreatinine; } + + public Integer getAst() { return ast; } + public void setAst(Integer ast) { this.ast = ast; } + + public Integer getAlt() { return alt; } + public void setAlt(Integer alt) { this.alt = alt; } + + public Integer getGammaGtp() { return gammaGtp; } + public void setGammaGtp(Integer gammaGtp) { this.gammaGtp = gammaGtp; } + + public Integer getSmokingStatus() { return smokingStatus; } + public void setSmokingStatus(Integer smokingStatus) { this.smokingStatus = smokingStatus; } + + public Integer getDrinkingStatus() { return drinkingStatus; } + public void setDrinkingStatus(Integer drinkingStatus) { this.drinkingStatus = drinkingStatus; } + + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } + + // 성별 관련 메서드 추가 + public Gender getGender() { + return Gender.fromCode(this.genderCode); + } + + public String getGenderDescription() { + Gender gender = getGender(); + return gender != null ? gender.getDescription() : "미상"; + } +} \ No newline at end of file diff --git a/health-service/src/main/java/com/healthsync/health/domain/HealthCheck/HealthNormalRange.java b/health-service/src/main/java/com/healthsync/health/domain/HealthCheck/HealthNormalRange.java new file mode 100644 index 0000000..b8c2a71 --- /dev/null +++ b/health-service/src/main/java/com/healthsync/health/domain/HealthCheck/HealthNormalRange.java @@ -0,0 +1,49 @@ +package com.healthsync.health.domain.HealthCheck; + +import java.time.LocalDateTime; + +public class HealthNormalRange { + private Integer rangeId; + private String healthItemCode; + private String healthItemName; + private Integer genderCode; + private String unit; + private String normalRange; + private String warningRange; + private String dangerRange; + private String note; + private LocalDateTime createdAt; + + public HealthNormalRange() {} + + // Getters and Setters + public Integer getRangeId() { return rangeId; } + public void setRangeId(Integer rangeId) { this.rangeId = rangeId; } + + public String getHealthItemCode() { return healthItemCode; } + public void setHealthItemCode(String healthItemCode) { this.healthItemCode = healthItemCode; } + + public String getHealthItemName() { return healthItemName; } + public void setHealthItemName(String healthItemName) { this.healthItemName = healthItemName; } + + public Integer getGenderCode() { return genderCode; } + public void setGenderCode(Integer genderCode) { this.genderCode = genderCode; } + + public String getUnit() { return unit; } + public void setUnit(String unit) { this.unit = unit; } + + public String getNormalRange() { return normalRange; } + public void setNormalRange(String normalRange) { this.normalRange = normalRange; } + + public String getWarningRange() { return warningRange; } + public void setWarningRange(String warningRange) { this.warningRange = warningRange; } + + public String getDangerRange() { return dangerRange; } + public void setDangerRange(String dangerRange) { this.dangerRange = dangerRange; } + + public String getNote() { return note; } + public void setNote(String note) { this.note = note; } + + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } +} \ No newline at end of file diff --git a/health-service/src/main/java/com/healthsync/health/domain/Oauth/JobCategory.java b/health-service/src/main/java/com/healthsync/health/domain/Oauth/JobCategory.java new file mode 100644 index 0000000..456bbbf --- /dev/null +++ b/health-service/src/main/java/com/healthsync/health/domain/Oauth/JobCategory.java @@ -0,0 +1,36 @@ +package com.healthsync.health.domain.Oauth; + +public enum JobCategory { + DEVELOPER(1, "개발"), + PM(2, "PM"), + MARKETING(3, "마케팅"), + SALES(4, "영업"), + INFRA_OPERATION(5, "인프라운영"), + CUSTOMER_SERVICE(6, "고객상담"), + ETC(7, "기타"); + + private final int code; + private final String name; + + JobCategory(int code, String name) { + this.code = code; + this.name = name; + } + + public int getCode() { + return code; + } + + public String getName() { + return name; + } + + public static JobCategory fromCode(int code) { + for (JobCategory category : JobCategory.values()) { + if (category.code == code) { + return category; + } + } + return ETC; // 기본값 + } +} diff --git a/health-service/src/main/java/com/healthsync/health/domain/Oauth/RefreshToken.java b/health-service/src/main/java/com/healthsync/health/domain/Oauth/RefreshToken.java new file mode 100644 index 0000000..079e54b --- /dev/null +++ b/health-service/src/main/java/com/healthsync/health/domain/Oauth/RefreshToken.java @@ -0,0 +1,42 @@ +package com.healthsync.health.domain.Oauth; + +import java.time.LocalDateTime; + +public class RefreshToken { + private Long id; + private String token; + private Long memberSerialNumber; // memberId -> memberSerialNumber로 변경 + private LocalDateTime expiryDate; + private LocalDateTime createdAt; + + public RefreshToken() { + this.createdAt = LocalDateTime.now(); + } + + public RefreshToken(String token, Long memberSerialNumber, LocalDateTime expiryDate) { + this(); + this.token = token; + this.memberSerialNumber = memberSerialNumber; + this.expiryDate = expiryDate; + } + + public boolean isExpired() { + return LocalDateTime.now().isAfter(expiryDate); + } + + // Getters and Setters + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + + public String getToken() { return token; } + public void setToken(String token) { this.token = token; } + + public Long getMemberSerialNumber() { return memberSerialNumber; } + public void setMemberSerialNumber(Long memberSerialNumber) { this.memberSerialNumber = memberSerialNumber; } + + public LocalDateTime getExpiryDate() { return expiryDate; } + public void setExpiryDate(LocalDateTime expiryDate) { this.expiryDate = expiryDate; } + + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } +} diff --git a/health-service/src/main/java/com/healthsync/health/domain/Oauth/User.java b/health-service/src/main/java/com/healthsync/health/domain/Oauth/User.java new file mode 100644 index 0000000..867ab6c --- /dev/null +++ b/health-service/src/main/java/com/healthsync/health/domain/Oauth/User.java @@ -0,0 +1,71 @@ +package com.healthsync.health.domain.Oauth; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +public class User { + private Long memberSerialNumber; // ID는 DB에서 자동 생성 + private String googleId; + private String name; + private LocalDate birthDate; + private String occupation; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + private LocalDateTime lastLoginAt; + + public User() { + this.createdAt = LocalDateTime.now(); + this.updatedAt = LocalDateTime.now(); + } + + // 신규 사용자 생성용 생성자 (ID 없음) + public User(String googleId, String name, LocalDate birthDate, String occupation) { + this(); // 기본 생성자 호출 + this.googleId = googleId; + this.name = name; + this.birthDate = birthDate; + this.occupation = occupation; + this.lastLoginAt = LocalDateTime.now(); + // memberSerialNumber는 설정하지 않음 - DB에서 자동 생성 + } + + // 로그인 시간 업데이트 + public void updateLastLoginAt() { + this.lastLoginAt = LocalDateTime.now(); + this.updatedAt = LocalDateTime.now(); + } + + // Getters and Setters + public Long getMemberSerialNumber() { return memberSerialNumber; } + public void setMemberSerialNumber(Long memberSerialNumber) { this.memberSerialNumber = memberSerialNumber; } + + public String getGoogleId() { return googleId; } + public void setGoogleId(String googleId) { this.googleId = googleId; } + + public String getName() { return name; } + public void setName(String name) { + this.name = name; + this.updatedAt = LocalDateTime.now(); + } + + public LocalDate getBirthDate() { return birthDate; } + public void setBirthDate(LocalDate birthDate) { + this.birthDate = birthDate; + this.updatedAt = LocalDateTime.now(); + } + + public String getOccupation() { return occupation; } + public void setOccupation(String occupation) { + this.occupation = occupation; + this.updatedAt = LocalDateTime.now(); + } + + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } + + public LocalDateTime getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; } + + public LocalDateTime getLastLoginAt() { return lastLoginAt; } + public void setLastLoginAt(LocalDateTime lastLoginAt) { this.lastLoginAt = lastLoginAt; } +} \ No newline at end of file diff --git a/health-service/src/main/java/com/healthsync/health/domain/Oauth/UserRole.java b/health-service/src/main/java/com/healthsync/health/domain/Oauth/UserRole.java new file mode 100644 index 0000000..6799743 --- /dev/null +++ b/health-service/src/main/java/com/healthsync/health/domain/Oauth/UserRole.java @@ -0,0 +1,5 @@ +package com.healthsync.health.domain.Oauth; + +public enum UserRole { + USER, ADMIN +} diff --git a/health-service/src/main/java/com/healthsync/health/dto/HealthCheck/HealthCheckupResponse.java b/health-service/src/main/java/com/healthsync/health/dto/HealthCheck/HealthCheckupResponse.java new file mode 100644 index 0000000..8439653 --- /dev/null +++ b/health-service/src/main/java/com/healthsync/health/dto/HealthCheck/HealthCheckupResponse.java @@ -0,0 +1,159 @@ +package com.healthsync.health.dto.HealthCheck; + +import com.healthsync.health.domain.HealthCheck.HealthCheckup; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +public class HealthCheckupResponse { + private Long checkupId; + private Integer referenceYear; + private Integer age; + private Integer height; + private Integer weight; + private BigDecimal bmi; + private String bmiStatus; + private Integer waistCircumference; + private BigDecimal visualAcuityLeft; + private BigDecimal visualAcuityRight; + private Integer hearingLeft; + private Integer hearingRight; + private Integer systolicBp; + private Integer diastolicBp; + private String bloodPressure; + private Integer fastingGlucose; + private Integer totalCholesterol; + private Integer triglyceride; + private Integer hdlCholesterol; + private Integer ldlCholesterol; + private BigDecimal hemoglobin; + private Integer urineProtein; + private BigDecimal serumCreatinine; + private Integer ast; + private Integer alt; + private Integer gammaGtp; + private Integer smokingStatus; + private Integer drinkingStatus; + private LocalDateTime processedAt; + + public HealthCheckupResponse() {} + + public HealthCheckupResponse(HealthCheckup healthCheckup) { + this.checkupId = healthCheckup.getCheckupId(); + this.referenceYear = healthCheckup.getReferenceYear(); + this.age = healthCheckup.getAge(); + this.height = healthCheckup.getHeight(); + this.weight = healthCheckup.getWeight(); + this.bmi = healthCheckup.getBmi(); + this.bmiStatus = healthCheckup.getBmiStatus(); + this.waistCircumference = healthCheckup.getWaistCircumference(); + this.visualAcuityLeft = healthCheckup.getVisualAcuityLeft(); + this.visualAcuityRight = healthCheckup.getVisualAcuityRight(); + this.hearingLeft = healthCheckup.getHearingLeft(); + this.hearingRight = healthCheckup.getHearingRight(); + this.systolicBp = healthCheckup.getSystolicBp(); + this.diastolicBp = healthCheckup.getDiastolicBp(); + this.bloodPressure = healthCheckup.getBloodPressureString(); + this.fastingGlucose = healthCheckup.getFastingGlucose(); + this.totalCholesterol = healthCheckup.getTotalCholesterol(); + this.triglyceride = healthCheckup.getTriglyceride(); + this.hdlCholesterol = healthCheckup.getHdlCholesterol(); + this.ldlCholesterol = healthCheckup.getLdlCholesterol(); + this.hemoglobin = healthCheckup.getHemoglobin(); + this.urineProtein = healthCheckup.getUrineProtein(); + this.serumCreatinine = healthCheckup.getSerumCreatinine(); + this.ast = healthCheckup.getAst(); + this.alt = healthCheckup.getAlt(); + this.gammaGtp = healthCheckup.getGammaGtp(); + this.smokingStatus = healthCheckup.getSmokingStatus(); + this.drinkingStatus = healthCheckup.getDrinkingStatus(); + this.processedAt = healthCheckup.getProcessedAt(); + } + + // Getters and Setters + public Long getCheckupId() { return checkupId; } + public void setCheckupId(Long checkupId) { this.checkupId = checkupId; } + + public Integer getReferenceYear() { return referenceYear; } + public void setReferenceYear(Integer referenceYear) { this.referenceYear = referenceYear; } + + public Integer getAge() { return age; } + public void setAge(Integer age) { this.age = age; } + + public Integer getHeight() { return height; } + public void setHeight(Integer height) { this.height = height; } + + public Integer getWeight() { return weight; } + public void setWeight(Integer weight) { this.weight = weight; } + + public BigDecimal getBmi() { return bmi; } + public void setBmi(BigDecimal bmi) { this.bmi = bmi; } + + public String getBmiStatus() { return bmiStatus; } + public void setBmiStatus(String bmiStatus) { this.bmiStatus = bmiStatus; } + + public Integer getWaistCircumference() { return waistCircumference; } + public void setWaistCircumference(Integer waistCircumference) { this.waistCircumference = waistCircumference; } + + public BigDecimal getVisualAcuityLeft() { return visualAcuityLeft; } + public void setVisualAcuityLeft(BigDecimal visualAcuityLeft) { this.visualAcuityLeft = visualAcuityLeft; } + + public BigDecimal getVisualAcuityRight() { return visualAcuityRight; } + public void setVisualAcuityRight(BigDecimal visualAcuityRight) { this.visualAcuityRight = visualAcuityRight; } + + public Integer getHearingLeft() { return hearingLeft; } + public void setHearingLeft(Integer hearingLeft) { this.hearingLeft = hearingLeft; } + + public Integer getHearingRight() { return hearingRight; } + public void setHearingRight(Integer hearingRight) { this.hearingRight = hearingRight; } + + public Integer getSystolicBp() { return systolicBp; } + public void setSystolicBp(Integer systolicBp) { this.systolicBp = systolicBp; } + + public Integer getDiastolicBp() { return diastolicBp; } + public void setDiastolicBp(Integer diastolicBp) { this.diastolicBp = diastolicBp; } + + public String getBloodPressure() { return bloodPressure; } + public void setBloodPressure(String bloodPressure) { this.bloodPressure = bloodPressure; } + + public Integer getFastingGlucose() { return fastingGlucose; } + public void setFastingGlucose(Integer fastingGlucose) { this.fastingGlucose = fastingGlucose; } + + public Integer getTotalCholesterol() { return totalCholesterol; } + public void setTotalCholesterol(Integer totalCholesterol) { this.totalCholesterol = totalCholesterol; } + + public Integer getTriglyceride() { return triglyceride; } + public void setTriglyceride(Integer triglyceride) { this.triglyceride = triglyceride; } + + public Integer getHdlCholesterol() { return hdlCholesterol; } + public void setHdlCholesterol(Integer hdlCholesterol) { this.hdlCholesterol = hdlCholesterol; } + + public Integer getLdlCholesterol() { return ldlCholesterol; } + public void setLdlCholesterol(Integer ldlCholesterol) { this.ldlCholesterol = ldlCholesterol; } + + public BigDecimal getHemoglobin() { return hemoglobin; } + public void setHemoglobin(BigDecimal hemoglobin) { this.hemoglobin = hemoglobin; } + + public Integer getUrineProtein() { return urineProtein; } + public void setUrineProtein(Integer urineProtein) { this.urineProtein = urineProtein; } + + public BigDecimal getSerumCreatinine() { return serumCreatinine; } + public void setSerumCreatinine(BigDecimal serumCreatinine) { this.serumCreatinine = serumCreatinine; } + + public Integer getAst() { return ast; } + public void setAst(Integer ast) { this.ast = ast; } + + public Integer getAlt() { return alt; } + public void setAlt(Integer alt) { this.alt = alt; } + + public Integer getGammaGtp() { return gammaGtp; } + public void setGammaGtp(Integer gammaGtp) { this.gammaGtp = gammaGtp; } + + public Integer getSmokingStatus() { return smokingStatus; } + public void setSmokingStatus(Integer smokingStatus) { this.smokingStatus = smokingStatus; } + + public Integer getDrinkingStatus() { return drinkingStatus; } + public void setDrinkingStatus(Integer drinkingStatus) { this.drinkingStatus = drinkingStatus; } + + public LocalDateTime getProcessedAt() { return processedAt; } + public void setProcessedAt(LocalDateTime processedAt) { this.processedAt = processedAt; } +} \ No newline at end of file diff --git a/health-service/src/main/java/com/healthsync/health/dto/HealthCheck/HealthCheckupSyncResult.java b/health-service/src/main/java/com/healthsync/health/dto/HealthCheck/HealthCheckupSyncResult.java new file mode 100644 index 0000000..7c3bbb5 --- /dev/null +++ b/health-service/src/main/java/com/healthsync/health/dto/HealthCheck/HealthCheckupSyncResult.java @@ -0,0 +1,38 @@ +package com.healthsync.health.dto.HealthCheck; + +import com.healthsync.health.domain.HealthCheck.HealthCheckup; + +public class HealthCheckupSyncResult { + private int totalCount; // 총 처리된 데이터 수 + private int newCount; // 새로 추가된 데이터 수 + private int updatedCount; // 업데이트된 데이터 수 + private int skippedCount; // 건너뛴 데이터 수 (이미 존재) + private HealthCheckup latestCheckup; // 최신 건강검진 데이터 + + public HealthCheckupSyncResult() {} + + public HealthCheckupSyncResult(int totalCount, int newCount, int updatedCount, + int skippedCount, HealthCheckup latestCheckup) { + this.totalCount = totalCount; + this.newCount = newCount; + this.updatedCount = updatedCount; + this.skippedCount = skippedCount; + this.latestCheckup = latestCheckup; + } + + // Getters and Setters + public int getTotalCount() { return totalCount; } + public void setTotalCount(int totalCount) { this.totalCount = totalCount; } + + public int getNewCount() { return newCount; } + public void setNewCount(int newCount) { this.newCount = newCount; } + + public int getUpdatedCount() { return updatedCount; } + public void setUpdatedCount(int updatedCount) { this.updatedCount = updatedCount; } + + public int getSkippedCount() { return skippedCount; } + public void setSkippedCount(int skippedCount) { this.skippedCount = skippedCount; } + + public HealthCheckup getLatestCheckup() { return latestCheckup; } + public void setLatestCheckup(HealthCheckup latestCheckup) { this.latestCheckup = latestCheckup; } +} \ No newline at end of file diff --git a/health-service/src/main/java/com/healthsync/health/dto/HealthCheck/HealthProfileDto.java b/health-service/src/main/java/com/healthsync/health/dto/HealthCheck/HealthProfileDto.java new file mode 100644 index 0000000..fdeef7c --- /dev/null +++ b/health-service/src/main/java/com/healthsync/health/dto/HealthCheck/HealthProfileDto.java @@ -0,0 +1,230 @@ +package com.healthsync.health.dto.HealthCheck; + +import com.healthsync.health.domain.HealthCheck.HealthCheckupRaw; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +public class HealthProfileDto { + @JsonProperty("rawId") + private Long rawId; + + @JsonProperty("referenceYear") + private Integer referenceYear; + + @JsonProperty("birthDate") + private LocalDate birthDate; + + @JsonProperty("name") + private String name; + + @JsonProperty("regionCode") + private Integer regionCode; + + @JsonProperty("genderCode") + private Integer genderCode; + + @JsonProperty("age") + private Integer age; + + @JsonProperty("height") + private Integer height; + + @JsonProperty("weight") + private Integer weight; + + @JsonProperty("waistCircumference") + private Integer waistCircumference; + + @JsonProperty("visualAcuityLeft") + private BigDecimal visualAcuityLeft; + + @JsonProperty("visualAcuityRight") + private BigDecimal visualAcuityRight; + + @JsonProperty("hearingLeft") + private Integer hearingLeft; + + @JsonProperty("hearingRight") + private Integer hearingRight; + + @JsonProperty("systolicBp") + private Integer systolicBp; + + @JsonProperty("diastolicBp") + private Integer diastolicBp; + + @JsonProperty("fastingGlucose") + private Integer fastingGlucose; + + @JsonProperty("totalCholesterol") + private Integer totalCholesterol; + + @JsonProperty("triglyceride") + private Integer triglyceride; + + @JsonProperty("hdlCholesterol") + private Integer hdlCholesterol; + + @JsonProperty("ldlCholesterol") + private Integer ldlCholesterol; + + @JsonProperty("hemoglobin") + private BigDecimal hemoglobin; + + @JsonProperty("urineProtein") + private Integer urineProtein; + + @JsonProperty("serumCreatinine") + private BigDecimal serumCreatinine; + + @JsonProperty("ast") + private Integer ast; + + @JsonProperty("alt") + private Integer alt; + + @JsonProperty("gammaGtp") + private Integer gammaGtp; + + @JsonProperty("smokingStatus") + private Integer smokingStatus; + + @JsonProperty("drinkingStatus") + private Integer drinkingStatus; + + @JsonProperty("createdAt") + private LocalDateTime createdAt; + + public HealthProfileDto() {} + + // HealthCheckupRaw로부터 변환하는 정적 메서드 + public static HealthProfileDto fromDomain(HealthCheckupRaw domain) { + if (domain == null) return null; + + HealthProfileDto dto = new HealthProfileDto(); + dto.rawId = domain.getRawId(); + dto.referenceYear = domain.getReferenceYear(); + dto.birthDate = domain.getBirthDate(); + dto.name = domain.getName(); + dto.regionCode = domain.getRegionCode(); + dto.genderCode = domain.getGenderCode(); + dto.age = domain.getAge(); + dto.height = domain.getHeight(); + dto.weight = domain.getWeight(); + dto.waistCircumference = domain.getWaistCircumference(); + dto.visualAcuityLeft = domain.getVisualAcuityLeft(); + dto.visualAcuityRight = domain.getVisualAcuityRight(); + dto.hearingLeft = domain.getHearingLeft(); + dto.hearingRight = domain.getHearingRight(); + dto.systolicBp = domain.getSystolicBp(); + dto.diastolicBp = domain.getDiastolicBp(); + dto.fastingGlucose = domain.getFastingGlucose(); + dto.totalCholesterol = domain.getTotalCholesterol(); + dto.triglyceride = domain.getTriglyceride(); + dto.hdlCholesterol = domain.getHdlCholesterol(); + dto.ldlCholesterol = domain.getLdlCholesterol(); + dto.hemoglobin = domain.getHemoglobin(); + dto.urineProtein = domain.getUrineProtein(); + dto.serumCreatinine = domain.getSerumCreatinine(); + dto.ast = domain.getAst(); + dto.alt = domain.getAlt(); + dto.gammaGtp = domain.getGammaGtp(); + dto.smokingStatus = domain.getSmokingStatus(); + dto.drinkingStatus = domain.getDrinkingStatus(); + dto.createdAt = domain.getCreatedAt(); + return dto; + } + + // Getters and Setters + public Long getRawId() { return rawId; } + public void setRawId(Long rawId) { this.rawId = rawId; } + + public Integer getReferenceYear() { return referenceYear; } + public void setReferenceYear(Integer referenceYear) { this.referenceYear = referenceYear; } + + public LocalDate getBirthDate() { return birthDate; } + public void setBirthDate(LocalDate birthDate) { this.birthDate = birthDate; } + + public String getName() { return name; } + public void setName(String name) { this.name = name; } + + public Integer getRegionCode() { return regionCode; } + public void setRegionCode(Integer regionCode) { this.regionCode = regionCode; } + + public Integer getGenderCode() { return genderCode; } + public void setGenderCode(Integer genderCode) { this.genderCode = genderCode; } + + public Integer getAge() { return age; } + public void setAge(Integer age) { this.age = age; } + + public Integer getHeight() { return height; } + public void setHeight(Integer height) { this.height = height; } + + public Integer getWeight() { return weight; } + public void setWeight(Integer weight) { this.weight = weight; } + + public Integer getWaistCircumference() { return waistCircumference; } + public void setWaistCircumference(Integer waistCircumference) { this.waistCircumference = waistCircumference; } + + public BigDecimal getVisualAcuityLeft() { return visualAcuityLeft; } + public void setVisualAcuityLeft(BigDecimal visualAcuityLeft) { this.visualAcuityLeft = visualAcuityLeft; } + + public BigDecimal getVisualAcuityRight() { return visualAcuityRight; } + public void setVisualAcuityRight(BigDecimal visualAcuityRight) { this.visualAcuityRight = visualAcuityRight; } + + public Integer getHearingLeft() { return hearingLeft; } + public void setHearingLeft(Integer hearingLeft) { this.hearingLeft = hearingLeft; } + + public Integer getHearingRight() { return hearingRight; } + public void setHearingRight(Integer hearingRight) { this.hearingRight = hearingRight; } + + public Integer getSystolicBp() { return systolicBp; } + public void setSystolicBp(Integer systolicBp) { this.systolicBp = systolicBp; } + + public Integer getDiastolicBp() { return diastolicBp; } + public void setDiastolicBp(Integer diastolicBp) { this.diastolicBp = diastolicBp; } + + public Integer getFastingGlucose() { return fastingGlucose; } + public void setFastingGlucose(Integer fastingGlucose) { this.fastingGlucose = fastingGlucose; } + + public Integer getTotalCholesterol() { return totalCholesterol; } + public void setTotalCholesterol(Integer totalCholesterol) { this.totalCholesterol = totalCholesterol; } + + public Integer getTriglyceride() { return triglyceride; } + public void setTriglyceride(Integer triglyceride) { this.triglyceride = triglyceride; } + + public Integer getHdlCholesterol() { return hdlCholesterol; } + public void setHdlCholesterol(Integer hdlCholesterol) { this.hdlCholesterol = hdlCholesterol; } + + public Integer getLdlCholesterol() { return ldlCholesterol; } + public void setLdlCholesterol(Integer ldlCholesterol) { this.ldlCholesterol = ldlCholesterol; } + + public BigDecimal getHemoglobin() { return hemoglobin; } + public void setHemoglobin(BigDecimal hemoglobin) { this.hemoglobin = hemoglobin; } + + public Integer getUrineProtein() { return urineProtein; } + public void setUrineProtein(Integer urineProtein) { this.urineProtein = urineProtein; } + + public BigDecimal getSerumCreatinine() { return serumCreatinine; } + public void setSerumCreatinine(BigDecimal serumCreatinine) { this.serumCreatinine = serumCreatinine; } + + public Integer getAst() { return ast; } + public void setAst(Integer ast) { this.ast = ast; } + + public Integer getAlt() { return alt; } + public void setAlt(Integer alt) { this.alt = alt; } + + public Integer getGammaGtp() { return gammaGtp; } + public void setGammaGtp(Integer gammaGtp) { this.gammaGtp = gammaGtp; } + + public Integer getSmokingStatus() { return smokingStatus; } + public void setSmokingStatus(Integer smokingStatus) { this.smokingStatus = smokingStatus; } + + public Integer getDrinkingStatus() { return drinkingStatus; } + public void setDrinkingStatus(Integer drinkingStatus) { this.drinkingStatus = drinkingStatus; } + + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } +} \ No newline at end of file diff --git a/health-service/src/main/java/com/healthsync/health/dto/HealthCheck/HealthProfileHistoryResponse.java b/health-service/src/main/java/com/healthsync/health/dto/HealthCheck/HealthProfileHistoryResponse.java new file mode 100644 index 0000000..db6e96f --- /dev/null +++ b/health-service/src/main/java/com/healthsync/health/dto/HealthCheck/HealthProfileHistoryResponse.java @@ -0,0 +1,93 @@ +package com.healthsync.health.dto.HealthCheck; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; +import java.util.ArrayList; +import java.util.stream.Collectors; +import com.healthsync.health.domain.HealthCheck.HealthCheckupRaw; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public class HealthProfileHistoryResponse { + + @JsonProperty("userInfo") + private UserInfo userInfo; + + @JsonProperty("recentHealthProfile") + private HealthProfileDto recentHealthProfile; + + @JsonProperty("healthProfiles") + private List healthProfiles; + + public HealthProfileHistoryResponse() { + this.healthProfiles = new ArrayList<>(); + } + + public HealthProfileHistoryResponse(UserInfo userInfo, + HealthCheckupRaw recentHealthProfile, + List healthProfiles) { + this.userInfo = userInfo; + this.recentHealthProfile = HealthProfileDto.fromDomain(recentHealthProfile); + this.healthProfiles = healthProfiles != null ? + healthProfiles.stream() + .map(HealthProfileDto::fromDomain) + .collect(Collectors.toList()) : new ArrayList<>(); + } + + // 데이터가 없을 때 생성자 (userInfo만 포함) + public HealthProfileHistoryResponse(UserInfo userInfo) { + this.userInfo = userInfo; + this.recentHealthProfile = null; + this.healthProfiles = new ArrayList<>(); + } + + // Getters and Setters + public UserInfo getUserInfo() { return userInfo; } + public void setUserInfo(UserInfo userInfo) { this.userInfo = userInfo; } + + public HealthProfileDto getRecentHealthProfile() { return recentHealthProfile; } + public void setRecentHealthProfile(HealthProfileDto recentHealthProfile) { this.recentHealthProfile = recentHealthProfile; } + + public List getHealthProfiles() { return healthProfiles; } + public void setHealthProfiles(List healthProfiles) { + this.healthProfiles = healthProfiles != null ? healthProfiles : new ArrayList<>(); + } + + // 내부 UserInfo 클래스 + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class UserInfo { + @JsonProperty("name") + private String name; + + @JsonProperty("age") + private int age; + + @JsonProperty("gender") + private String gender; + + @JsonProperty("occupation") + private String occupation; + + public UserInfo() {} + + public UserInfo(String name, int age, String gender, String occupation) { + this.name = name; + this.age = age; + this.gender = gender; + this.occupation = occupation; + } + + // Getters and Setters + public String getName() { return name; } + public void setName(String name) { this.name = name; } + + public int getAge() { return age; } + public void setAge(int age) { this.age = age; } + + public String getGender() { return gender; } + public void setGender(String gender) { this.gender = gender; } + + public String getOccupation() { return occupation; } + public void setOccupation(String occupation) { this.occupation = occupation; } + } +} \ No newline at end of file diff --git a/health-service/src/main/java/com/healthsync/health/dto/Oauth/OAuth2UserInfo.java b/health-service/src/main/java/com/healthsync/health/dto/Oauth/OAuth2UserInfo.java new file mode 100644 index 0000000..308f456 --- /dev/null +++ b/health-service/src/main/java/com/healthsync/health/dto/Oauth/OAuth2UserInfo.java @@ -0,0 +1,31 @@ +package com.healthsync.health.dto.Oauth; + +import java.util.Map; + +public class OAuth2UserInfo { + private Map attributes; + + public OAuth2UserInfo(Map attributes) { + this.attributes = attributes; + } + + public String getId() { + return (String) attributes.get("sub"); + } + + public String getName() { + return (String) attributes.get("name"); + } + + public String getEmail() { + return (String) attributes.get("email"); + } + + public String getImageUrl() { + return (String) attributes.get("picture"); + } + + public Map getAttributes() { + return attributes; + } +} \ No newline at end of file diff --git a/health-service/src/main/java/com/healthsync/health/dto/Oauth/TokenRefreshRequest.java b/health-service/src/main/java/com/healthsync/health/dto/Oauth/TokenRefreshRequest.java new file mode 100644 index 0000000..420e9f9 --- /dev/null +++ b/health-service/src/main/java/com/healthsync/health/dto/Oauth/TokenRefreshRequest.java @@ -0,0 +1,19 @@ +package com.healthsync.health.dto.Oauth; + +import jakarta.validation.constraints.NotBlank; + +public class TokenRefreshRequest { + @NotBlank(message = "리프레시 토큰은 필수입니다") + private String refreshToken; + + public TokenRefreshRequest() {} + + public TokenRefreshRequest(String refreshToken) { + this.refreshToken = refreshToken; + } + + public String getRefreshToken() { return refreshToken; } + public void setRefreshToken(String refreshToken) { this.refreshToken = refreshToken; } +} + + diff --git a/health-service/src/main/java/com/healthsync/health/dto/Oauth/TokenResponse.java b/health-service/src/main/java/com/healthsync/health/dto/Oauth/TokenResponse.java new file mode 100644 index 0000000..e6456a0 --- /dev/null +++ b/health-service/src/main/java/com/healthsync/health/dto/Oauth/TokenResponse.java @@ -0,0 +1,23 @@ +package com.healthsync.health.dto.Oauth; + +public class TokenResponse { + private String accessToken; + private String refreshToken; + private String tokenType = "Bearer"; + + public TokenResponse() {} + + public TokenResponse(String accessToken, String refreshToken) { + this.accessToken = accessToken; + this.refreshToken = refreshToken; + } + + public String getAccessToken() { return accessToken; } + public void setAccessToken(String accessToken) { this.accessToken = accessToken; } + + public String getRefreshToken() { return refreshToken; } + public void setRefreshToken(String refreshToken) { this.refreshToken = refreshToken; } + + public String getTokenType() { return tokenType; } + public void setTokenType(String tokenType) { this.tokenType = tokenType; } +} \ No newline at end of file diff --git a/health-service/src/main/java/com/healthsync/health/dto/UserProfile/LoginResponse.java b/health-service/src/main/java/com/healthsync/health/dto/UserProfile/LoginResponse.java new file mode 100644 index 0000000..307c676 --- /dev/null +++ b/health-service/src/main/java/com/healthsync/health/dto/UserProfile/LoginResponse.java @@ -0,0 +1,30 @@ +package com.healthsync.health.dto.UserProfile; + +public class LoginResponse { + + private String accessToken; + private String refreshToken; + private UserProfileResponse user; + private boolean isNewUser; + + public LoginResponse() {} + + public LoginResponse(String accessToken, String refreshToken, UserProfileResponse user, boolean isNewUser) { + this.accessToken = accessToken; + this.refreshToken = refreshToken; + this.user = user; + this.isNewUser = isNewUser; + } + + public String getAccessToken() { return accessToken; } + public void setAccessToken(String accessToken) { this.accessToken = accessToken; } + + public String getRefreshToken() { return refreshToken; } + public void setRefreshToken(String refreshToken) { this.refreshToken = refreshToken; } + + public UserProfileResponse getUser() { return user; } + public void setUser(UserProfileResponse user) { this.user = user; } + + public boolean isNewUser() { return isNewUser; } + public void setNewUser(boolean isNewUser) { this.isNewUser = isNewUser; } +} \ No newline at end of file diff --git a/health-service/src/main/java/com/healthsync/health/dto/UserProfile/UserProfileResponse.java b/health-service/src/main/java/com/healthsync/health/dto/UserProfile/UserProfileResponse.java new file mode 100644 index 0000000..0a532cf --- /dev/null +++ b/health-service/src/main/java/com/healthsync/health/dto/UserProfile/UserProfileResponse.java @@ -0,0 +1,50 @@ +package com.healthsync.health.dto.UserProfile; + +import com.healthsync.health.domain.Oauth.User; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +public class UserProfileResponse { + private Long memberSerialNumber; + private String googleId; + private String name; + private LocalDate birthDate; + private String occupation; + private LocalDateTime createdAt; + private LocalDateTime lastLoginAt; + + public UserProfileResponse() {} + + public UserProfileResponse(User user) { + this.memberSerialNumber = user.getMemberSerialNumber(); + this.googleId = user.getGoogleId(); + this.name = user.getName(); + this.birthDate = user.getBirthDate(); + this.occupation = user.getOccupation(); + this.createdAt = user.getCreatedAt(); + this.lastLoginAt = user.getLastLoginAt(); + } + + // Getters and Setters + public Long getMemberSerialNumber() { return memberSerialNumber; } + public void setMemberSerialNumber(Long memberSerialNumber) { this.memberSerialNumber = memberSerialNumber; } + + public String getGoogleId() { return googleId; } + public void setGoogleId(String googleId) { this.googleId = googleId; } + + public String getName() { return name; } + public void setName(String name) { this.name = name; } + + public LocalDate getBirthDate() { return birthDate; } + public void setBirthDate(LocalDate birthDate) { this.birthDate = birthDate; } + + public String getOccupation() { return occupation; } + public void setOccupation(String occupation) { this.occupation = occupation; } + + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } + + public LocalDateTime getLastLoginAt() { return lastLoginAt; } + public void setLastLoginAt(LocalDateTime lastLoginAt) { this.lastLoginAt = lastLoginAt; } +} \ No newline at end of file diff --git a/health-service/src/main/java/com/healthsync/health/dto/UserProfile/UserUpdateRequest.java b/health-service/src/main/java/com/healthsync/health/dto/UserProfile/UserUpdateRequest.java new file mode 100644 index 0000000..ab2d590 --- /dev/null +++ b/health-service/src/main/java/com/healthsync/health/dto/UserProfile/UserUpdateRequest.java @@ -0,0 +1,58 @@ +package com.healthsync.health.dto.UserProfile; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Past; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + +public class UserUpdateRequest { + + @NotBlank(message = "이름은 필수입니다") + private String name; + + @NotBlank(message = "생년월일은 필수입니다") + private String birthDate; + + private String occupation; + + public UserUpdateRequest() { + } + + public UserUpdateRequest(String name, String birthDate, String occupation) { + this.name = name; + this.birthDate = birthDate; + this.occupation = occupation; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public LocalDate getBirthDate() { + if (birthDate == null || birthDate.trim().isEmpty()) { + return null; + } + return LocalDate.parse(birthDate, DateTimeFormatter.ISO_LOCAL_DATE); + } + + public String getBirthDateString() { + return birthDate; + } + + public void setBirthDate(String birthDate) { + this.birthDate = birthDate; + } + + public String getOccupation() { + return occupation; + } + + public void setOccupation(String occupation) { + this.occupation = occupation; + } +} \ No newline at end of file diff --git a/health-service/src/main/java/com/healthsync/health/exception/AuthenticationException.java b/health-service/src/main/java/com/healthsync/health/exception/AuthenticationException.java new file mode 100644 index 0000000..f24b819 --- /dev/null +++ b/health-service/src/main/java/com/healthsync/health/exception/AuthenticationException.java @@ -0,0 +1,13 @@ +package com.healthsync.health.exception; + +import com.healthsync.common.exception.CustomException; + +public class AuthenticationException extends CustomException { + public AuthenticationException(String message) { + super(message); + } + + public AuthenticationException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/health-service/src/main/java/com/healthsync/health/exception/TokenExpiredException.java b/health-service/src/main/java/com/healthsync/health/exception/TokenExpiredException.java new file mode 100644 index 0000000..397288b --- /dev/null +++ b/health-service/src/main/java/com/healthsync/health/exception/TokenExpiredException.java @@ -0,0 +1,13 @@ +package com.healthsync.health.exception; + +import com.healthsync.common.exception.CustomException; + +public class TokenExpiredException extends CustomException { + public TokenExpiredException(String message) { + super(message); + } + + public TokenExpiredException(String message, Throwable cause) { + super(message, cause); + } +} \ No newline at end of file diff --git a/health-service/src/main/java/com/healthsync/health/exception/UserNotFoundException.java b/health-service/src/main/java/com/healthsync/health/exception/UserNotFoundException.java new file mode 100644 index 0000000..6695877 --- /dev/null +++ b/health-service/src/main/java/com/healthsync/health/exception/UserNotFoundException.java @@ -0,0 +1,13 @@ +package com.healthsync.health.exception; + +import com.healthsync.common.exception.CustomException; + +public class UserNotFoundException extends CustomException { + public UserNotFoundException(String message) { + super(message); + } + + public UserNotFoundException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/health-service/src/main/java/com/healthsync/health/repository/entity/HealthCheckupEntity.java b/health-service/src/main/java/com/healthsync/health/repository/entity/HealthCheckupEntity.java new file mode 100644 index 0000000..57f08c1 --- /dev/null +++ b/health-service/src/main/java/com/healthsync/health/repository/entity/HealthCheckupEntity.java @@ -0,0 +1,307 @@ +package com.healthsync.health.repository.entity; + +import com.healthsync.health.domain.HealthCheck.HealthCheckup; +import jakarta.persistence.*; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Entity +@Table(name = "health_checkup", schema = "health_service") +public class HealthCheckupEntity { + + @Id + @Column(name = "checkup_id") + private Long checkupId; // member_serial_number와 동일한 값으로 수동 설정 + + @Column(name = "member_serial_number", nullable = false) + private Long memberSerialNumber; + + @Column(name = "raw_id", nullable = false) + private Long rawId; + + @Column(name = "reference_year", nullable = false) + private Integer referenceYear; + + @Column(name = "age") + private Integer age; + + @Column(name = "height") + private Integer height; + + @Column(name = "weight") + private Integer weight; + + @Column(name = "bmi", precision = 5, scale = 2) + private BigDecimal bmi; + + @Column(name = "waist_circumference") + private Integer waistCircumference; + + @Column(name = "visual_acuity_left", precision = 3, scale = 1) + private BigDecimal visualAcuityLeft; + + @Column(name = "visual_acuity_right", precision = 3, scale = 1) + private BigDecimal visualAcuityRight; + + @Column(name = "hearing_left") + private Integer hearingLeft; + + @Column(name = "hearing_right") + private Integer hearingRight; + + @Column(name = "systolic_bp") + private Integer systolicBp; + + @Column(name = "diastolic_bp") + private Integer diastolicBp; + + @Column(name = "fasting_glucose") + private Integer fastingGlucose; + + @Column(name = "total_cholesterol") + private Integer totalCholesterol; + + @Column(name = "triglyceride") + private Integer triglyceride; + + @Column(name = "hdl_cholesterol") + private Integer hdlCholesterol; + + @Column(name = "ldl_cholesterol") + private Integer ldlCholesterol; + + @Column(name = "hemoglobin", precision = 4, scale = 1) + private BigDecimal hemoglobin; + + @Column(name = "urine_protein") + private Integer urineProtein; + + @Column(name = "serum_creatinine", precision = 4, scale = 1) + private BigDecimal serumCreatinine; + + @Column(name = "ast") + private Integer ast; + + @Column(name = "alt") + private Integer alt; + + @Column(name = "gamma_gtp") + private Integer gammaGtp; + + @Column(name = "smoking_status") + private Integer smokingStatus; + + @Column(name = "drinking_status") + private Integer drinkingStatus; + + @Column(name = "processed_at") + private LocalDateTime processedAt; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + // 기본 생성자 + protected HealthCheckupEntity() {} + + // 생성자 - checkup_id를 member_serial_number와 동일하게 설정 + public HealthCheckupEntity(Long memberSerialNumber, Long rawId, Integer referenceYear) { + this.checkupId = memberSerialNumber; // 핵심: checkup_id = member_serial_number + this.memberSerialNumber = memberSerialNumber; + this.rawId = rawId; + this.referenceYear = referenceYear; + this.processedAt = LocalDateTime.now(); + this.createdAt = LocalDateTime.now(); + } + + // PrePersist로 생성 시간 자동 설정 + @PrePersist + protected void onCreate() { + if (this.createdAt == null) { + this.createdAt = LocalDateTime.now(); + } + if (this.processedAt == null) { + this.processedAt = LocalDateTime.now(); + } + // checkup_id가 설정되지 않았다면 member_serial_number로 설정 + if (this.checkupId == null && this.memberSerialNumber != null) { + this.checkupId = this.memberSerialNumber; + } + } + + // PreUpdate로 업데이트 시간 자동 설정 + @PreUpdate + protected void onUpdate() { + this.processedAt = LocalDateTime.now(); + } + + // Entity ↔ Domain 변환 메서드 + public static HealthCheckupEntity fromDomain(HealthCheckup healthCheckup) { + if (healthCheckup == null) return null; + + HealthCheckupEntity entity = new HealthCheckupEntity(); + + // checkup_id는 항상 member_serial_number와 동일 + entity.checkupId = healthCheckup.getMemberSerialNumber(); + entity.memberSerialNumber = healthCheckup.getMemberSerialNumber(); + entity.rawId = healthCheckup.getRawId(); + entity.referenceYear = healthCheckup.getReferenceYear(); + entity.age = healthCheckup.getAge(); + entity.height = healthCheckup.getHeight(); + entity.weight = healthCheckup.getWeight(); + entity.bmi = healthCheckup.getBmi(); + entity.waistCircumference = healthCheckup.getWaistCircumference(); + entity.visualAcuityLeft = healthCheckup.getVisualAcuityLeft(); + entity.visualAcuityRight = healthCheckup.getVisualAcuityRight(); + entity.hearingLeft = healthCheckup.getHearingLeft(); + entity.hearingRight = healthCheckup.getHearingRight(); + entity.systolicBp = healthCheckup.getSystolicBp(); + entity.diastolicBp = healthCheckup.getDiastolicBp(); + entity.fastingGlucose = healthCheckup.getFastingGlucose(); + entity.totalCholesterol = healthCheckup.getTotalCholesterol(); + entity.triglyceride = healthCheckup.getTriglyceride(); + entity.hdlCholesterol = healthCheckup.getHdlCholesterol(); + entity.ldlCholesterol = healthCheckup.getLdlCholesterol(); + entity.hemoglobin = healthCheckup.getHemoglobin(); + entity.urineProtein = healthCheckup.getUrineProtein(); + entity.serumCreatinine = healthCheckup.getSerumCreatinine(); + entity.ast = healthCheckup.getAst(); + entity.alt = healthCheckup.getAlt(); + entity.gammaGtp = healthCheckup.getGammaGtp(); + entity.smokingStatus = healthCheckup.getSmokingStatus(); + entity.drinkingStatus = healthCheckup.getDrinkingStatus(); + entity.processedAt = healthCheckup.getProcessedAt(); + entity.createdAt = healthCheckup.getCreatedAt(); + + return entity; + } + + public HealthCheckup toDomain() { + HealthCheckup domain = new HealthCheckup(); + domain.setCheckupId(this.checkupId); + domain.setMemberSerialNumber(this.memberSerialNumber); + domain.setRawId(this.rawId); + domain.setReferenceYear(this.referenceYear); + domain.setAge(this.age); + domain.setHeight(this.height); + domain.setWeight(this.weight); + domain.setBmi(this.bmi); + domain.setWaistCircumference(this.waistCircumference); + domain.setVisualAcuityLeft(this.visualAcuityLeft); + domain.setVisualAcuityRight(this.visualAcuityRight); + domain.setHearingLeft(this.hearingLeft); + domain.setHearingRight(this.hearingRight); + domain.setSystolicBp(this.systolicBp); + domain.setDiastolicBp(this.diastolicBp); + domain.setFastingGlucose(this.fastingGlucose); + domain.setTotalCholesterol(this.totalCholesterol); + domain.setTriglyceride(this.triglyceride); + domain.setHdlCholesterol(this.hdlCholesterol); + domain.setLdlCholesterol(this.ldlCholesterol); + domain.setHemoglobin(this.hemoglobin); + domain.setUrineProtein(this.urineProtein); + domain.setSerumCreatinine(this.serumCreatinine); + domain.setAst(this.ast); + domain.setAlt(this.alt); + domain.setGammaGtp(this.gammaGtp); + domain.setSmokingStatus(this.smokingStatus); + domain.setDrinkingStatus(this.drinkingStatus); + domain.setProcessedAt(this.processedAt); + domain.setCreatedAt(this.createdAt); + return domain; + } + + // Getters and Setters + public Long getCheckupId() { return checkupId; } + public void setCheckupId(Long checkupId) { this.checkupId = checkupId; } + + public Long getMemberSerialNumber() { return memberSerialNumber; } + public void setMemberSerialNumber(Long memberSerialNumber) { + this.memberSerialNumber = memberSerialNumber; + // member_serial_number가 변경되면 checkup_id도 동일하게 설정 + this.checkupId = memberSerialNumber; + } + + public Long getRawId() { return rawId; } + public void setRawId(Long rawId) { this.rawId = rawId; } + + public Integer getReferenceYear() { return referenceYear; } + public void setReferenceYear(Integer referenceYear) { this.referenceYear = referenceYear; } + + public Integer getAge() { return age; } + public void setAge(Integer age) { this.age = age; } + + public Integer getHeight() { return height; } + public void setHeight(Integer height) { this.height = height; } + + public Integer getWeight() { return weight; } + public void setWeight(Integer weight) { this.weight = weight; } + + public BigDecimal getBmi() { return bmi; } + public void setBmi(BigDecimal bmi) { this.bmi = bmi; } + + public Integer getWaistCircumference() { return waistCircumference; } + public void setWaistCircumference(Integer waistCircumference) { this.waistCircumference = waistCircumference; } + + public BigDecimal getVisualAcuityLeft() { return visualAcuityLeft; } + public void setVisualAcuityLeft(BigDecimal visualAcuityLeft) { this.visualAcuityLeft = visualAcuityLeft; } + + public BigDecimal getVisualAcuityRight() { return visualAcuityRight; } + public void setVisualAcuityRight(BigDecimal visualAcuityRight) { this.visualAcuityRight = visualAcuityRight; } + + public Integer getHearingLeft() { return hearingLeft; } + public void setHearingLeft(Integer hearingLeft) { this.hearingLeft = hearingLeft; } + + public Integer getHearingRight() { return hearingRight; } + public void setHearingRight(Integer hearingRight) { this.hearingRight = hearingRight; } + + public Integer getSystolicBp() { return systolicBp; } + public void setSystolicBp(Integer systolicBp) { this.systolicBp = systolicBp; } + + public Integer getDiastolicBp() { return diastolicBp; } + public void setDiastolicBp(Integer diastolicBp) { this.diastolicBp = diastolicBp; } + + public Integer getFastingGlucose() { return fastingGlucose; } + public void setFastingGlucose(Integer fastingGlucose) { this.fastingGlucose = fastingGlucose; } + + public Integer getTotalCholesterol() { return totalCholesterol; } + public void setTotalCholesterol(Integer totalCholesterol) { this.totalCholesterol = totalCholesterol; } + + public Integer getTriglyceride() { return triglyceride; } + public void setTriglyceride(Integer triglyceride) { this.triglyceride = triglyceride; } + + public Integer getHdlCholesterol() { return hdlCholesterol; } + public void setHdlCholesterol(Integer hdlCholesterol) { this.hdlCholesterol = hdlCholesterol; } + + public Integer getLdlCholesterol() { return ldlCholesterol; } + public void setLdlCholesterol(Integer ldlCholesterol) { this.ldlCholesterol = ldlCholesterol; } + + public BigDecimal getHemoglobin() { return hemoglobin; } + public void setHemoglobin(BigDecimal hemoglobin) { this.hemoglobin = hemoglobin; } + + public Integer getUrineProtein() { return urineProtein; } + public void setUrineProtein(Integer urineProtein) { this.urineProtein = urineProtein; } + + public BigDecimal getSerumCreatinine() { return serumCreatinine; } + public void setSerumCreatinine(BigDecimal serumCreatinine) { this.serumCreatinine = serumCreatinine; } + + public Integer getAst() { return ast; } + public void setAst(Integer ast) { this.ast = ast; } + + public Integer getAlt() { return alt; } + public void setAlt(Integer alt) { this.alt = alt; } + + public Integer getGammaGtp() { return gammaGtp; } + public void setGammaGtp(Integer gammaGtp) { this.gammaGtp = gammaGtp; } + + public Integer getSmokingStatus() { return smokingStatus; } + public void setSmokingStatus(Integer smokingStatus) { this.smokingStatus = smokingStatus; } + + public Integer getDrinkingStatus() { return drinkingStatus; } + public void setDrinkingStatus(Integer drinkingStatus) { this.drinkingStatus = drinkingStatus; } + + public LocalDateTime getProcessedAt() { return processedAt; } + public void setProcessedAt(LocalDateTime processedAt) { this.processedAt = processedAt; } + + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } +} \ No newline at end of file diff --git a/health-service/src/main/java/com/healthsync/health/repository/entity/HealthCheckupRawEntity.java b/health-service/src/main/java/com/healthsync/health/repository/entity/HealthCheckupRawEntity.java new file mode 100644 index 0000000..6dfbaf1 --- /dev/null +++ b/health-service/src/main/java/com/healthsync/health/repository/entity/HealthCheckupRawEntity.java @@ -0,0 +1,269 @@ +package com.healthsync.health.repository.entity; + +import com.healthsync.health.domain.HealthCheck.HealthCheckupRaw; +import jakarta.persistence.*; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Entity +@Table(name = "health_checkup_raw", schema = "health_service") +public class HealthCheckupRawEntity { + + @Id + @Column(name = "raw_id") + private Long rawId; + + @Column(name = "reference_year", nullable = false) + private Integer referenceYear; + + @Column(name = "birth_date", nullable = false) + private LocalDate birthDate; + + @Column(name = "name", nullable = false, length = 50) + private String name; + + @Column(name = "region_code") + private Integer regionCode; + + @Column(name = "gender_code") + private Integer genderCode; + + @Column(name = "age") + private Integer age; + + @Column(name = "height") + private Integer height; + + @Column(name = "weight") + private Integer weight; + + @Column(name = "waist_circumference") + private Integer waistCircumference; + + @Column(name = "visual_acuity_left", precision = 3, scale = 1) + private BigDecimal visualAcuityLeft; + + @Column(name = "visual_acuity_right", precision = 3, scale = 1) + private BigDecimal visualAcuityRight; + + @Column(name = "hearing_left") + private Integer hearingLeft; + + @Column(name = "hearing_right") + private Integer hearingRight; + + @Column(name = "systolic_bp") + private Integer systolicBp; + + @Column(name = "diastolic_bp") + private Integer diastolicBp; + + @Column(name = "fasting_glucose") + private Integer fastingGlucose; + + @Column(name = "total_cholesterol") + private Integer totalCholesterol; + + @Column(name = "triglyceride") + private Integer triglyceride; + + @Column(name = "hdl_cholesterol") + private Integer hdlCholesterol; + + @Column(name = "ldl_cholesterol") + private Integer ldlCholesterol; + + @Column(name = "hemoglobin", precision = 4, scale = 1) + private BigDecimal hemoglobin; + + @Column(name = "urine_protein") + private Integer urineProtein; + + @Column(name = "serum_creatinine", precision = 4, scale = 1) + private BigDecimal serumCreatinine; + + @Column(name = "ast") + private Integer ast; + + @Column(name = "alt") + private Integer alt; + + @Column(name = "gamma_gtp") + private Integer gammaGtp; + + @Column(name = "smoking_status") + private Integer smokingStatus; + + @Column(name = "drinking_status") + private Integer drinkingStatus; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + protected HealthCheckupRawEntity() {} + + // Getters and Setters + public Long getRawId() { return rawId; } + public void setRawId(Long rawId) { this.rawId = rawId; } + + public Integer getReferenceYear() { return referenceYear; } + public void setReferenceYear(Integer referenceYear) { this.referenceYear = referenceYear; } + + public LocalDate getBirthDate() { return birthDate; } + public void setBirthDate(LocalDate birthDate) { this.birthDate = birthDate; } + + public String getName() { return name; } + public void setName(String name) { this.name = name; } + + public Integer getRegionCode() { return regionCode; } + public void setRegionCode(Integer regionCode) { this.regionCode = regionCode; } + + public Integer getGenderCode() { return genderCode; } + public void setGenderCode(Integer genderCode) { this.genderCode = genderCode; } + + public Integer getAge() { return age; } + public void setAge(Integer age) { this.age = age; } + + public Integer getHeight() { return height; } + public void setHeight(Integer height) { this.height = height; } + + public Integer getWeight() { return weight; } + public void setWeight(Integer weight) { this.weight = weight; } + + public Integer getWaistCircumference() { return waistCircumference; } + public void setWaistCircumference(Integer waistCircumference) { this.waistCircumference = waistCircumference; } + + public BigDecimal getVisualAcuityLeft() { return visualAcuityLeft; } + public void setVisualAcuityLeft(BigDecimal visualAcuityLeft) { this.visualAcuityLeft = visualAcuityLeft; } + + public BigDecimal getVisualAcuityRight() { return visualAcuityRight; } + public void setVisualAcuityRight(BigDecimal visualAcuityRight) { this.visualAcuityRight = visualAcuityRight; } + + public Integer getHearingLeft() { return hearingLeft; } + public void setHearingLeft(Integer hearingLeft) { this.hearingLeft = hearingLeft; } + + public Integer getHearingRight() { return hearingRight; } + public void setHearingRight(Integer hearingRight) { this.hearingRight = hearingRight; } + + public Integer getSystolicBp() { return systolicBp; } + public void setSystolicBp(Integer systolicBp) { this.systolicBp = systolicBp; } + + public Integer getDiastolicBp() { return diastolicBp; } + public void setDiastolicBp(Integer diastolicBp) { this.diastolicBp = diastolicBp; } + + public Integer getFastingGlucose() { return fastingGlucose; } + public void setFastingGlucose(Integer fastingGlucose) { this.fastingGlucose = fastingGlucose; } + + public Integer getTotalCholesterol() { return totalCholesterol; } + public void setTotalCholesterol(Integer totalCholesterol) { this.totalCholesterol = totalCholesterol; } + + public Integer getTriglyceride() { return triglyceride; } + public void setTriglyceride(Integer triglyceride) { this.triglyceride = triglyceride; } + + public Integer getHdlCholesterol() { return hdlCholesterol; } + public void setHdlCholesterol(Integer hdlCholesterol) { this.hdlCholesterol = hdlCholesterol; } + + public Integer getLdlCholesterol() { return ldlCholesterol; } + public void setLdlCholesterol(Integer ldlCholesterol) { this.ldlCholesterol = ldlCholesterol; } + + public BigDecimal getHemoglobin() { return hemoglobin; } + public void setHemoglobin(BigDecimal hemoglobin) { this.hemoglobin = hemoglobin; } + + public Integer getUrineProtein() { return urineProtein; } + public void setUrineProtein(Integer urineProtein) { this.urineProtein = urineProtein; } + + public BigDecimal getSerumCreatinine() { return serumCreatinine; } + public void setSerumCreatinine(BigDecimal serumCreatinine) { this.serumCreatinine = serumCreatinine; } + + public Integer getAst() { return ast; } + public void setAst(Integer ast) { this.ast = ast; } + + public Integer getAlt() { return alt; } + public void setAlt(Integer alt) { this.alt = alt; } + + public Integer getGammaGtp() { return gammaGtp; } + public void setGammaGtp(Integer gammaGtp) { this.gammaGtp = gammaGtp; } + + public Integer getSmokingStatus() { return smokingStatus; } + public void setSmokingStatus(Integer smokingStatus) { this.smokingStatus = smokingStatus; } + + public Integer getDrinkingStatus() { return drinkingStatus; } + public void setDrinkingStatus(Integer drinkingStatus) { this.drinkingStatus = drinkingStatus; } + + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } + + // Entity ↔ Domain 변환 메서드 + public static HealthCheckupRawEntity fromDomain(HealthCheckupRaw healthCheckupRaw) { + if (healthCheckupRaw == null) return null; + + HealthCheckupRawEntity entity = new HealthCheckupRawEntity(); + entity.rawId = healthCheckupRaw.getRawId(); + entity.referenceYear = healthCheckupRaw.getReferenceYear(); + entity.birthDate = healthCheckupRaw.getBirthDate(); + entity.name = healthCheckupRaw.getName(); + entity.regionCode = healthCheckupRaw.getRegionCode(); + entity.genderCode = healthCheckupRaw.getGenderCode(); + entity.age = healthCheckupRaw.getAge(); + entity.height = healthCheckupRaw.getHeight(); + entity.weight = healthCheckupRaw.getWeight(); + entity.waistCircumference = healthCheckupRaw.getWaistCircumference(); + entity.visualAcuityLeft = healthCheckupRaw.getVisualAcuityLeft(); + entity.visualAcuityRight = healthCheckupRaw.getVisualAcuityRight(); + entity.hearingLeft = healthCheckupRaw.getHearingLeft(); + entity.hearingRight = healthCheckupRaw.getHearingRight(); + entity.systolicBp = healthCheckupRaw.getSystolicBp(); + entity.diastolicBp = healthCheckupRaw.getDiastolicBp(); + entity.fastingGlucose = healthCheckupRaw.getFastingGlucose(); + entity.totalCholesterol = healthCheckupRaw.getTotalCholesterol(); + entity.triglyceride = healthCheckupRaw.getTriglyceride(); + entity.hdlCholesterol = healthCheckupRaw.getHdlCholesterol(); + entity.ldlCholesterol = healthCheckupRaw.getLdlCholesterol(); + entity.hemoglobin = healthCheckupRaw.getHemoglobin(); + entity.urineProtein = healthCheckupRaw.getUrineProtein(); + entity.serumCreatinine = healthCheckupRaw.getSerumCreatinine(); + entity.ast = healthCheckupRaw.getAst(); + entity.alt = healthCheckupRaw.getAlt(); + entity.gammaGtp = healthCheckupRaw.getGammaGtp(); + entity.smokingStatus = healthCheckupRaw.getSmokingStatus(); + entity.drinkingStatus = healthCheckupRaw.getDrinkingStatus(); + entity.createdAt = healthCheckupRaw.getCreatedAt(); + return entity; + } + + public HealthCheckupRaw toDomain() { + HealthCheckupRaw domain = new HealthCheckupRaw(); + domain.setRawId(this.rawId); + domain.setReferenceYear(this.referenceYear); + domain.setBirthDate(this.birthDate); + domain.setName(this.name); + domain.setRegionCode(this.regionCode); + domain.setGenderCode(this.genderCode); + domain.setAge(this.age); + domain.setHeight(this.height); + domain.setWeight(this.weight); + domain.setWaistCircumference(this.waistCircumference); + domain.setVisualAcuityLeft(this.visualAcuityLeft); + domain.setVisualAcuityRight(this.visualAcuityRight); + domain.setHearingLeft(this.hearingLeft); + domain.setHearingRight(this.hearingRight); + domain.setSystolicBp(this.systolicBp); + domain.setDiastolicBp(this.diastolicBp); + domain.setFastingGlucose(this.fastingGlucose); + domain.setTotalCholesterol(this.totalCholesterol); + domain.setTriglyceride(this.triglyceride); + domain.setHdlCholesterol(this.hdlCholesterol); + domain.setLdlCholesterol(this.ldlCholesterol); + domain.setHemoglobin(this.hemoglobin); + domain.setUrineProtein(this.urineProtein); + domain.setSerumCreatinine(this.serumCreatinine); + domain.setAst(this.ast); + domain.setAlt(this.alt); + domain.setGammaGtp(this.gammaGtp); + domain.setSmokingStatus(this.smokingStatus); + domain.setDrinkingStatus(this.drinkingStatus); + domain.setCreatedAt(this.createdAt); + return domain; + } +} \ No newline at end of file diff --git a/health-service/src/main/java/com/healthsync/health/repository/entity/HealthNormalRangeEntity.java b/health-service/src/main/java/com/healthsync/health/repository/entity/HealthNormalRangeEntity.java new file mode 100644 index 0000000..004e0a3 --- /dev/null +++ b/health-service/src/main/java/com/healthsync/health/repository/entity/HealthNormalRangeEntity.java @@ -0,0 +1,107 @@ +package com.healthsync.health.repository.entity; + +import com.healthsync.health.domain.HealthCheck.HealthNormalRange; +import jakarta.persistence.*; +import java.time.LocalDateTime; + +@Entity +@Table(name = "health_normal_range", schema = "health_service") +public class HealthNormalRangeEntity { + + @Id + @Column(name = "range_id") + private Integer rangeId; + + @Column(name = "health_item_code", length = 25) + private String healthItemCode; + + @Column(name = "health_item_name", length = 30) + private String healthItemName; + + @Column(name = "gender_code") + private Integer genderCode; + + @Column(name = "unit", length = 10) + private String unit; + + @Column(name = "normal_range", length = 15) + private String normalRange; + + @Column(name = "warning_range", length = 15) + private String warningRange; + + @Column(name = "danger_range", length = 15) + private String dangerRange; + + @Column(name = "note", length = 50) + private String note; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + protected HealthNormalRangeEntity() {} + + // Getters and Setters + public Integer getRangeId() { return rangeId; } + public void setRangeId(Integer rangeId) { this.rangeId = rangeId; } + + public String getHealthItemCode() { return healthItemCode; } + public void setHealthItemCode(String healthItemCode) { this.healthItemCode = healthItemCode; } + + public String getHealthItemName() { return healthItemName; } + public void setHealthItemName(String healthItemName) { this.healthItemName = healthItemName; } + + public Integer getGenderCode() { return genderCode; } + public void setGenderCode(Integer genderCode) { this.genderCode = genderCode; } + + public String getUnit() { return unit; } + public void setUnit(String unit) { this.unit = unit; } + + public String getNormalRange() { return normalRange; } + public void setNormalRange(String normalRange) { this.normalRange = normalRange; } + + public String getWarningRange() { return warningRange; } + public void setWarningRange(String warningRange) { this.warningRange = warningRange; } + + public String getDangerRange() { return dangerRange; } + public void setDangerRange(String dangerRange) { this.dangerRange = dangerRange; } + + public String getNote() { return note; } + public void setNote(String note) { this.note = note; } + + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } + + // Entity ↔ Domain 변환 메서드 + public static HealthNormalRangeEntity fromDomain(HealthNormalRange healthNormalRange) { + if (healthNormalRange == null) return null; + + HealthNormalRangeEntity entity = new HealthNormalRangeEntity(); + entity.rangeId = healthNormalRange.getRangeId(); + entity.healthItemCode = healthNormalRange.getHealthItemCode(); + entity.healthItemName = healthNormalRange.getHealthItemName(); + entity.genderCode = healthNormalRange.getGenderCode(); + entity.unit = healthNormalRange.getUnit(); + entity.normalRange = healthNormalRange.getNormalRange(); + entity.warningRange = healthNormalRange.getWarningRange(); + entity.dangerRange = healthNormalRange.getDangerRange(); + entity.note = healthNormalRange.getNote(); + entity.createdAt = healthNormalRange.getCreatedAt(); + return entity; + } + + public HealthNormalRange toDomain() { + HealthNormalRange domain = new HealthNormalRange(); + domain.setRangeId(this.rangeId); + domain.setHealthItemCode(this.healthItemCode); + domain.setHealthItemName(this.healthItemName); + domain.setGenderCode(this.genderCode); + domain.setUnit(this.unit); + domain.setNormalRange(this.normalRange); + domain.setWarningRange(this.warningRange); + domain.setDangerRange(this.dangerRange); + domain.setNote(this.note); + domain.setCreatedAt(this.createdAt); + return domain; + } +} \ No newline at end of file diff --git a/health-service/src/main/java/com/healthsync/health/repository/entity/OccupationTypeEntity.java b/health-service/src/main/java/com/healthsync/health/repository/entity/OccupationTypeEntity.java new file mode 100644 index 0000000..633a788 --- /dev/null +++ b/health-service/src/main/java/com/healthsync/health/repository/entity/OccupationTypeEntity.java @@ -0,0 +1,55 @@ +package com.healthsync.health.repository.entity; + +import jakarta.persistence.*; + +/** + * 직업 유형 정보를 담는 엔티티 (health-service용) + * user_service.occupation_type 테이블과 매핑 + */ +@Entity +@Table(name = "occupation_type", schema = "user_service") +public class OccupationTypeEntity { + + @Id + @Column(name = "occupation_code", length = 20) + private String occupationCode; + + @Column(name = "occupation_name", length = 100, nullable = false) + private String occupationName; + + @Column(name = "category", length = 50) + private String category; + + protected OccupationTypeEntity() {} + + public OccupationTypeEntity(String occupationCode, String occupationName, String category) { + this.occupationCode = occupationCode; + this.occupationName = occupationName; + this.category = category; + } + + // Getters and Setters + public String getOccupationCode() { + return occupationCode; + } + + public void setOccupationCode(String occupationCode) { + this.occupationCode = occupationCode; + } + + public String getOccupationName() { + return occupationName; + } + + public void setOccupationName(String occupationName) { + this.occupationName = occupationName; + } + + public String getCategory() { + return category; + } + + public void setCategory(String category) { + this.category = category; + } +} \ No newline at end of file diff --git a/health-service/src/main/java/com/healthsync/health/repository/entity/RefreshTokenEntity.java b/health-service/src/main/java/com/healthsync/health/repository/entity/RefreshTokenEntity.java new file mode 100644 index 0000000..eaaa685 --- /dev/null +++ b/health-service/src/main/java/com/healthsync/health/repository/entity/RefreshTokenEntity.java @@ -0,0 +1,75 @@ +package com.healthsync.health.repository.entity; + +import com.healthsync.health.domain.Oauth.RefreshToken; +import jakarta.persistence.*; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "refresh_tokens", schema = "user_service") +@EntityListeners(AuditingEntityListener.class) +public class RefreshTokenEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(unique = true, nullable = false, length = 500) + private String token; + + @Column(name = "member_serial_number", nullable = false) + private Long memberSerialNumber; + + @Column(name = "expiry_date", nullable = false) + private LocalDateTime expiryDate; + + @CreatedDate + @Column(name = "created_at", updatable = false) + private LocalDateTime createdAt; + + protected RefreshTokenEntity() {} + + public RefreshTokenEntity(String token, Long memberSerialNumber, LocalDateTime expiryDate) { + this.token = token; + this.memberSerialNumber = memberSerialNumber; + this.expiryDate = expiryDate; + } + + public static RefreshTokenEntity fromDomain(RefreshToken refreshToken) { + RefreshTokenEntity entity = new RefreshTokenEntity(); + entity.id = refreshToken.getId(); + entity.token = refreshToken.getToken(); + entity.memberSerialNumber = refreshToken.getMemberSerialNumber(); + entity.expiryDate = refreshToken.getExpiryDate(); + entity.createdAt = refreshToken.getCreatedAt(); + return entity; + } + + public RefreshToken toDomain() { + RefreshToken refreshToken = new RefreshToken(); + refreshToken.setId(this.id); + refreshToken.setToken(this.token); + refreshToken.setMemberSerialNumber(this.memberSerialNumber); + refreshToken.setExpiryDate(this.expiryDate); + refreshToken.setCreatedAt(this.createdAt); + return refreshToken; + } + + // Getters and Setters + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + + public String getToken() { return token; } + public void setToken(String token) { this.token = token; } + + public Long getMemberSerialNumber() { return memberSerialNumber; } + public void setMemberSerialNumber(Long memberSerialNumber) { this.memberSerialNumber = memberSerialNumber; } + + public LocalDateTime getExpiryDate() { return expiryDate; } + public void setExpiryDate(LocalDateTime expiryDate) { this.expiryDate = expiryDate; } + + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } +} diff --git a/health-service/src/main/java/com/healthsync/health/repository/entity/UserEntity.java b/health-service/src/main/java/com/healthsync/health/repository/entity/UserEntity.java new file mode 100644 index 0000000..5cc8135 --- /dev/null +++ b/health-service/src/main/java/com/healthsync/health/repository/entity/UserEntity.java @@ -0,0 +1,113 @@ +package com.healthsync.health.repository.entity; + +import com.healthsync.health.domain.Oauth.User; +import jakarta.persistence.*; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * 사용자 정보를 담는 엔티티 (health-service용) + * user_service.user 테이블과 매핑 + */ +@Entity +@Table(name = "user", schema = "user_service") +public class UserEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "member_serial_number") + private Long memberSerialNumber; + + @Column(name = "google_id", length = 255, unique = true, nullable = false) + private String googleId; + + @Column(name = "name", length = 100, nullable = false) + private String name; + + @Column(name = "birth_date", nullable = false) + private LocalDate birthDate; + + @Column(name = "occupation", length = 50) + private String occupation; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + @Column(name = "last_login_at") + private LocalDateTime lastLoginAt; + + protected UserEntity() {} + + public UserEntity(String googleId, String name, LocalDate birthDate, String occupation) { + this.googleId = googleId; + this.name = name; + this.birthDate = birthDate; + this.occupation = occupation; + this.createdAt = LocalDateTime.now(); + this.updatedAt = LocalDateTime.now(); + this.lastLoginAt = LocalDateTime.now(); + } + + public static UserEntity fromDomain(User user) { + UserEntity entity = new UserEntity(); + // 핵심 수정: 새 엔티티인 경우 ID를 설정하지 않음 (BIGSERIAL이 자동 생성) + if (user.getMemberSerialNumber() != null) { + entity.memberSerialNumber = user.getMemberSerialNumber(); + } + entity.googleId = user.getGoogleId(); + entity.name = user.getName(); + entity.birthDate = user.getBirthDate(); + entity.occupation = user.getOccupation(); + entity.createdAt = user.getCreatedAt(); + entity.updatedAt = user.getUpdatedAt(); + entity.lastLoginAt = user.getLastLoginAt(); + return entity; + } + + public User toDomain() { + User user = new User(); + user.setMemberSerialNumber(this.memberSerialNumber); + user.setGoogleId(this.googleId); + user.setName(this.name); + user.setBirthDate(this.birthDate); + user.setOccupation(this.occupation); + user.setCreatedAt(this.createdAt); + user.setUpdatedAt(this.updatedAt); + user.setLastLoginAt(this.lastLoginAt); + return user; + } + + public void updateLastLoginAt() { + this.lastLoginAt = LocalDateTime.now(); + this.updatedAt = LocalDateTime.now(); + } + + // Getters and Setters + public Long getMemberSerialNumber() { return memberSerialNumber; } + public void setMemberSerialNumber(Long memberSerialNumber) { this.memberSerialNumber = memberSerialNumber; } + + public String getGoogleId() { return googleId; } + public void setGoogleId(String googleId) { this.googleId = googleId; } + + public String getName() { return name; } + public void setName(String name) { this.name = name; } + + public LocalDate getBirthDate() { return birthDate; } + public void setBirthDate(LocalDate birthDate) { this.birthDate = birthDate; } + + public String getOccupation() { return occupation; } + public void setOccupation(String occupation) { this.occupation = occupation; } + + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } + + public LocalDateTime getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; } + + public LocalDateTime getLastLoginAt() { return lastLoginAt; } + public void setLastLoginAt(LocalDateTime lastLoginAt) { this.lastLoginAt = lastLoginAt; } +} \ No newline at end of file diff --git a/health-service/src/main/java/com/healthsync/health/repository/jpa/HealthCheckupRawRepository.java b/health-service/src/main/java/com/healthsync/health/repository/jpa/HealthCheckupRawRepository.java new file mode 100644 index 0000000..e89d6c5 --- /dev/null +++ b/health-service/src/main/java/com/healthsync/health/repository/jpa/HealthCheckupRawRepository.java @@ -0,0 +1,134 @@ +package com.healthsync.health.repository.jpa; + +import com.healthsync.health.repository.entity.HealthCheckupRawEntity; +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; + +@Repository +public interface HealthCheckupRawRepository extends JpaRepository { + + // 이름과 생년월일로 최근 건강검진 데이터 조회 (가장 최근 연도) + @Query("SELECT h FROM HealthCheckupRawEntity h " + + "WHERE h.name = :name AND h.birthDate = :birthDate " + + "ORDER BY h.referenceYear DESC, h.createdAt DESC") + List findByNameAndBirthDateOrderByReferenceYearDesc( + @Param("name") String name, + @Param("birthDate") LocalDate birthDate); + + // 이름과 생년월일로 최근 건강검진 데이터 1개만 조회 (첫 번째 결과만) + @Query(value = "SELECT * FROM health_service.health_checkup_raw h " + + "WHERE h.name = :name AND h.birth_date = :birthDate " + + "ORDER BY h.reference_year DESC, h.created_at DESC " + + "LIMIT 1", nativeQuery = true) + Optional findMostRecentByNameAndBirthDate( + @Param("name") String name, + @Param("birthDate") LocalDate birthDate); + + // 이름과 생년월일로 최근 5개 건강검진 데이터 조회 - **주요 메서드** + @Query(value = "SELECT * FROM health_service.health_checkup_raw h " + + "WHERE h.name = :name AND h.birth_date = :birthDate " + + "ORDER BY h.reference_year DESC, h.created_at DESC " + + "LIMIT 5", nativeQuery = true) + List findTop5ByNameAndBirthDateOrderByReferenceYearDescCreatedAtDesc( + @Param("name") String name, + @Param("birthDate") LocalDate birthDate); + + // 특정 연도의 건강검진 데이터 조회 + @Query("SELECT h FROM HealthCheckupRawEntity h " + + "WHERE h.name = :name AND h.birthDate = :birthDate AND h.referenceYear = :year " + + "ORDER BY h.createdAt DESC") + List findByNameAndBirthDateAndYear( + @Param("name") String name, + @Param("birthDate") LocalDate birthDate, + @Param("year") Integer year); + + // 이름과 생년월일로 모든 건강검진 이력 조회 + @Query("SELECT h FROM HealthCheckupRawEntity h " + + "WHERE h.name = :name AND h.birthDate = :birthDate " + + "ORDER BY h.referenceYear DESC, h.createdAt DESC") + List findAllByNameAndBirthDate( + @Param("name") String name, + @Param("birthDate") LocalDate birthDate); + + // 특정 Raw ID로 조회 + Optional findByRawId(Long rawId); + + // 특정 Raw ID 목록으로 조회 + @Query("SELECT h FROM HealthCheckupRawEntity h WHERE h.rawId IN :rawIds") + List findByRawIdIn(@Param("rawIds") List rawIds); + + // 특정 연도 범위의 데이터 조회 + @Query("SELECT h FROM HealthCheckupRawEntity h " + + "WHERE h.name = :name AND h.birthDate = :birthDate " + + "AND h.referenceYear BETWEEN :startYear AND :endYear " + + "ORDER BY h.referenceYear DESC, h.createdAt DESC") + List findByNameAndBirthDateAndReferenceYearBetween( + @Param("name") String name, + @Param("birthDate") LocalDate birthDate, + @Param("startYear") Integer startYear, + @Param("endYear") Integer endYear); + + // 이름으로만 조회 (생년월일이 다른 동명이인 포함) + List findByNameOrderByReferenceYearDescCreatedAtDesc(String name); + + // 특정 성별의 데이터 조회 + @Query("SELECT h FROM HealthCheckupRawEntity h " + + "WHERE h.name = :name AND h.birthDate = :birthDate AND h.genderCode = :genderCode " + + "ORDER BY h.referenceYear DESC, h.createdAt DESC") + List findByNameAndBirthDateAndGenderCode( + @Param("name") String name, + @Param("birthDate") LocalDate birthDate, + @Param("genderCode") Integer genderCode); + + // 특정 지역의 데이터 조회 + @Query("SELECT h FROM HealthCheckupRawEntity h " + + "WHERE h.regionCode = :regionCode " + + "ORDER BY h.referenceYear DESC, h.createdAt DESC") + List findByRegionCodeOrderByReferenceYearDesc(@Param("regionCode") Integer regionCode); + + // 특정 연령대의 데이터 조회 + @Query("SELECT h FROM HealthCheckupRawEntity h " + + "WHERE h.age BETWEEN :minAge AND :maxAge " + + "ORDER BY h.referenceYear DESC, h.createdAt DESC") + List findByAgeBetweenOrderByReferenceYearDesc( + @Param("minAge") Integer minAge, + @Param("maxAge") Integer maxAge); + + // 특정 사용자의 연도별 데이터 개수 + @Query("SELECT h.referenceYear, COUNT(h) FROM HealthCheckupRawEntity h " + + "WHERE h.name = :name AND h.birthDate = :birthDate " + + "GROUP BY h.referenceYear " + + "ORDER BY h.referenceYear DESC") + List countByYearForUser(@Param("name") String name, @Param("birthDate") LocalDate birthDate); + + // 특정 사용자의 최신 검진 연도 + @Query("SELECT MAX(h.referenceYear) FROM HealthCheckupRawEntity h " + + "WHERE h.name = :name AND h.birthDate = :birthDate") + Optional findLatestYearByUser(@Param("name") String name, @Param("birthDate") LocalDate birthDate); + + // 특정 사용자의 가장 오래된 검진 연도 + @Query("SELECT MIN(h.referenceYear) FROM HealthCheckupRawEntity h " + + "WHERE h.name = :name AND h.birthDate = :birthDate") + Optional findOldestYearByUser(@Param("name") String name, @Param("birthDate") LocalDate birthDate); + + @Query("SELECT COUNT(h) FROM HealthCheckupRawEntity h WHERE h.referenceYear = :year") + long countByReferenceYear(@Param("year") Integer year); + + // 데이터 품질 체크 - 필수 필드가 null인 데이터 + @Query("SELECT h FROM HealthCheckupRawEntity h " + + "WHERE h.name IS NULL OR h.birthDate IS NULL OR h.referenceYear IS NULL") + List findIncompleteData(); + + // 중복 데이터 체크 - 같은 사용자, 같은 연도에 여러 레코드 + @Query("SELECT h.name, h.birthDate, h.referenceYear, COUNT(h) " + + "FROM HealthCheckupRawEntity h " + + "GROUP BY h.name, h.birthDate, h.referenceYear " + + "HAVING COUNT(h) > 1") + List findDuplicateRecords(); +} \ No newline at end of file diff --git a/health-service/src/main/java/com/healthsync/health/repository/jpa/HealthCheckupRepository.java b/health-service/src/main/java/com/healthsync/health/repository/jpa/HealthCheckupRepository.java new file mode 100644 index 0000000..03ffa5f --- /dev/null +++ b/health-service/src/main/java/com/healthsync/health/repository/jpa/HealthCheckupRepository.java @@ -0,0 +1,131 @@ +package com.healthsync.health.repository.jpa; + +import com.healthsync.health.repository.entity.HealthCheckupEntity; +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.util.List; +import java.util.Optional; + +@Repository +public interface HealthCheckupRepository extends JpaRepository { + + // 특정 회원의 건강검진 데이터 조회 (1개만 존재) + // checkup_id = member_serial_number이므로 findById로도 조회 가능 + Optional findByMemberSerialNumber(Long memberSerialNumber); + + // checkup_id로 조회 (member_serial_number와 동일) + Optional findByCheckupId(Long checkupId); + + // 회원 존재 여부 확인 + boolean existsByMemberSerialNumber(Long memberSerialNumber); + + // checkup_id 존재 여부 확인 (member_serial_number와 동일) + boolean existsByCheckupId(Long checkupId); + + // 특정 Raw ID로 이미 처리된 데이터 확인 - **중복 방지 핵심** + boolean existsByRawId(Long rawId); + + // 특정 Raw ID로 가공된 데이터 조회 + Optional findByRawId(Long rawId); + + // 여러 회원들의 건강검진 데이터 조회 + @Query("SELECT h FROM HealthCheckupEntity h WHERE h.memberSerialNumber IN :memberSerialNumbers") + List findByMemberSerialNumberIn(@Param("memberSerialNumbers") List memberSerialNumbers); + + // 특정 연도의 건강검진 데이터를 가진 회원들 조회 + List findByReferenceYear(Integer referenceYear); + + // 특정 연도 이후의 건강검진 데이터를 가진 회원들 조회 + @Query("SELECT h FROM HealthCheckupEntity h WHERE h.referenceYear >= :fromYear") + List findByReferenceYearGreaterThanEqual(@Param("fromYear") Integer fromYear); + + // 특정 연도 범위의 건강검진 데이터를 가진 회원들 조회 + @Query("SELECT h FROM HealthCheckupEntity h WHERE h.referenceYear BETWEEN :startYear AND :endYear") + List findByReferenceYearBetween(@Param("startYear") Integer startYear, @Param("endYear") Integer endYear); + + // 최근 처리된 순으로 N개 조회 (관리자용) + @Query("SELECT h FROM HealthCheckupEntity h ORDER BY h.processedAt DESC") + List findAllOrderByProcessedAtDesc(); + + // 특정 기간에 처리된 데이터 조회 + @Query("SELECT h FROM HealthCheckupEntity h WHERE h.processedAt BETWEEN :startDate AND :endDate ORDER BY h.processedAt DESC") + List findByProcessedAtBetween(@Param("startDate") java.time.LocalDateTime startDate, + @Param("endDate") java.time.LocalDateTime endDate); + + // 전체 건강검진 데이터 개수 + long count(); + + // 특정 연도의 건강검진 데이터 개수 + long countByReferenceYear(Integer referenceYear); + + // Raw ID 목록으로 가공된 데이터들 조회 + @Query("SELECT h FROM HealthCheckupEntity h WHERE h.rawId IN :rawIds") + List findByRawIdIn(@Param("rawIds") List rawIds); + + // 최신 건강검진 연도 조회 (전체) + @Query("SELECT MAX(h.referenceYear) FROM HealthCheckupEntity h") + Optional findMaxReferenceYear(); + + // 가장 오래된 건강검진 연도 조회 (전체) + @Query("SELECT MIN(h.referenceYear) FROM HealthCheckupEntity h") + Optional findMinReferenceYear(); + + // 특정 Raw ID가 이미 다른 회원에게 할당되었는지 확인 (데이터 무결성) + @Query("SELECT h.memberSerialNumber FROM HealthCheckupEntity h WHERE h.rawId = :rawId") + Optional findMemberSerialNumberByRawId(@Param("rawId") Long rawId); + + // BMI 범위별 회원 조회 + @Query("SELECT h FROM HealthCheckupEntity h WHERE h.bmi BETWEEN :minBmi AND :maxBmi") + List findByBmiBetween(@Param("minBmi") java.math.BigDecimal minBmi, + @Param("maxBmi") java.math.BigDecimal maxBmi); + + // 혈압 범위별 회원 조회 + @Query("SELECT h FROM HealthCheckupEntity h WHERE h.systolicBp >= :minSystolic OR h.diastolicBp >= :minDiastolic") + List findByHighBloodPressure(@Param("minSystolic") Integer minSystolic, + @Param("minDiastolic") Integer minDiastolic); + + // 혈당 범위별 회원 조회 + @Query("SELECT h FROM HealthCheckupEntity h WHERE h.fastingGlucose >= :minGlucose") + List findByHighGlucose(@Param("minGlucose") Integer minGlucose); + + // 처리되지 않은 Raw 데이터 개수 확인 (전체 통계) + @Query(value = "SELECT COUNT(*) FROM health_service.health_checkup_raw r " + + "WHERE NOT EXISTS (SELECT 1 FROM health_service.health_checkup h WHERE h.raw_id = r.raw_id)", + nativeQuery = true) + long countUnprocessedRawData(); + + // 특정 회원의 처리되지 않은 Raw 데이터 개수 + @Query(value = "SELECT COUNT(*) FROM health_service.health_checkup_raw r " + + "WHERE r.name = :name AND r.birth_date = :birthDate " + + "AND NOT EXISTS (SELECT 1 FROM health_service.health_checkup h WHERE h.raw_id = r.raw_id)", + nativeQuery = true) + long countUnprocessedRawDataByUser(@Param("name") String name, @Param("birthDate") java.time.LocalDate birthDate); + + // 데이터 정합성 체크 - checkup_id와 member_serial_number가 다른 레코드 + @Query("SELECT h FROM HealthCheckupEntity h WHERE h.checkupId != h.memberSerialNumber") + List findInconsistentData(); + + // 연령대별 통계 + @Query("SELECT " + + "CASE " + + " WHEN h.age < 30 THEN '20대' " + + " WHEN h.age < 40 THEN '30대' " + + " WHEN h.age < 50 THEN '40대' " + + " WHEN h.age < 60 THEN '50대' " + + " ELSE '60대 이상' " + + "END as ageGroup, " + + "COUNT(h) as count " + + "FROM HealthCheckupEntity h " + + "GROUP BY " + + "CASE " + + " WHEN h.age < 30 THEN '20대' " + + " WHEN h.age < 40 THEN '30대' " + + " WHEN h.age < 50 THEN '40대' " + + " WHEN h.age < 60 THEN '50대' " + + " ELSE '60대 이상' " + + "END") + List getAgeGroupStatistics(); +} \ No newline at end of file diff --git a/health-service/src/main/java/com/healthsync/health/repository/jpa/HealthNormalRangeRepository.java b/health-service/src/main/java/com/healthsync/health/repository/jpa/HealthNormalRangeRepository.java new file mode 100644 index 0000000..88876a0 --- /dev/null +++ b/health-service/src/main/java/com/healthsync/health/repository/jpa/HealthNormalRangeRepository.java @@ -0,0 +1,40 @@ +package com.healthsync.health.repository.jpa; + +import com.healthsync.health.repository.entity.HealthNormalRangeEntity; +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.util.List; +import java.util.Optional; + +@Repository +public interface HealthNormalRangeRepository extends JpaRepository { + + // 모든 정상 범위 데이터 조회 + List findAll(); + + // 특정 건강 항목 코드로 조회 + Optional findByHealthItemCode(String healthItemCode); + + // 성별 코드로 필터링하여 조회 + List findByGenderCode(Integer genderCode); + + // 특정 건강 항목 코드와 성별 코드로 조회 + @Query("SELECT h FROM HealthNormalRangeEntity h " + + "WHERE h.healthItemCode = :healthItemCode " + + "AND (h.genderCode = :genderCode OR h.genderCode IS NULL)") + Optional findByHealthItemCodeAndGenderCode( + @Param("healthItemCode") String healthItemCode, + @Param("genderCode") Integer genderCode); + + // 건강 항목명으로 조회 + List findByHealthItemName(String healthItemName); + + // 성별에 맞는 정상 범위 조회 (해당 성별 + 범용(null)) + @Query("SELECT h FROM HealthNormalRangeEntity h " + + "WHERE h.genderCode = :genderCode OR h.genderCode IS NULL " + + "ORDER BY h.healthItemCode, h.genderCode DESC") + List findRelevantByGenderCode(@Param("genderCode") Integer genderCode); +} \ No newline at end of file diff --git a/health-service/src/main/java/com/healthsync/health/repository/jpa/OccupationTypeRepository.java b/health-service/src/main/java/com/healthsync/health/repository/jpa/OccupationTypeRepository.java new file mode 100644 index 0000000..c76ff43 --- /dev/null +++ b/health-service/src/main/java/com/healthsync/health/repository/jpa/OccupationTypeRepository.java @@ -0,0 +1,24 @@ +package com.healthsync.health.repository.jpa; + +import com.healthsync.health.repository.entity.OccupationTypeEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +/** + * 직업 유형 정보 조회를 위한 리포지토리 (health-service용) + */ +@Repository +public interface OccupationTypeRepository extends JpaRepository { + + /** + * 직업 코드로 직업 정보 조회 (조회 시 사용) + */ + Optional findByOccupationCode(String occupationCode); + + /** + * 직업명으로 직업 정보 조회 (저장 시 사용) + */ + Optional findByOccupationName(String occupationName); +} \ No newline at end of file diff --git a/health-service/src/main/java/com/healthsync/health/repository/jpa/RefreshTokenRepository.java b/health-service/src/main/java/com/healthsync/health/repository/jpa/RefreshTokenRepository.java new file mode 100644 index 0000000..0874eac --- /dev/null +++ b/health-service/src/main/java/com/healthsync/health/repository/jpa/RefreshTokenRepository.java @@ -0,0 +1,25 @@ +package com.healthsync.health.repository.jpa; + +import com.healthsync.health.repository.entity.RefreshTokenEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.Optional; + +@Repository +public interface RefreshTokenRepository extends JpaRepository { + Optional findByToken(String token); + Optional findByMemberSerialNumber(Long memberSerialNumber); + + @Modifying + @Query("DELETE FROM RefreshTokenEntity r WHERE r.memberSerialNumber = :memberSerialNumber") + void deleteByMemberSerialNumber(@Param("memberSerialNumber") Long memberSerialNumber); + + @Modifying + @Query("DELETE FROM RefreshTokenEntity r WHERE r.expiryDate < :now") + void deleteExpiredTokens(@Param("now") LocalDateTime now); +} diff --git a/health-service/src/main/java/com/healthsync/health/repository/jpa/UserRepository.java b/health-service/src/main/java/com/healthsync/health/repository/jpa/UserRepository.java new file mode 100644 index 0000000..705ce0a --- /dev/null +++ b/health-service/src/main/java/com/healthsync/health/repository/jpa/UserRepository.java @@ -0,0 +1,90 @@ +package com.healthsync.health.repository.jpa; + +import com.healthsync.health.repository.entity.UserEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +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.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +/** + * 사용자 정보 조회를 위한 리포지토리 (health-service용) + * user_service.user 테이블 접근 + */ +@Repository +public interface UserRepository extends JpaRepository { + + // 기본 조회 메서드 + Optional findByGoogleId(String googleId); + boolean existsByGoogleId(String googleId); + + // 이름으로 검색 + List findByNameContaining(String name); + + // 직업으로 검색 + List findByOccupation(String occupation); + List findByOccupationContaining(String occupation); + + // 생년월일 범위 검색 + List findByBirthDateBetween(LocalDate startDate, LocalDate endDate); + + // 가입일 범위 검색 + List findByCreatedAtBetween(LocalDateTime startDate, LocalDateTime endDate); + + // 최근 로그인한 사용자들 + @Query("SELECT u FROM UserEntity u WHERE u.lastLoginAt >= :since ORDER BY u.lastLoginAt DESC") + List findRecentlyLoggedInUsers(@Param("since") LocalDateTime since); + + // 특정 기간 동안 로그인하지 않은 사용자들 + @Query("SELECT u FROM UserEntity u WHERE u.lastLoginAt < :before OR u.lastLoginAt IS NULL") + List findInactiveUsers(@Param("before") LocalDateTime before); + + // 로그인 시간 업데이트 + @Modifying + @Query("UPDATE UserEntity u SET u.lastLoginAt = :lastLoginAt, u.updatedAt = :updatedAt WHERE u.memberSerialNumber = :memberSerialNumber") + void updateLastLoginAt(@Param("memberSerialNumber") Long memberSerialNumber, + @Param("lastLoginAt") LocalDateTime lastLoginAt, + @Param("updatedAt") LocalDateTime updatedAt); + + // 사용자 정보 부분 업데이트 + @Modifying + @Query("UPDATE UserEntity u SET u.name = :name, u.updatedAt = :updatedAt WHERE u.memberSerialNumber = :memberSerialNumber") + void updateUserName(@Param("memberSerialNumber") Long memberSerialNumber, + @Param("name") String name, + @Param("updatedAt") LocalDateTime updatedAt); + + @Modifying + @Query("UPDATE UserEntity u SET u.birthDate = :birthDate, u.updatedAt = :updatedAt WHERE u.memberSerialNumber = :memberSerialNumber") + void updateUserBirthDate(@Param("memberSerialNumber") Long memberSerialNumber, + @Param("birthDate") LocalDate birthDate, + @Param("updatedAt") LocalDateTime updatedAt); + + @Modifying + @Query("UPDATE UserEntity u SET u.occupation = :occupation, u.updatedAt = :updatedAt WHERE u.memberSerialNumber = :memberSerialNumber") + void updateUserOccupation(@Param("memberSerialNumber") Long memberSerialNumber, + @Param("occupation") String occupation, + @Param("updatedAt") LocalDateTime updatedAt); + + // 통계 관련 쿼리 + @Query("SELECT COUNT(u) FROM UserEntity u WHERE u.createdAt >= :startDate") + long countNewUsersFrom(@Param("startDate") LocalDateTime startDate); + + @Query("SELECT COUNT(u) FROM UserEntity u WHERE u.lastLoginAt >= :startDate") + long countActiveUsersFrom(@Param("startDate") LocalDateTime startDate); + + @Query("SELECT u.occupation, COUNT(u) FROM UserEntity u WHERE u.occupation IS NOT NULL GROUP BY u.occupation") + List countUsersByOccupation(); + + // 생년월일이 설정되지 않은 사용자들 (임시값 사용자들) + @Query("SELECT u FROM UserEntity u WHERE u.birthDate = :defaultDate") + List findUsersWithDefaultBirthDate(@Param("defaultDate") LocalDate defaultDate); + + // 프로필이 완성되지 않은 사용자들 + @Query("SELECT u FROM UserEntity u WHERE u.birthDate = :defaultDate OR u.occupation IS NULL") + List findIncompleteProfiles(@Param("defaultDate") LocalDate defaultDate); +} \ No newline at end of file diff --git a/health-service/src/main/java/com/healthsync/health/service/HealthProfile/HealthProfileService.java b/health-service/src/main/java/com/healthsync/health/service/HealthProfile/HealthProfileService.java new file mode 100644 index 0000000..3fb02d4 --- /dev/null +++ b/health-service/src/main/java/com/healthsync/health/service/HealthProfile/HealthProfileService.java @@ -0,0 +1,74 @@ +package com.healthsync.health.service.HealthProfile; + +import com.healthsync.health.domain.HealthCheck.HealthCheckupRaw; +import com.healthsync.health.domain.HealthCheck.HealthCheckup; +import com.healthsync.health.domain.HealthCheck.HealthNormalRange; +import com.healthsync.health.dto.HealthCheck.HealthCheckupSyncResult; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +public interface HealthProfileService { + + // ========== RAW 데이터 관련 메서드 ========== + + /** + * 이름과 생년월일로 최근 건강검진 원천 데이터 조회 + */ + Optional getMostRecentHealthCheckup(String name, LocalDate birthDate); + + /** + * 이름과 생년월일로 5년간 건강검진 원천 데이터 조회 + */ + List getHealthCheckupHistory5years(String name, LocalDate birthDate); + + // ========== 가공된 데이터 관련 메서드 ========== + + /** + * raw 데이터를 가공하여 health_checkup 테이블에 저장 (기존 메서드) + */ + HealthCheckupSyncResult processAndSaveHealthCheckupData(List rawCheckupData, Long memberSerialNumber); + + /** + * Raw 데이터와 가공된 데이터를 동기화 (중복 방지) - 새로 추가 + */ + HealthCheckupSyncResult syncHealthCheckupData(List rawData, Long memberSerialNumber); + + /** + * 특정 회원의 가공된 건강검진 이력 조회 (1개 레코드 방식에서는 최대 1개) + */ + List getProcessedHealthCheckupHistory(Long memberSerialNumber); + + /** + * 특정 회원의 최신 가공된 건강검진 데이터 조회 (1개 레코드 방식에서는 유일한 데이터) + */ + Optional getLatestProcessedHealthCheckup(Long memberSerialNumber); + + /** + * 특정 회원의 최근 N개 건강검진 데이터 조회 (1개 레코드 방식에서는 limit 무관하게 최대 1개) + */ + List getRecentHealthCheckups(Long memberSerialNumber, int limit); + + // ========== 정상 범위 관련 메서드 ========== + + /** + * 모든 건강 정상 범위 데이터 조회 + */ + List getAllHealthNormalRanges(); + + /** + * 성별 코드에 따른 건강 정상 범위 데이터 조회 + */ + List getHealthNormalRangesByGender(Integer genderCode); + + /** + * 특정 건강 항목의 정상 범위 조회 + */ + Optional getHealthNormalRangeByItemCode(String healthItemCode, Integer genderCode); + + /** + * 성별에 맞는 건강 정상 범위 데이터 조회 (null 허용하는 범용 범위 포함) + */ + List getRelevantHealthNormalRanges(Integer genderCode); +} \ No newline at end of file diff --git a/health-service/src/main/java/com/healthsync/health/service/HealthProfile/HealthProfileServiceImpl.java b/health-service/src/main/java/com/healthsync/health/service/HealthProfile/HealthProfileServiceImpl.java new file mode 100644 index 0000000..0b918f8 --- /dev/null +++ b/health-service/src/main/java/com/healthsync/health/service/HealthProfile/HealthProfileServiceImpl.java @@ -0,0 +1,317 @@ +package com.healthsync.health.service.HealthProfile; + +import com.healthsync.health.domain.HealthCheck.HealthCheckupRaw; +import com.healthsync.health.domain.HealthCheck.HealthCheckup; +import com.healthsync.health.domain.HealthCheck.HealthNormalRange; +import com.healthsync.health.dto.HealthCheck.HealthCheckupSyncResult; +import com.healthsync.health.repository.entity.HealthCheckupRawEntity; +import com.healthsync.health.repository.entity.HealthCheckupEntity; +import com.healthsync.health.repository.entity.HealthNormalRangeEntity; +import com.healthsync.health.repository.jpa.HealthCheckupRawRepository; +import com.healthsync.health.repository.jpa.HealthCheckupRepository; +import com.healthsync.health.repository.jpa.HealthNormalRangeRepository; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +@Service +@Transactional(readOnly = true) +public class HealthProfileServiceImpl implements HealthProfileService { + + private static final Logger logger = LoggerFactory.getLogger(HealthProfileServiceImpl.class); + + private final HealthCheckupRawRepository healthCheckupRawRepository; + private final HealthCheckupRepository healthCheckupRepository; + private final HealthNormalRangeRepository healthNormalRangeRepository; + + public HealthProfileServiceImpl(HealthCheckupRawRepository healthCheckupRawRepository, + HealthCheckupRepository healthCheckupRepository, + HealthNormalRangeRepository healthNormalRangeRepository) { + this.healthCheckupRawRepository = healthCheckupRawRepository; + this.healthCheckupRepository = healthCheckupRepository; + this.healthNormalRangeRepository = healthNormalRangeRepository; + } + + // ========== RAW 데이터 관련 메서드 ========== + + @Override + public Optional getMostRecentHealthCheckup(String name, LocalDate birthDate) { + logger.info("최근 건강검진 원천 데이터 조회 - 이름: {}, 생년월일: {}", name, birthDate); + + List entities = healthCheckupRawRepository + .findTop5ByNameAndBirthDateOrderByReferenceYearDescCreatedAtDesc(name, birthDate); + + if (!entities.isEmpty()) { + HealthCheckupRawEntity entity = entities.get(0); // 첫 번째(최신) 데이터 + logger.info("건강검진 원천 데이터 발견 - 검진년도: {}, Raw ID: {}, 성별 코드: {}", + entity.getReferenceYear(), entity.getRawId(), entity.getGenderCode()); + return Optional.of(entity.toDomain()); + } else { + logger.warn("해당 사용자의 건강검진 원천 데이터를 찾을 수 없음 - 이름: {}, 생년월일: {}", name, birthDate); + return Optional.empty(); + } + } + + @Override + public List getHealthCheckupHistory5years(String name, LocalDate birthDate) { + logger.info("5년간 건강검진 원천 데이터 조회 - 이름: {}, 생년월일: {}", name, birthDate); + + List entities = healthCheckupRawRepository + .findTop5ByNameAndBirthDateOrderByReferenceYearDescCreatedAtDesc(name, birthDate); + + logger.info("건강검진 원천 데이터 {}건 발견", entities.size()); + + return entities.stream() + .map(HealthCheckupRawEntity::toDomain) + .collect(Collectors.toList()); + } + + // ========== 가공된 데이터 관련 메서드 (1개 레코드 방식) ========== + + @Override + @Transactional + public HealthCheckupSyncResult syncHealthCheckupData(List rawData, Long memberSerialNumber) { + logger.info("건강검진 데이터 동기화 시작 (1개 레코드 방식) - Member Serial Number: {}, Raw 데이터 수: {}", + memberSerialNumber, rawData.size()); + + if (rawData.isEmpty()) { + return new HealthCheckupSyncResult(0, 0, 0, 0, null); + } + + // 가장 최신 Raw 데이터 선택 (첫 번째가 가장 최신) + HealthCheckupRaw latestRaw = rawData.get(0); + + int totalCount = 1; // 1개만 처리 + int newCount = 0; + int updatedCount = 0; + int skippedCount = 0; + HealthCheckup resultCheckup = null; + + try { + // 1. 현재 회원의 건강검진 데이터가 있는지 확인 + Optional existingEntity = healthCheckupRepository.findByMemberSerialNumber(memberSerialNumber); + + if (existingEntity.isPresent()) { + // 2-A. 기존 데이터가 있는 경우 - 업데이트 여부 결정 + HealthCheckupEntity entity = existingEntity.get(); + + // 2-A-1. 이미 같은 raw_id로 처리된 경우 건너뜀 + if (latestRaw.getRawId().equals(entity.getRawId())) { + logger.debug("이미 같은 raw_id로 처리됨 - Raw ID: {}", latestRaw.getRawId()); + skippedCount++; + resultCheckup = entity.toDomain(); + } else { + // 2-A-2. 더 최신 데이터인지 확인 (연도 비교 우선, 그 다음 생성일시 비교) + boolean shouldUpdate = latestRaw.getReferenceYear() > entity.getReferenceYear() || + (latestRaw.getReferenceYear().equals(entity.getReferenceYear()) && + latestRaw.getCreatedAt().isAfter(entity.getCreatedAt())); + + if (shouldUpdate) { + // 더 최신 데이터로 업데이트 + updateEntityFromRaw(entity, latestRaw); + entity.setProcessedAt(LocalDateTime.now()); + healthCheckupRepository.save(entity); + + logger.debug("기존 데이터 업데이트 - Member: {}, 새 Raw ID: {}, 새 연도: {}", + memberSerialNumber, latestRaw.getRawId(), latestRaw.getReferenceYear()); + updatedCount++; + resultCheckup = entity.toDomain(); + } else { + // 더 오래된 데이터 → 건너뜀 + logger.debug("더 오래된 raw 데이터로 업데이트 안함 - Raw ID: {}, 연도: {}", + latestRaw.getRawId(), latestRaw.getReferenceYear()); + skippedCount++; + resultCheckup = entity.toDomain(); + } + } + + } else { + // 2-B. 기존 데이터가 없는 경우 - 새로 생성 + HealthCheckup processedCheckup = HealthCheckup.fromRaw(latestRaw, memberSerialNumber); + HealthCheckupEntity newEntity = HealthCheckupEntity.fromDomain(processedCheckup); + + // checkup_id를 member_serial_number와 동일하게 설정 (Entity에서 자동 설정되지만 명시적으로) + newEntity.setCheckupId(memberSerialNumber); + + HealthCheckupEntity savedEntity = healthCheckupRepository.save(newEntity); + + logger.debug("새 건강검진 데이터 저장 - Member: {}, Raw ID: {}, 연도: {}", + memberSerialNumber, latestRaw.getRawId(), latestRaw.getReferenceYear()); + newCount++; + resultCheckup = savedEntity.toDomain(); + } + + } catch (Exception e) { + logger.error("건강검진 데이터 동기화 중 오류 - Member: {}, Raw ID: {}", + memberSerialNumber, latestRaw.getRawId(), e); + skippedCount++; + } + + HealthCheckupSyncResult result = new HealthCheckupSyncResult( + totalCount, newCount, updatedCount, skippedCount, resultCheckup); + + logger.info("건강검진 데이터 동기화 완료 (1개 레코드 방식) - Member: {}, " + + "결과: 총 {}개 (신규: {}, 갱신: {}, 건너뜀: {})", + memberSerialNumber, totalCount, newCount, updatedCount, skippedCount); + + return result; + } + + @Override + @Transactional + public HealthCheckupSyncResult processAndSaveHealthCheckupData(List rawCheckupData, Long memberSerialNumber) { + // 1개 레코드 방식에서는 syncHealthCheckupData와 동일하게 동작 + return syncHealthCheckupData(rawCheckupData, memberSerialNumber); + } + + @Override + public List getProcessedHealthCheckupHistory(Long memberSerialNumber) { + logger.info("가공된 건강검진 데이터 조회 - Member Serial Number: {}", memberSerialNumber); + + Optional entity = healthCheckupRepository.findByMemberSerialNumber(memberSerialNumber); + + if (entity.isPresent()) { + return List.of(entity.get().toDomain()); // 1개만 반환 + } else { + return List.of(); // 빈 리스트 반환 + } + } + + @Override + public Optional getLatestProcessedHealthCheckup(Long memberSerialNumber) { + logger.info("최신 가공된 건강검진 데이터 조회 - Member Serial Number: {}", memberSerialNumber); + + Optional entity = healthCheckupRepository.findByMemberSerialNumber(memberSerialNumber); + return entity.map(HealthCheckupEntity::toDomain); + } + + @Override + public List getRecentHealthCheckups(Long memberSerialNumber, int limit) { + logger.info("최근 {}개 가공된 건강검진 데이터 조회 - Member Serial Number: {}", limit, memberSerialNumber); + + // 1개 레코드 방식에서는 limit과 관계없이 최대 1개만 반환 + return getProcessedHealthCheckupHistory(memberSerialNumber); + } + + // ========== 정상 범위 관련 메서드 (기존과 동일) ========== + + @Override + public List getAllHealthNormalRanges() { + logger.info("모든 건강 정상 범위 데이터 조회"); + + List entities = healthNormalRangeRepository.findAll(); + + logger.info("건강 정상 범위 데이터 {}건 조회됨", entities.size()); + + return entities.stream() + .map(HealthNormalRangeEntity::toDomain) + .collect(Collectors.toList()); + } + + @Override + public List getHealthNormalRangesByGender(Integer genderCode) { + logger.info("성별별 건강 정상 범위 데이터 조회 - 성별 코드: {}", genderCode); + + List entities = healthNormalRangeRepository.findByGenderCode(genderCode); + + logger.info("성별별 건강 정상 범위 데이터 {}건 조회됨", entities.size()); + + return entities.stream() + .map(HealthNormalRangeEntity::toDomain) + .collect(Collectors.toList()); + } + + @Override + public Optional getHealthNormalRangeByItemCode(String healthItemCode, Integer genderCode) { + logger.info("특정 건강 항목 정상 범위 조회 - 항목 코드: {}, 성별 코드: {}", healthItemCode, genderCode); + + Optional entity = healthNormalRangeRepository + .findByHealthItemCodeAndGenderCode(healthItemCode, genderCode); + + if (entity.isPresent()) { + logger.info("건강 항목 정상 범위 발견 - 항목명: {}", entity.get().getHealthItemName()); + return Optional.of(entity.get().toDomain()); + } else { + logger.warn("해당 건강 항목의 정상 범위를 찾을 수 없음 - 항목 코드: {}, 성별 코드: {}", + healthItemCode, genderCode); + return Optional.empty(); + } + } + + @Override + public List getRelevantHealthNormalRanges(Integer genderCode) { + logger.info("성별에 맞는 건강 정상 범위 데이터 조회 - 성별 코드: {}", genderCode); + + List entities = healthNormalRangeRepository + .findRelevantByGenderCode(genderCode); + + logger.info("관련 건강 정상 범위 데이터 {}건 조회됨", entities.size()); + + return entities.stream() + .map(HealthNormalRangeEntity::toDomain) + .collect(Collectors.toList()); + } + + // ========== 헬퍼 메서드 ========== + + /** + * Raw 데이터로 기존 Entity 업데이트 (1개 레코드 방식) + */ + private void updateEntityFromRaw(HealthCheckupEntity entity, HealthCheckupRaw raw) { + // 기본 정보 업데이트 + entity.setRawId(raw.getRawId()); + entity.setReferenceYear(raw.getReferenceYear()); + entity.setAge(raw.getAge()); + + // 신체 측정 정보 + entity.setHeight(raw.getHeight()); + entity.setWeight(raw.getWeight()); + entity.setBmi(raw.calculateBMI()); + entity.setWaistCircumference(raw.getWaistCircumference()); + + // 시력/청력 + entity.setVisualAcuityLeft(raw.getVisualAcuityLeft()); + entity.setVisualAcuityRight(raw.getVisualAcuityRight()); + entity.setHearingLeft(raw.getHearingLeft()); + entity.setHearingRight(raw.getHearingRight()); + + // 혈압 + entity.setSystolicBp(raw.getSystolicBp()); + entity.setDiastolicBp(raw.getDiastolicBp()); + + // 혈액검사 + entity.setFastingGlucose(raw.getFastingGlucose()); + entity.setTotalCholesterol(raw.getTotalCholesterol()); + entity.setTriglyceride(raw.getTriglyceride()); + entity.setHdlCholesterol(raw.getHdlCholesterol()); + entity.setLdlCholesterol(raw.getLdlCholesterol()); + entity.setHemoglobin(raw.getHemoglobin()); + + // 소변/혈청 + entity.setUrineProtein(raw.getUrineProtein()); + entity.setSerumCreatinine(raw.getSerumCreatinine()); + + // 간기능 + entity.setAst(raw.getAst()); + entity.setAlt(raw.getAlt()); + entity.setGammaGtp(raw.getGammaGtp()); + + // 생활습관 + entity.setSmokingStatus(raw.getSmokingStatus()); + entity.setDrinkingStatus(raw.getDrinkingStatus()); + + // 시간 정보 (created_at은 Raw 데이터의 것으로, processed_at은 현재 시간으로) + entity.setCreatedAt(raw.getCreatedAt()); + + logger.debug("Entity 업데이트 완료 - Raw ID: {}, Reference Year: {}", + raw.getRawId(), raw.getReferenceYear()); + } +} \ No newline at end of file diff --git a/health-service/src/main/java/com/healthsync/health/service/HealthProfile/RealisticHealthMockDataGenerator.java b/health-service/src/main/java/com/healthsync/health/service/HealthProfile/RealisticHealthMockDataGenerator.java new file mode 100644 index 0000000..99cb7ab --- /dev/null +++ b/health-service/src/main/java/com/healthsync/health/service/HealthProfile/RealisticHealthMockDataGenerator.java @@ -0,0 +1,590 @@ +package com.healthsync.health.service.HealthProfile; + +import com.healthsync.health.domain.HealthCheck.HealthCheckupRaw; +import com.healthsync.health.domain.Oauth.User; +import com.healthsync.health.dto.HealthCheck.HealthProfileHistoryResponse; +import com.healthsync.health.dto.HealthCheck.HealthCheckupSyncResult; +import com.healthsync.health.repository.entity.HealthCheckupRawEntity; +import com.healthsync.health.repository.jpa.HealthCheckupRawRepository; +import com.healthsync.common.dto.CusApiResponse; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.Period; +import java.util.*; + +/** + * 현실적인 건강 변화 패턴을 반영한 다중 연도 Mock 데이터 생성기 + * + * @author healthsync-team + * @version 2.0 + */ +@Service +public class RealisticHealthMockDataGenerator { + + private static final Logger logger = LoggerFactory.getLogger(RealisticHealthMockDataGenerator.class); + private final Random random = new Random(); + private final HealthProfileService healthProfileService; + private final HealthCheckupRawRepository healthCheckupRawRepository; + + // 지역 코드 + private final List regionCodes = Arrays.asList(11, 26, 27, 28, 29, 30, 31, 36, 41, 42, 43, 44, 45, 46, 47, 48, 50); + + public RealisticHealthMockDataGenerator(HealthProfileService healthProfileService, + HealthCheckupRawRepository healthCheckupRawRepository) { + this.healthProfileService = healthProfileService; + this.healthCheckupRawRepository = healthCheckupRawRepository; + } + + /** + * ✨ 현실적인 다중 연도 Mock 데이터 생성 (메인 메서드) + * + * 🎯 적용 위치: HealthMockDataGenerator.generateAndProcessMockData() 대체 + * + * @param user 사용자 정보 (이름, 생년월일, 직업 포함) + * @param genderCode 성별 코드 + * @param memberSerialNumber 회원 일련번호 + * @param yearCount 생성할 연도 수 (1~5, 기본값 3) + * @return 건강검진 이력 응답 + */ + @Transactional + public CusApiResponse generateRealisticMockData( + User user, Integer genderCode, Long memberSerialNumber, Integer yearCount) { + + // 연도 수 검증 및 기본값 설정 + int validYearCount = (yearCount != null && yearCount >= 1 && yearCount <= 5) ? yearCount : 5; + + // 사용자 정보 추출 + String userName = user.getName(); + LocalDate birthDate = user.getBirthDate(); + String occupation = user.getOccupation(); + + logger.info("현실적인 다중 연도 Mock 데이터 생성 시작 - 사용자: {}, 생년월일: {}, 연도 수: {}", + userName, birthDate, validYearCount); + + try { + // 🎂 실제 생년월일 기반 나이 계산 + int currentAge = Period.between(birthDate, LocalDate.now()).getYears(); + int currentYear = LocalDate.now().getYear(); + + logger.debug("사용자 기본 정보 - 현재 나이: {}세, 성별: {}, 직업: {}", + currentAge, genderCode == 1 ? "남성" : "여성", occupation); + + // 🏥 개인별 건강 베이스라인 생성 (일관된 개인 특성) + HealthBaseline baseline = generatePersonalHealthBaseline(currentAge, genderCode); + + List mockDataList = new ArrayList<>(); + + // 📅 연도별 데이터 생성 (최신 → 과거 순서) + for (int i = 0; i < validYearCount; i++) { + int targetYear = currentYear - i; + int ageAtTime = currentAge - i; + + // 🚨 음수/0 나이 방지 + if (ageAtTime <= 0) { + logger.warn("유효하지 않은 나이 - {}년 ({}세) 데이터 생성 중단", + targetYear, ageAtTime); + break; // 더 이상 과거로 가지 않음 + } + + HealthCheckupRaw mockData = generateRealisticHealthDataForYear( + userName, birthDate, ageAtTime, genderCode, targetYear, i, baseline + ); + + mockDataList.add(mockData); + } + + // 💾 health_checkup_raw 테이블에 모든 데이터 저장 + List savedDataList = saveAllMockDataToDatabase(mockDataList); + + // 🔄 최신 데이터를 기존 로직으로 health_checkup에 처리 + HealthCheckupRaw latestData = savedDataList.get(0); + HealthCheckupSyncResult syncResult = processMockDataWithExistingLogic(latestData, memberSerialNumber); + + // 📋 응답 생성 (실제 사용자 정보 활용) + HealthProfileHistoryResponse response = createResponseWithAllData( + userName, currentAge, genderCode, occupation, latestData, savedDataList + ); + + logger.info("현실적인 다중 연도 Mock 데이터 생성 완료 - 사용자: {} ({}세), 생성: {}개, 처리결과: 신규{}/갱신{}", + userName, currentAge, savedDataList.size(), syncResult.getNewCount(), syncResult.getUpdatedCount()); + + return new CusApiResponse<>(true, + String.format("현실적인 건강검진 데이터 %d개 연도 생성 완료 (%d~%d년) - %s (%d세)", + validYearCount, currentYear - (validYearCount-1), currentYear, userName, currentAge), response); + + } catch (Exception e) { + logger.error("현실적인 다중 연도 Mock 데이터 생성 중 오류 - 사용자: {} ({}세)", + userName, Period.between(birthDate, LocalDate.now()).getYears(), e); + return new CusApiResponse<>(false, "현실적인 Mock 데이터 생성 실패: " + e.getMessage(), null); + } + } + + /** + * 🔄 기존 API 호환성을 위한 오버로드 메서드 + */ + @Transactional + public CusApiResponse generateRealisticMockData( + String userName, LocalDate birthDate, Integer genderCode, String occupation, + Long memberSerialNumber, Integer yearCount) { + + // User 객체 생성하여 메인 메서드 호출 + User tempUser = new User(); + tempUser.setName(userName); + tempUser.setBirthDate(birthDate); + tempUser.setOccupation(occupation); + + return generateRealisticMockData(tempUser, genderCode, memberSerialNumber, yearCount); + } + + /** + * 🧬 개인별 건강 베이스라인 생성 (일관된 개인 특성 유지) + */ + private HealthBaseline generatePersonalHealthBaseline(int age, Integer genderCode) { + HealthBaseline baseline = new HealthBaseline(); + + // 🏃‍♂️ 개인 유형 결정 (건강형, 평균형, 주의형) + double healthTypeRand = random.nextDouble(); + if (healthTypeRand < 0.3) { + baseline.healthType = HealthType.HEALTHY; // 30% - 건강형 + } else if (healthTypeRand < 0.8) { + baseline.healthType = HealthType.AVERAGE; // 50% - 평균형 + } else { + baseline.healthType = HealthType.AT_RISK; // 20% - 주의형 + } + + // 💪 성별별 신체 기본값 + if (genderCode == 1) { // 남성 + baseline.baseHeight = 165 + random.nextInt(20); // 165~185cm + baseline.baseWeight = 60 + random.nextInt(35); // 60~95kg + } else { // 여성 + baseline.baseHeight = 155 + random.nextInt(20); // 155~175cm + baseline.baseWeight = 45 + random.nextInt(30); // 45~75kg + } + + // 🧘‍♀️ 개인별 생활습관 패턴 + baseline.smokingTrend = random.nextDouble() < 0.7 ? "never" : + (random.nextDouble() < 0.5 ? "quit" : "current"); + baseline.drinkingTrend = random.nextDouble() < 0.3 ? "none" : + (random.nextDouble() < 0.6 ? "moderate" : "frequent"); + + // 🎯 개인별 취약 부위 설정 (현실적인 건강 약점) + List vulnerabilities = Arrays.asList("blood_pressure", "cholesterol", "glucose", "liver", "vision"); + Collections.shuffle(vulnerabilities); + baseline.primaryVulnerability = vulnerabilities.get(0); + baseline.secondaryVulnerability = vulnerabilities.get(1); + + logger.debug("개인 베이스라인 생성 - 유형: {}, 키: {}cm, 몸무게: {}kg, 주요약점: {}", + baseline.healthType, baseline.baseHeight, baseline.baseWeight, baseline.primaryVulnerability); + + return baseline; + } + + /** + * 📊 연도별 현실적인 건강 데이터 생성 + */ + private HealthCheckupRaw generateRealisticHealthDataForYear( + String name, LocalDate birthDate, int ageAtTime, Integer genderCode, + int targetYear, int yearsAgo, HealthBaseline baseline) { + + HealthCheckupRaw raw = new HealthCheckupRaw(); + + // 🆔 기본 정보 (실제 사용자 정보 활용) + raw.setRawId(System.currentTimeMillis() + random.nextInt(1000) + yearsAgo * 1000); + raw.setReferenceYear(targetYear); + raw.setBirthDate(birthDate); // ✅ 실제 사용자 생년월일 사용 + raw.setName(name); // ✅ 실제 사용자 이름 사용 + raw.setRegionCode(regionCodes.get(random.nextInt(regionCodes.size()))); + raw.setGenderCode(genderCode); + raw.setAge(ageAtTime); // ✅ 실제 생년월일 기반 계산된 나이 + raw.setCreatedAt(LocalDateTime.of(targetYear, 6 + random.nextInt(6), 15, 10, 0)); + + logger.debug("기본 정보 설정 - 이름: {}, 생년월일: {}, {}년 당시 나이: {}세", + name, birthDate, targetYear, ageAtTime); + + // 📏 신체 계측 - 비선형적 변화 + raw.setHeight(baseline.baseHeight); // 키는 고정 + raw.setWeight(generateRealisticWeight(baseline, yearsAgo, ageAtTime)); + raw.setWaistCircumference(generateRealisticWaistCircumference(baseline, yearsAgo, ageAtTime)); + + // 👁️ 시력 - 점진적 변화 + 개인차 + generateRealisticVision(raw, baseline, yearsAgo, ageAtTime); + + // 👂 청력 - 연령별 현실적 변화 + generateRealisticHearing(raw, baseline, yearsAgo, ageAtTime); + + // 🩸 혈압 - 개인 패턴 + 연령 요소 + generateRealisticBloodPressure(raw, baseline, yearsAgo, ageAtTime); + + // 🧪 혈액검사 - 생활습관 반영 + 비선형 변화 + generateRealisticBloodTests(raw, baseline, yearsAgo, ageAtTime, genderCode); + + // 🥃 생활습관 - 시간에 따른 변화 패턴 + generateRealisticLifestyle(raw, baseline, yearsAgo); + + logger.debug("건강 데이터 생성 완료 - {}년: 체중 {}kg, 혈압 {}/{}, 총콜레스테롤 {}", + targetYear, raw.getWeight(), raw.getSystolicBp(), raw.getDiastolicBp(), raw.getTotalCholesterol()); + + return raw; + } + + /** + * ⚖️ 현실적인 체중 변화 생성 + */ + private Integer generateRealisticWeight(HealthBaseline baseline, int yearsAgo, int age) { + double baseWeight = baseline.baseWeight; + + // 🎢 비선형적 체중 변화 (사인파 + 노이즈) + double yearlyVariation = Math.sin(yearsAgo * 0.5) * 3; // 주기적 변동 ±3kg + double randomNoise = (random.nextGaussian() * 1.5); // 랜덤 노이즈 ±1.5kg + double ageEffect = (age > 40) ? (age - 40) * 0.2 : 0; // 40세 이후 연간 +0.2kg + + // 💪 개인 유형별 체중 변화 패턴 + double typeModifier = switch (baseline.healthType) { + case HEALTHY -> -1.0; // 건강형은 약간 가벼움 + case AVERAGE -> 0.0; // 평균형은 변화 없음 + case AT_RISK -> 2.0; // 주의형은 약간 무거움 + }; + + int finalWeight = (int)(baseWeight + yearlyVariation + randomNoise + ageEffect + typeModifier); + return Math.max(40, Math.min(150, finalWeight)); // 40~150kg 범위 제한 + } + + /** + * 📐 현실적인 허리둘레 변화 생성 + */ + private Integer generateRealisticWaistCircumference(HealthBaseline baseline, int yearsAgo, int age) { + // BMI 기반 기본 허리둘레 추정 + double estimatedBMI = baseline.baseWeight / Math.pow(baseline.baseHeight / 100.0, 2); + int baseWaist = (int)(estimatedBMI * 3 + 50); // 대략적인 계산 + + // 연령별 증가 경향 + double ageIncrease = (age > 35) ? (age - 35) * 0.3 : 0; + double yearlyVariation = (random.nextGaussian() * 2); // ±2cm 변동 + + int finalWaist = (int)(baseWaist + ageIncrease + yearlyVariation); + return Math.max(60, Math.min(120, finalWaist)); + } + + /** + * 👁️ 현실적인 시력 변화 생성 + */ + private void generateRealisticVision(HealthCheckupRaw raw, HealthBaseline baseline, int yearsAgo, int age) { + // 기본 시력 (20~30대는 좋음, 이후 점진적 하락) + double baseVision = (age < 30) ? 0.9 + random.nextDouble() * 0.8 : + (age < 50) ? 0.7 + random.nextDouble() * 0.6 : + 0.5 + random.nextDouble() * 0.4; + + + + // 📱 현대인 시력 악화 요소 (비선형) + double modernLifeEffect = (age>15) + ? Math.log(age - 15) * 0.05 // 로그 함수로 비선형 악화 + : Math.log(age) * 0.01; + + // 👀 개인차 및 유전적 요소 + double geneticFactor = (random.nextGaussian() * 0.1); + + // 🕰️ 연도별 변화 (과거가 더 좋았음) + double timeRecovery = yearsAgo * 0.03; // 과거로 갈수록 시력 개선 + + double leftVision = Math.max(0.1, Math.min(2.0, baseVision - modernLifeEffect + geneticFactor + timeRecovery)); + double rightVision = Math.max(0.1, Math.min(2.0, leftVision + random.nextGaussian() * 0.1)); // 좌우 약간 차이 + + raw.setVisualAcuityLeft(BigDecimal.valueOf(leftVision).setScale(1, BigDecimal.ROUND_HALF_UP)); + raw.setVisualAcuityRight(BigDecimal.valueOf(rightVision).setScale(1, BigDecimal.ROUND_HALF_UP)); + } + + /** + * 👂 현실적인 청력 변화 생성 + */ + private void generateRealisticHearing(HealthCheckupRaw raw, HealthBaseline baseline, int yearsAgo, int age) { + // 연령별 청력 손실 확률 (비선형) + double hearingLossProbability = Math.max(0, (age - 50) * 0.02); // 50세부터 증가 + + // 🎵 개인 유형별 청력 건강도 + double typeModifier = switch (baseline.healthType) { + case HEALTHY -> 0.005; // 건강형은 청력 좋음 + case AVERAGE -> 0.01; // 평균형 + case AT_RISK -> 0.02; // 주의형은 청력 나쁨 + }; + + boolean leftHearingLoss = random.nextDouble() < (hearingLossProbability + typeModifier); + boolean rightHearingLoss = random.nextDouble() < (hearingLossProbability + typeModifier); + + raw.setHearingLeft(leftHearingLoss ? 2 : 1); + raw.setHearingRight(rightHearingLoss ? 2 : 1); + } + + /** + * 🩸 현실적인 혈압 변화 생성 + */ + private void generateRealisticBloodPressure(HealthCheckupRaw raw, HealthBaseline baseline, int yearsAgo, int age) { + // 🎯 개인 유형별 기본 혈압 + int baseSystolic = switch (baseline.healthType) { + case HEALTHY -> 110 + random.nextInt(15); // 110~125 + case AVERAGE -> 115 + random.nextInt(20); // 115~135 + case AT_RISK -> 125 + random.nextInt(25); // 125~150 + }; + + int baseDiastolic = (int)(baseSystolic * 0.6 + random.nextInt(10)); // 수축기의 60% + 변동 + + // 📈 연령별 혈압 상승 (비선형 - 제곱근 함수) + double ageEffect = Math.sqrt(Math.max(0, age - 30)) * 2; + + // 🌊 스트레스 요인 (주기적 변화) + double stressEffect = Math.sin(yearsAgo * 0.3) * 5 + random.nextGaussian() * 3; + + // 🏃‍♂️ 생활습관 영향 + double lifestyleEffect = baseline.smokingTrend.equals("current") ? 8 : + baseline.smokingTrend.equals("quit") ? 3 : 0; + lifestyleEffect += baseline.drinkingTrend.equals("frequent") ? 5 : 0; + + // 💊 취약 부위 반영 + double vulnerabilityEffect = baseline.primaryVulnerability.equals("blood_pressure") ? 10 : 0; + + int finalSystolic = (int)(baseSystolic + ageEffect + stressEffect + lifestyleEffect + vulnerabilityEffect); + int finalDiastolic = (int)(baseDiastolic + ageEffect * 0.5 + stressEffect * 0.6); + + // 🔒 현실적 범위 제한 + raw.setSystolicBp(Math.max(90, Math.min(200, finalSystolic))); + raw.setDiastolicBp(Math.max(60, Math.min(120, finalDiastolic))); + } + + /** + * 🧪 현실적인 혈액검사 수치 생성 + */ + private void generateRealisticBloodTests(HealthCheckupRaw raw, HealthBaseline baseline, + int yearsAgo, int age, Integer genderCode) { + + // 🍭 혈당 - 식습관과 연령 반영 + int baseGlucose = switch (baseline.healthType) { + case HEALTHY -> 85 + random.nextInt(10); // 85~95 + case AVERAGE -> 90 + random.nextInt(15); // 90~105 + case AT_RISK -> 95 + random.nextInt(20); // 95~115 + }; + + double glucoseAgeEffect = (age > 40) ? (age - 40) * 0.8 : 0; + double vulnerabilityEffect = baseline.primaryVulnerability.equals("glucose") ? 15 : 0; + raw.setFastingGlucose((int)(baseGlucose + glucoseAgeEffect + vulnerabilityEffect + random.nextGaussian() * 5)); + + // 🥩 콜레스테롤 - 연령과 생활습관 강한 상관관계 + generateRealisticCholesterol(raw, baseline, yearsAgo, age); + + // 🩸 헤모글로빈 - 성별차 + 개인차 + generateRealisticHemoglobin(raw, baseline, genderCode); + + // 🫘 간기능 - 음주 패턴 강하게 반영 + generateRealisticLiverFunction(raw, baseline, yearsAgo, age); + + // 💧 신장기능 - 안정적이지만 연령별 변화 + generateRealisticKidneyFunction(raw, baseline, age); + } + + /** + * 🥩 현실적인 콜레스테롤 수치 생성 + */ + private void generateRealisticCholesterol(HealthCheckupRaw raw, HealthBaseline baseline, int yearsAgo, int age) { + // 총 콜레스테롤 - 연령별 증가 경향 + int baseTotalChol = 160 + age * 2 + random.nextInt(40); + double vulnerabilityEffect = baseline.primaryVulnerability.equals("cholesterol") ? 30 : 0; + raw.setTotalCholesterol((int)(baseTotalChol + vulnerabilityEffect)); + + // 중성지방 - 더 변동성 크고 생활습관 민감 + int baseTriglyceride = 80 + random.nextInt(60); + double lifestyleEffect = baseline.drinkingTrend.equals("frequent") ? 40 : 0; + double metabolicEffect = Math.sin(yearsAgo * 0.4) * 20; // 주기적 변동 + raw.setTriglyceride((int)(baseTriglyceride + lifestyleEffect + metabolicEffect + random.nextGaussian() * 15)); + + // HDL (좋은 콜레스테롤) - 운동 습관 반영 + int baseHDL = switch (baseline.healthType) { + case HEALTHY -> 55 + random.nextInt(20); // 55~75 + case AVERAGE -> 45 + random.nextInt(20); // 45~65 + case AT_RISK -> 35 + random.nextInt(20); // 35~55 + }; + raw.setHdlCholesterol(baseHDL); + + // LDL (나쁜 콜레스테롤) - 총 콜레스테롤에서 계산 + int calculatedLDL = raw.getTotalCholesterol() - raw.getHdlCholesterol() - (raw.getTriglyceride() / 5); + raw.setLdlCholesterol(Math.max(50, calculatedLDL)); + } + + /** + * 🩸 현실적인 헤모글로빈 수치 생성 + */ + private void generateRealisticHemoglobin(HealthCheckupRaw raw, HealthBaseline baseline, Integer genderCode) { + double baseHemoglobin; + if (genderCode == 1) { // 남성 + baseHemoglobin = 14.0 + random.nextGaussian() * 1.5; // 평균 14.0 ± 1.5 + } else { // 여성 + baseHemoglobin = 12.5 + random.nextGaussian() * 1.2; // 평균 12.5 ± 1.2 + } + + // 🍎 영양 상태 반영 + double nutritionEffect = switch (baseline.healthType) { + case HEALTHY -> 0.5; + case AVERAGE -> 0.0; + case AT_RISK -> -0.8; + }; + + double finalHemoglobin = Math.max(8.0, Math.min(20.0, baseHemoglobin + nutritionEffect)); + raw.setHemoglobin(BigDecimal.valueOf(finalHemoglobin).setScale(1, BigDecimal.ROUND_HALF_UP)); + } + + /** + * 🫘 현실적인 간기능 수치 생성 + */ + private void generateRealisticLiverFunction(HealthCheckupRaw raw, HealthBaseline baseline, int yearsAgo, int age) { + // 기본 간기능 수치 + int baseAST = 20 + random.nextInt(15); + int baseALT = 15 + random.nextInt(20); + int baseGGT = 15 + random.nextInt(20); + + // 🍺 음주 영향 (강한 상관관계) + double drinkingEffect = switch (baseline.drinkingTrend) { + case "none" -> 0; + case "moderate" -> 5 + random.nextGaussian() * 3; + case "frequent" -> 15 + random.nextGaussian() * 8; + default -> 0; + }; + + // 🎯 간이 취약 부위인 경우 + double vulnerabilityEffect = baseline.primaryVulnerability.equals("liver") ? 12 : 0; + + double ageEffect = (age > 19) + ? Math.log(age - 19) * 2 + : Math.log(age); + + + raw.setAst((int)(baseAST + drinkingEffect + vulnerabilityEffect + ageEffect)); + raw.setAlt((int)(baseALT + drinkingEffect * 1.2 + vulnerabilityEffect + ageEffect)); + raw.setGammaGtp((int)(baseGGT + drinkingEffect * 2 + vulnerabilityEffect * 1.5)); + } + + /** + * 💧 현실적인 신장기능 수치 생성 + */ + private void generateRealisticKidneyFunction(HealthCheckupRaw raw, HealthBaseline baseline, int age) { + // 소변 단백 - 대부분 음성, 연령별 약간 증가 + double proteinuriaProbability = Math.max(0.05, (age - 50) * 0.01); + raw.setUrineProtein(random.nextDouble() < proteinuriaProbability ? 2 : 1); + + // 혈청 크레아티닌 - 안정적이지만 연령별 약간 증가 + double baseCreatinine = 0.8 + random.nextGaussian() * 0.2; + double ageEffect = (age > 60) ? (age - 60) * 0.01 : 0; + double finalCreatinine = Math.max(0.5, Math.min(2.0, baseCreatinine + ageEffect)); + raw.setSerumCreatinine(BigDecimal.valueOf(finalCreatinine).setScale(1, BigDecimal.ROUND_HALF_UP)); + } + + /** + * 🚬 현실적인 생활습관 변화 생성 + */ + private void generateRealisticLifestyle(HealthCheckupRaw raw, HealthBaseline baseline, int yearsAgo) { + // 🚭 흡연 상태 - 시간에 따른 변화 패턴 + int smokingStatus = switch (baseline.smokingTrend) { + case "never" -> 1; // 비흡연 + case "quit" -> (yearsAgo > 2) ? 3 : 2; // 과거에는 흡연, 최근에 금연 + case "current" -> (random.nextDouble() < 0.1) ? 2 : 3; // 대부분 흡연, 10% 확률로 과거 흡연 + default -> 1; + }; + + // 🍺 음주 상태 - 연령별 패턴 변화 + int drinkingStatus = switch (baseline.drinkingTrend) { + case "none" -> 1; // 비음주 + case "moderate" -> (random.nextDouble() < 0.7) ? 2 : 3; // 70% 과거, 30% 현재 + case "frequent" -> (yearsAgo > 3 && random.nextDouble() < 0.3) ? 2 : 3; // 과거엔 더 많이 음주 + default -> 1; + }; + + raw.setSmokingStatus(smokingStatus); + raw.setDrinkingStatus(drinkingStatus); + } + + /** + * 💾 모든 Mock 데이터를 데이터베이스에 저장 + */ + private List saveAllMockDataToDatabase(List mockDataList) { + List savedDataList = new ArrayList<>(); + + for (HealthCheckupRaw mockData : mockDataList) { + HealthCheckupRawEntity rawEntity = HealthCheckupRawEntity.fromDomain(mockData); + HealthCheckupRawEntity savedEntity = healthCheckupRawRepository.save(rawEntity); + savedDataList.add(savedEntity.toDomain()); + } + + logger.info("Mock Raw 데이터 DB 저장 완료 - {} 건", savedDataList.size()); + return savedDataList; + } + + /** + * 🔄 기존 로직으로 Mock 데이터 처리 + */ + private HealthCheckupSyncResult processMockDataWithExistingLogic(HealthCheckupRaw latestData, Long memberSerialNumber) { + List latestDataList = Arrays.asList(latestData); + HealthCheckupSyncResult syncResult = healthProfileService.syncHealthCheckupData(latestDataList, memberSerialNumber); + + logger.info("최신 Mock 데이터 처리 완료 - 연도: {}, 결과: 신규{}/갱신{}/건너뜀{}", + latestData.getReferenceYear(), syncResult.getNewCount(), syncResult.getUpdatedCount(), syncResult.getSkippedCount()); + + return syncResult; + } + + /** + * 📋 모든 데이터를 포함한 응답 생성 + */ + private HealthProfileHistoryResponse createResponseWithAllData( + String userName, int currentAge, Integer genderCode, String occupation, + HealthCheckupRaw latestData, List allData) { + + HealthProfileHistoryResponse.UserInfo userInfo = new HealthProfileHistoryResponse.UserInfo( + userName, + currentAge, + convertGenderCodeToString(genderCode), + occupation != null ? occupation : "정보 없음" + ); + + return new HealthProfileHistoryResponse(userInfo, latestData, allData); + } + + /** + * 성별 코드 변환 + */ + private String convertGenderCodeToString(Integer genderCode) { + return switch (genderCode) { + case 1 -> "남성"; + case 2 -> "여성"; + default -> "정보 없음"; + }; + } + + // ================================================================= + // 내부 클래스들 + // ================================================================= + + /** + * 🧬 개인 건강 베이스라인 정보 + */ + private static class HealthBaseline { + public HealthType healthType; + public int baseHeight; + public int baseWeight; + public String smokingTrend; + public String drinkingTrend; + public String primaryVulnerability; + public String secondaryVulnerability; + } + + /** + * 🏃‍♂️ 건강 유형 열거형 + */ + private enum HealthType { + HEALTHY, // 건강형 - 전반적으로 좋은 수치 + AVERAGE, // 평균형 - 일반적인 수치 + AT_RISK // 주의형 - 일부 수치가 주의 범위 + } +} \ No newline at end of file diff --git a/health-service/src/main/java/com/healthsync/health/service/Oauth/RefreshTokenService.java b/health-service/src/main/java/com/healthsync/health/service/Oauth/RefreshTokenService.java new file mode 100644 index 0000000..9731e52 --- /dev/null +++ b/health-service/src/main/java/com/healthsync/health/service/Oauth/RefreshTokenService.java @@ -0,0 +1,13 @@ +package com.healthsync.health.service.Oauth; + +import com.healthsync.health.domain.Oauth.RefreshToken; + +import java.util.Optional; + +public interface RefreshTokenService { + RefreshToken createRefreshToken(Long memberSerialNumber); + Optional findByToken(String token); + RefreshToken verifyExpiration(RefreshToken token); + void deleteByMemberSerialNumber(Long memberSerialNumber); + void deleteExpiredTokens(); +} \ No newline at end of file diff --git a/health-service/src/main/java/com/healthsync/health/service/Oauth/RefreshTokenServiceImpl.java b/health-service/src/main/java/com/healthsync/health/service/Oauth/RefreshTokenServiceImpl.java new file mode 100644 index 0000000..585808d --- /dev/null +++ b/health-service/src/main/java/com/healthsync/health/service/Oauth/RefreshTokenServiceImpl.java @@ -0,0 +1,69 @@ +package com.healthsync.health.service.Oauth; + +import com.healthsync.health.domain.Oauth.RefreshToken; +import com.healthsync.health.repository.entity.RefreshTokenEntity; +import com.healthsync.health.repository.jpa.RefreshTokenRepository; +import com.healthsync.health.exception.AuthenticationException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.Optional; +import java.util.UUID; + +@Service +@Transactional +public class RefreshTokenServiceImpl implements RefreshTokenService { + + private final RefreshTokenRepository refreshTokenRepository; + private final long refreshTokenExpiration; + + public RefreshTokenServiceImpl(RefreshTokenRepository refreshTokenRepository, + @Value("${jwt.refresh-token-expiration}") long refreshTokenExpiration) { + this.refreshTokenRepository = refreshTokenRepository; + this.refreshTokenExpiration = refreshTokenExpiration; + } + + @Override + public RefreshToken createRefreshToken(Long memberSerialNumber) { + // 기존 리프레시 토큰 삭제 + refreshTokenRepository.deleteByMemberSerialNumber(memberSerialNumber); + + // 새 리프레시 토큰 생성 + String token = UUID.randomUUID().toString(); + LocalDateTime expiryDate = LocalDateTime.now().plusSeconds(refreshTokenExpiration / 1000); + + RefreshToken refreshToken = new RefreshToken(token, memberSerialNumber, expiryDate); + RefreshTokenEntity entity = RefreshTokenEntity.fromDomain(refreshToken); + RefreshTokenEntity savedEntity = refreshTokenRepository.save(entity); + + return savedEntity.toDomain(); + } + + @Override + @Transactional(readOnly = true) + public Optional findByToken(String token) { + return refreshTokenRepository.findByToken(token) + .map(RefreshTokenEntity::toDomain); + } + + @Override + public RefreshToken verifyExpiration(RefreshToken token) { + if (token.isExpired()) { + refreshTokenRepository.deleteByMemberSerialNumber(token.getMemberSerialNumber()); + throw new AuthenticationException("리프레시 토큰이 만료되었습니다. 다시 로그인해주세요."); + } + return token; + } + + @Override + public void deleteByMemberSerialNumber(Long memberSerialNumber) { + refreshTokenRepository.deleteByMemberSerialNumber(memberSerialNumber); + } + + @Override + public void deleteExpiredTokens() { + refreshTokenRepository.deleteExpiredTokens(LocalDateTime.now()); + } +} diff --git a/health-service/src/main/java/com/healthsync/health/service/UserProfile/UserService.java b/health-service/src/main/java/com/healthsync/health/service/UserProfile/UserService.java new file mode 100644 index 0000000..8ffb4ef --- /dev/null +++ b/health-service/src/main/java/com/healthsync/health/service/UserProfile/UserService.java @@ -0,0 +1,30 @@ +package com.healthsync.health.service.UserProfile; + +import com.healthsync.health.domain.Oauth.User; +import java.util.Optional; + +public interface UserService { + + // 기존 메서드들 + Optional findById(Long memberSerialNumber); + Optional findByGoogleId(String googleId); + User saveUser(User user); + User updateUser(User user); + void updateLastLoginAt(Long memberSerialNumber); + boolean existsByGoogleId(String googleId); + + // 직업 코드 변환 메서드 추가 + /** + * 직업 코드를 직업명으로 변환 (조회 시 사용) + * @param occupationCode 직업 코드 + * @return 직업명 + */ + String convertOccupationCodeToName(String occupationCode); + + /** + * 직업명을 직업 코드로 변환 (저장 시 사용) + * @param occupationName 직업명 + * @return 직업 코드 + */ + String convertOccupationNameToCode(String occupationName); +} \ No newline at end of file diff --git a/health-service/src/main/java/com/healthsync/health/service/UserProfile/UserServiceImpl.java b/health-service/src/main/java/com/healthsync/health/service/UserProfile/UserServiceImpl.java new file mode 100644 index 0000000..8620752 --- /dev/null +++ b/health-service/src/main/java/com/healthsync/health/service/UserProfile/UserServiceImpl.java @@ -0,0 +1,164 @@ +package com.healthsync.health.service.UserProfile; + +import com.healthsync.health.domain.Oauth.User; +import com.healthsync.health.repository.jpa.UserRepository; +import com.healthsync.health.repository.jpa.OccupationTypeRepository; +import com.healthsync.health.repository.entity.UserEntity; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.Optional; + +@Service +@Transactional +public class UserServiceImpl implements UserService { + + private static final Logger logger = LoggerFactory.getLogger(UserServiceImpl.class); + + private final UserRepository userRepository; + private final OccupationTypeRepository occupationTypeRepository; + + public UserServiceImpl(UserRepository userRepository, + OccupationTypeRepository occupationTypeRepository) { + this.userRepository = userRepository; + this.occupationTypeRepository = occupationTypeRepository; + } + + @Override + public String convertOccupationCodeToName(String occupationCode) { + if (occupationCode == null || occupationCode.trim().isEmpty()) { + return "정보 없음"; + } + + logger.debug("직업 코드를 이름으로 변환: {}", occupationCode); + + return occupationTypeRepository.findByOccupationCode(occupationCode) + .map(entity -> { + logger.debug("직업 코드 변환 성공: {} -> {}", occupationCode, entity.getOccupationName()); + return entity.getOccupationName(); + }) + .orElseGet(() -> { + logger.warn("직업 코드를 찾을 수 없음: {}", occupationCode); + return occupationCode; // 코드가 없으면 원래 값 반환 + }); + } + + @Override + public String convertOccupationNameToCode(String occupationName) { + if (occupationName == null || occupationName.trim().isEmpty()) { + return null; + } + + logger.debug("직업명을 코드로 변환: {}", occupationName); + + return occupationTypeRepository.findByOccupationName(occupationName) + .map(entity -> { + logger.debug("직업명 변환 성공: {} -> {}", occupationName, entity.getOccupationCode()); + return entity.getOccupationCode(); + }) + .orElseGet(() -> { + logger.warn("직업명을 찾을 수 없음: {}", occupationName); + return occupationName; // 매칭되는 코드가 없으면 원래 값 반환 + }); + } + + @Override + @Transactional(readOnly = true) + public Optional findById(Long memberSerialNumber) { + return userRepository.findById(memberSerialNumber) + .map(entity -> { + User user = entity.toDomain(); + // 조회할 때 occupation 코드를 이름으로 변환 + if (user.getOccupation() != null) { + String occupationName = convertOccupationCodeToName(user.getOccupation()); + user.setOccupation(occupationName); + } + return user; + }); + } + + @Override + @Transactional(readOnly = true) + public Optional findByGoogleId(String googleId) { + return userRepository.findByGoogleId(googleId) + .map(entity -> { + User user = entity.toDomain(); + // 조회할 때 occupation 코드를 이름으로 변환 + if (user.getOccupation() != null) { + String occupationName = convertOccupationCodeToName(user.getOccupation()); + user.setOccupation(occupationName); + } + return user; + }); + } + + @Override + public User saveUser(User user) { + logger.info("새 사용자 저장"); + + // 저장하기 전에 occupation 이름을 코드로 변환 + String originalOccupation = user.getOccupation(); + if (originalOccupation != null) { + String occupationCode = convertOccupationNameToCode(originalOccupation); + user.setOccupation(occupationCode); + logger.debug("직업 정보 변환하여 저장: {} -> {}", originalOccupation, occupationCode); + } + + UserEntity entity = UserEntity.fromDomain(user); + UserEntity savedEntity = userRepository.save(entity); + + // 저장 후 다시 조회해서 코드를 이름으로 변환하여 반환 + User savedUser = savedEntity.toDomain(); + if (savedUser.getOccupation() != null) { + String occupationName = convertOccupationCodeToName(savedUser.getOccupation()); + savedUser.setOccupation(occupationName); + } + + logger.info("새 사용자 저장 완료: {}", savedUser.getMemberSerialNumber()); + return savedUser; + } + + @Override + public User updateUser(User user) { + logger.info("사용자 정보 업데이트: {}", user.getMemberSerialNumber()); + + // 저장하기 전에 occupation 이름을 코드로 변환 + String originalOccupation = user.getOccupation(); + if (originalOccupation != null) { + String occupationCode = convertOccupationNameToCode(originalOccupation); + user.setOccupation(occupationCode); + logger.debug("직업 정보 변환하여 저장: {} -> {}", originalOccupation, occupationCode); + } + + // 기존 구현 유지 + user.setUpdatedAt(LocalDateTime.now()); + UserEntity entity = UserEntity.fromDomain(user); + UserEntity savedEntity = userRepository.save(entity); + + // 저장 후 다시 조회해서 코드를 이름으로 변환하여 반환 + User savedUser = savedEntity.toDomain(); + if (savedUser.getOccupation() != null) { + String occupationName = convertOccupationCodeToName(savedUser.getOccupation()); + savedUser.setOccupation(occupationName); + } + + logger.info("사용자 정보 업데이트 완료: {}", savedUser.getMemberSerialNumber()); + return savedUser; + } + + @Override + public void updateLastLoginAt(Long memberSerialNumber) { + LocalDateTime now = LocalDateTime.now(); + userRepository.updateLastLoginAt(memberSerialNumber, now, now); + } + + @Override + @Transactional(readOnly = true) + public boolean existsByGoogleId(String googleId) { + return userRepository.existsByGoogleId(googleId); + } +} \ No newline at end of file diff --git a/health-service/src/main/java/com/healthsync/health/util/HealthDataConverter.java b/health-service/src/main/java/com/healthsync/health/util/HealthDataConverter.java new file mode 100644 index 0000000..161ebe0 --- /dev/null +++ b/health-service/src/main/java/com/healthsync/health/util/HealthDataConverter.java @@ -0,0 +1,184 @@ +package com.healthsync.health.util; + +import com.healthsync.health.domain.HealthCheck.HealthCheckup; +import com.healthsync.health.domain.HealthCheck.HealthCheckupRaw; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.stream.Collectors; + +/** + * 건강검진 데이터 변환 유틸리티 클래스 + */ +@Component +public class HealthDataConverter { + + private static final Logger logger = LoggerFactory.getLogger(HealthDataConverter.class); + + /** + * 가공된 데이터를 Raw 형태로 변환 (응답 형태 유지를 위해) + */ + public HealthCheckupRaw convertToRawForResponse(HealthCheckup processed, List originalRawList) { + if (processed == null) { + return null; + } + + // 같은 raw_id의 원본 Raw 데이터 찾기 + Optional originalRaw = originalRawList.stream() + .filter(raw -> raw.getRawId().equals(processed.getRawId())) + .findFirst(); + + if (originalRaw.isPresent()) { + logger.debug("원본 Raw 데이터 사용 - Raw ID: {}", processed.getRawId()); + return originalRaw.get(); + } else { + logger.debug("원본 Raw 데이터 없음, 가공된 데이터로 생성 - Raw ID: {}", processed.getRawId()); + return createRawFromProcessed(processed); + } + } + + /** + * 가공된 데이터 리스트를 Raw 형태 리스트로 변환 + */ + public List convertToRawListForResponse(List processedList, List originalRawList) { + if (processedList == null || processedList.isEmpty()) { + return List.of(); + } + + return processedList.stream() + .map(processed -> convertToRawForResponse(processed, originalRawList)) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + } + + /** + * 가공된 데이터로부터 Raw 형태 객체 생성 (fallback) + */ + private HealthCheckupRaw createRawFromProcessed(HealthCheckup processed) { + HealthCheckupRaw raw = new HealthCheckupRaw(); + + // 기본 정보 + raw.setRawId(processed.getRawId()); + raw.setReferenceYear(processed.getReferenceYear()); + raw.setAge(processed.getAge()); + raw.setCreatedAt(processed.getCreatedAt()); + + // 신체 측정 + raw.setHeight(processed.getHeight()); + raw.setWeight(processed.getWeight()); + raw.setWaistCircumference(processed.getWaistCircumference()); + + // 시력/청력 + raw.setVisualAcuityLeft(processed.getVisualAcuityLeft()); + raw.setVisualAcuityRight(processed.getVisualAcuityRight()); + raw.setHearingLeft(processed.getHearingLeft()); + raw.setHearingRight(processed.getHearingRight()); + + // 혈압 + raw.setSystolicBp(processed.getSystolicBp()); + raw.setDiastolicBp(processed.getDiastolicBp()); + + // 혈액검사 + raw.setFastingGlucose(processed.getFastingGlucose()); + raw.setTotalCholesterol(processed.getTotalCholesterol()); + raw.setTriglyceride(processed.getTriglyceride()); + raw.setHdlCholesterol(processed.getHdlCholesterol()); + raw.setLdlCholesterol(processed.getLdlCholesterol()); + raw.setHemoglobin(processed.getHemoglobin()); + + // 소변/혈청 + raw.setUrineProtein(processed.getUrineProtein()); + raw.setSerumCreatinine(processed.getSerumCreatinine()); + + // 간기능 + raw.setAst(processed.getAst()); + raw.setAlt(processed.getAlt()); + raw.setGammaGtp(processed.getGammaGtp()); + + // 생활습관 + raw.setSmokingStatus(processed.getSmokingStatus()); + raw.setDrinkingStatus(processed.getDrinkingStatus()); + + // 가공된 데이터에는 없는 정보들은 기본값 설정 + raw.setName(""); // 빈 문자열로 설정 + raw.setBirthDate(LocalDate.of(1000, 1, 1)); // 기본값 + raw.setRegionCode(null); + raw.setGenderCode(null); // 성별 정보는 별도로 처리 + + logger.debug("가공된 데이터로부터 Raw 객체 생성 완료 - Raw ID: {}, Reference Year: {}", + raw.getRawId(), raw.getReferenceYear()); + + return raw; + } + + /** + * 성별 코드를 문자열로 변환 + */ + public String convertGenderCodeToString(Integer genderCode) { + if (genderCode == null) { + return "정보 없음"; + } + + switch (genderCode) { + case 1: + return "남성"; + case 2: + return "여성"; + default: + return "정보 없음"; + } + } + + /** + * 성별 문자열을 코드로 변환 + */ + public Integer convertGenderStringToCode(String gender) { + if (gender == null || gender.trim().isEmpty()) { + return null; + } + + switch (gender.trim()) { + case "남성": + case "M": + case "MALE": + return 1; + case "여성": + case "F": + case "FEMALE": + return 2; + default: + return null; + } + } + + /** + * Raw 데이터 리스트에서 특정 연도의 데이터 찾기 + */ + public Optional findRawByYear(List rawList, Integer year) { + if (rawList == null || rawList.isEmpty() || year == null) { + return Optional.empty(); + } + + return rawList.stream() + .filter(raw -> year.equals(raw.getReferenceYear())) + .findFirst(); + } + + /** + * Raw 데이터 리스트에서 특정 Raw ID의 데이터 찾기 + */ + public Optional findRawById(List rawList, Long rawId) { + if (rawList == null || rawList.isEmpty() || rawId == null) { + return Optional.empty(); + } + + return rawList.stream() + .filter(raw -> rawId.equals(raw.getRawId())) + .findFirst(); + } +} \ No newline at end of file diff --git a/health-service/src/main/resources/application.yml b/health-service/src/main/resources/application.yml new file mode 100644 index 0000000..ea0fcd2 --- /dev/null +++ b/health-service/src/main/resources/application.yml @@ -0,0 +1,114 @@ +spring: + application: + name: health-service + + datasource: + url: ${DB_URL:jdbc:postgresql://psql-digitalgarage-01.postgres.database.azure.com:5432/healthsync_db} + username: ${DB_USERNAME:team1tier} + password: ${DB_PASSWORD:Hi5Jessica!} + driver-class-name: org.postgresql.Driver + hikari: + connection-timeout: 20000 + maximum-pool-size: 10 + minimum-idle: 5 + idle-timeout: 300000 + max-lifetime: 1200000 + + jpa: + hibernate: + ddl-auto: none + show-sql: ${SHOW_SQL:false} + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + format_sql: true + jdbc: + time_zone: UTC + + + servlet: + multipart: + max-file-size: 10MB + max-request-size: 10MB + +server: + port: ${SERVER_PORT:8082} + +# 외부 서비스 URL +services: + user-service: + url: ${USER_SERVICE_URL:http://user-service:8081} + intelligence-service: + url: ${INTELLIGENCE_SERVICE_URL:http://intelligence-service:8083} + +# Azure Blob Storage 설정 (건강검진 파일 업로드용) +azure: + storage: + account-name: ${AZURE_STORAGE_ACCOUNT:healthsyncstorage} + account-key: ${AZURE_STORAGE_KEY:your-storage-key} + container-name: ${AZURE_STORAGE_CONTAINER:health-documents} + connection-string: ${AZURE_STORAGE_CONNECTION_STRING:DefaultEndpointsProtocol=https;AccountName=healthsyncstorage;AccountKey=ceMrDkY+cD4OPiS812JIRZcwF/Re5lJGJUO58gue1LBHxlzRhbD6OpDR85zDs5hZTnFooWlhZACZ+AStJO9dMQ==} + +# 건강검진 데이터 처리 설정 +# Health Service Specific Configuration +health: + # Mock 데이터 생성 설정 + mock: + enabled: ${HEALTH_MOCK_ENABLED:true} # 환경변수로 제어 가능 + auto-save-to-db: ${HEALTH_MOCK_AUTO_SAVE:true} # Mock 데이터 자동 DB 저장 여부 + + # Mock 데이터 생성 시 사용할 기본 설정 + defaults: + gender-code: 1 # 기본 성별 (1: 남성, 2: 여성) + age-range: + min: 20 + max: 70 + region-codes: [11, 26, 27, 28, 29, 30, 31, 36, 41, 42, 43, 44, 45, 46, 47, 48, 50] + + # Mock 데이터 품질 설정 + quality: + realistic-ranges: true # 현실적인 수치 범위 사용 + age-based-risks: true # 연령대별 위험 요소 적용 + gender-based-norms: true # 성별별 정상 범위 적용 + seasonal-variation: false # 계절별 변동 (추후 구현) + + security: + oauth2: + resource server: + jwt: + issuer-uri: "http://team1tier.20.214.196.128.nip.io" + +# JWT 설정 +jwt: + private-key: "-----BEGIN PRIVATE KEY-----MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCnHuDmeTbJcxNtY/VJbCktLaqEwCEJStwY32A24xh6jMjz0km9JNpWXpVUtAuAXy+g9omD0u+38E5CdFu6INgykmLeSsm2h8GftiDPX8Nf2NF8HcikFD7N1G2X8loswSTvg40hzPnHllOJr0ZmXAoN4Z55ndkPdPUj8qB364nqx9Yv0natlm9fu0a7WOPB7PtWY5qZa9uCDZ4rn1xaVgHTT89lEqvPVvbnrpbgtyRKK7DEj3AaAsXMHoxYgqyM+h3U3HZjWAdsC50Stl/4Snz+kYbEcsp3HJUbo627Tn2rxssvxktTiYuW0c1RHqZXIXumi2AmLqeRqt0VCBMR+0HDAgMBAAECggEAFPpMy9FqXaYqyZ/zAcjocEnbrjc5zmNNtnePqcQe5f83GFgMtofiOlY8E3pYOUB5h5B62YfIXIP3JuNZQkduLAbxDys/H8Dxvp0LiExih2z9esF4VpRN/+NK8HhU9mo2OzR9qkEDF5kYml9cjGvAPVbVYDm+rfCF9wG1P+hakxRXY5dua3J+Z7KBGYWfTGRLdb8/xl07s7eb4CDDmEWAae+LwLIUXWY1Gh82lp+AhgkWv6Q2ohxqFA6cOR5lScRqLAN7be6pbvIvKlNYFdectaTaRuD7m4rCfQnNpX+TyX7Q53qSzTPddvcymkKaUwaXw2jXSPhgdrUJ9PKbhJ0z/QKBgQDmonJTGZ+gq1RvIWC9IuVUyIwXk3umN4EP6qI/4BxargO4sVAOOS6m61HugpSLehTp9XL09GFHKFsQd9yT0B+hqIIF+BTWXlvbQ2eEzFsIhCc2yAeV414sLWRAIfllMPKk3rc+m5bD81KgPW5yrwJtMgxgoGtj6ETH3BPg0/oIpwKBgQC5gC/Lm8cTWN5AfK30DW/fYD7/RxHsaYSuAN0hLOBGBoGbfqk0f6AG6IOIxYJsU7/uKtXlDNvSPdvXYCupe39S8WDKB1YxpDCODwYWVqfRTEuJzD0SbF3stcn2wwpWyxDRtep947o/P+VCePVgZtoWf8340n89h1bIZ+EXHXqFhQKBgHcwqp6RlnJFOMx51nHIb/ZB8kxY1sUO2C8ulg0mt+CRH7E6SWIgYSC4ak41w6jVPauvQmqfRQquK2m2WBM3srEr0Y5eJ/6lIxmMmxoBNmaPTWi9NVZb+5YfGzkdlbKa+jsEMnUzmVXJEQFo3gR8t2dRPx5MqVMnfSxAazF8uzHvAoGAGQKTbxw9pvogXQlyWqlFIBTV6Y0neXxwixVKuyJVypst9k0Jey6J4OSQd2xJvVk9U1srI4qsSJhWf59Tw7IG5KPurM54bJD6iuyzoWdlkO58cMO8qDM8JqIL7N03E6SlS+D/EKIXhleTDXdJfgnf9ZCdsKKQzTbmGHcI/hjXYBECgYEAiTW83qOqgioI0kJen5cM9/bTkMgYU22I2p2fFdnYEtOn2tIrTJ198fwr1hpWqm/rwK3mig4DyM2/G7cNyJTqexM0gxKqluRPM+ebzyDY9crF4A34lhKsf267tQbasr9uaVrPM23a8CflTrZGAtTMVtJKtHKNFBFxV+p5o6LRDb8=-----END PRIVATE KEY-----" + public-key: "-----BEGIN PUBLIC KEY-----MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApx7g5nk2yXMTbWP1SWwpLS2qhMAhCUrcGN9gNuMYeozI89JJvSTaVl6VVLQLgF8voPaJg9Lvt/BOQnRbuiDYMpJi3krJtofBn7Ygz1/DX9jRfB3IpBQ+zdRtl/JaLMEk74ONIcz5x5ZTia9GZlwKDeGeeZ3ZD3T1I/Kgd+uJ6sfWL9J2rZZvX7tGu1jjwez7VmOamWvbgg2eK59cWlYB00/PZRKrz1b2566W4LckSiuwxI9wGgLFzB6MWIKsjPod1Nx2Y1gHbAudErZf+Ep8/pGGxHLKdxyVG6Otu059q8bLL8ZLU4mLltHNUR6mVyF7potgJi6nkardFQgTEftBwwIDAQAB-----END PUBLIC KEY-----" + access-token-expiration: 1800000 # 30 minutes in milliseconds + refresh-token-expiration: 604800000 # 7 days in milliseconds + + +# 로깅 설정 +logging: + level: + com.healthsync.user: ${LOG_LEVEL:TRACE} + org.springframework.web: ${WEB_LOG_LEVEL:TRACE} + org.springframework.security: ${SECURITY_LOG_LEVEL:TRACE} + org.springframework.data.jpa: ${JPA_LOG_LEVEL:TRACE} + # OAuth2 관련 로깅 추가 + org.springframework.security.oauth2: TRACE + org.springframework.security.oauth2.client: TRACE + org.springframework.security.oauth2.server.resource: TRACE + org.springframework.security.jwt: TRACE + org.springframework.security.web.authentication: TRACE + org.springframework.security.web.FilterChainProxy: TRACE + org.apache.http: TRACE + org.apache.http.wire: TRACE # HTTP 와이어 레벨 로깅 (실제 HTTP 메시지) + +# Management endpoints +management: + endpoints: + web: + exposure: + include: health,info,metrics,prometheus + endpoint: + health: + show-details: always diff --git a/healthsync/deployment/manifest/configmap/api-gateway-config.yaml b/healthsync/deployment/manifest/configmap/api-gateway-config.yaml new file mode 100644 index 0000000..aaacfb6 --- /dev/null +++ b/healthsync/deployment/manifest/configmap/api-gateway-config.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: api-gateway-config + namespace: team1tier-healthsync-ns +data: + SERVER_PORT: "8080" + USER_SERVICE_URL: "http://user-service:80" + HEALTH_SERVICE_URL: "http://health-service:80" + INTELLIGENCE_SERVICE_URL: "http://intelligence-service:80" + GOAL_SERVICE_URL: "http://goal-service:80" + MOTIVATOR_SERVICE_URL: "http://motivator-service:80" diff --git a/healthsync/deployment/manifest/configmap/common-config.yaml b/healthsync/deployment/manifest/configmap/common-config.yaml new file mode 100644 index 0000000..44bb359 --- /dev/null +++ b/healthsync/deployment/manifest/configmap/common-config.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: common-config + namespace: team1tier-healthsync-ns +data: + ALLOWED_ORIGINS: "http://20.249.193.105" + POSTGRES_HOST: "psql-digitalgarage-01.postgres.database.azure.com" + POSTGRES_PORT: "5432" + REDIS_HOST: "redis-digitalgarage-01.redis.cache.windows.net" + REDIS_PORT: "6380" + REDIS_USE_SSL: "true" + SERVICE_BUS_NAMESPACE: "sb-healthsync.servicebus.windows.net" diff --git a/healthsync/deployment/manifest/configmap/goal-service-config.yaml b/healthsync/deployment/manifest/configmap/goal-service-config.yaml new file mode 100644 index 0000000..e3e1d6d --- /dev/null +++ b/healthsync/deployment/manifest/configmap/goal-service-config.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: goal-service-config + namespace: team1tier-healthsync-ns +data: + SERVER_PORT: "8084" + POSTGRES_DB: "healthsync_goal" diff --git a/healthsync/deployment/manifest/configmap/health-service-config.yaml b/healthsync/deployment/manifest/configmap/health-service-config.yaml new file mode 100644 index 0000000..45932e7 --- /dev/null +++ b/healthsync/deployment/manifest/configmap/health-service-config.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: health-service-config + namespace: team1tier-healthsync-ns +data: + SERVER_PORT: "8082" + POSTGRES_DB: "healthsync_health" diff --git a/healthsync/deployment/manifest/configmap/intelligence-service-config.yaml b/healthsync/deployment/manifest/configmap/intelligence-service-config.yaml new file mode 100644 index 0000000..5ae399d --- /dev/null +++ b/healthsync/deployment/manifest/configmap/intelligence-service-config.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: intelligence-service-config + namespace: team1tier-healthsync-ns +data: + SERVER_PORT: "8083" + POSTGRES_DB: "healthsync_intelligence" diff --git a/healthsync/deployment/manifest/configmap/motivator-service-config.yaml b/healthsync/deployment/manifest/configmap/motivator-service-config.yaml new file mode 100644 index 0000000..74161f3 --- /dev/null +++ b/healthsync/deployment/manifest/configmap/motivator-service-config.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: motivator-service-config + namespace: team1tier-healthsync-ns +data: + SERVER_PORT: "8085" + POSTGRES_DB: "healthsync_motivator" diff --git a/healthsync/deployment/manifest/configmap/user-service-config.yaml b/healthsync/deployment/manifest/configmap/user-service-config.yaml new file mode 100644 index 0000000..3b7525a --- /dev/null +++ b/healthsync/deployment/manifest/configmap/user-service-config.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: user-service-config + namespace: team1tier-healthsync-ns +data: + SERVER_PORT: "8081" + POSTGRES_DB: "healthsync_user" + + # Google OAuth 설정 + GOOGLE_OAUTH_TOKEN_VERIFY_URL: "https://oauth2.googleapis.com/tokeninfo" + GOOGLE_OAUTH_USERINFO_URL: "https://www.googleapis.com/oauth2/v2/userinfo" + GOOGLE_OAUTH_ISSUER: "https://accounts.google.com" diff --git a/healthsync/deployment/manifest/deployment/api-gateway-deployment.yaml b/healthsync/deployment/manifest/deployment/api-gateway-deployment.yaml new file mode 100644 index 0000000..ad048fb --- /dev/null +++ b/healthsync/deployment/manifest/deployment/api-gateway-deployment.yaml @@ -0,0 +1,55 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: api-gateway + namespace: team1tier-healthsync-ns +spec: + replicas: 2 + selector: + matchLabels: + app: api-gateway + template: + metadata: + labels: + app: api-gateway + spec: + imagePullSecrets: + - name: acr-secret + containers: + - name: api-gateway + image: acrhealthsync01.azurecr.io/team1tier/api-gateway:1.0.0 + imagePullPolicy: Always + envFrom: + - configMapRef: + name: common-config + - configMapRef: + name: api-gateway-config + - secretRef: + name: common-secret + resources: + requests: + cpu: 256m + memory: 256Mi + limits: + cpu: 1024m + memory: 1024Mi + ports: + - containerPort: 8080 + startupProbe: + httpGet: + path: /actuator/health + port: 8080 + failureThreshold: 30 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /actuator/health + port: 8080 + initialDelaySeconds: 60 + periodSeconds: 15 + readinessProbe: + httpGet: + path: /actuator/health + port: 8080 + initialDelaySeconds: 10 + periodSeconds: 5 diff --git a/healthsync/deployment/manifest/deployment/goal-service-deployment.yaml b/healthsync/deployment/manifest/deployment/goal-service-deployment.yaml new file mode 100644 index 0000000..d0e8183 --- /dev/null +++ b/healthsync/deployment/manifest/deployment/goal-service-deployment.yaml @@ -0,0 +1,55 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: goal-service + namespace: team1tier-healthsync-ns +spec: + replicas: 2 + selector: + matchLabels: + app: goal-service + template: + metadata: + labels: + app: goal-service + spec: + imagePullSecrets: + - name: acr-secret + containers: + - name: goal-service + image: acrhealthsync01.azurecr.io/team1tier/goal-service:1.0.0 + imagePullPolicy: Always + envFrom: + - configMapRef: + name: common-config + - configMapRef: + name: goal-service-config + - secretRef: + name: common-secret + resources: + requests: + cpu: 256m + memory: 256Mi + limits: + cpu: 1024m + memory: 1024Mi + ports: + - containerPort: 8084 + startupProbe: + httpGet: + path: /actuator/health + port: 8084 + failureThreshold: 30 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /actuator/health + port: 8084 + initialDelaySeconds: 60 + periodSeconds: 15 + readinessProbe: + httpGet: + path: /actuator/health + port: 8084 + initialDelaySeconds: 10 + periodSeconds: 5 diff --git a/healthsync/deployment/manifest/deployment/health-service-deployment.yaml b/healthsync/deployment/manifest/deployment/health-service-deployment.yaml new file mode 100644 index 0000000..6dd922e --- /dev/null +++ b/healthsync/deployment/manifest/deployment/health-service-deployment.yaml @@ -0,0 +1,55 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: health-service + namespace: team1tier-healthsync-ns +spec: + replicas: 2 + selector: + matchLabels: + app: health-service + template: + metadata: + labels: + app: health-service + spec: + imagePullSecrets: + - name: acr-secret + containers: + - name: health-service + image: acrhealthsync01.azurecr.io/team1tier/health-service:1.0.0 + imagePullPolicy: Always + envFrom: + - configMapRef: + name: common-config + - configMapRef: + name: health-service-config + - secretRef: + name: common-secret + resources: + requests: + cpu: 256m + memory: 256Mi + limits: + cpu: 1024m + memory: 1024Mi + ports: + - containerPort: 8082 + startupProbe: + httpGet: + path: /actuator/health + port: 8082 + failureThreshold: 30 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /actuator/health + port: 8082 + initialDelaySeconds: 60 + periodSeconds: 15 + readinessProbe: + httpGet: + path: /actuator/health + port: 8082 + initialDelaySeconds: 10 + periodSeconds: 5 diff --git a/healthsync/deployment/manifest/deployment/intelligence-service-deployment.yaml b/healthsync/deployment/manifest/deployment/intelligence-service-deployment.yaml new file mode 100644 index 0000000..35a3c2c --- /dev/null +++ b/healthsync/deployment/manifest/deployment/intelligence-service-deployment.yaml @@ -0,0 +1,78 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: intelligence-service + namespace: team1tier-healthsync-ns +spec: + replicas: 2 + selector: + matchLabels: + app: intelligence-service + template: + metadata: + labels: + app: intelligence-service + spec: + imagePullSecrets: + - name: acr-secret + containers: + - name: intelligence-service + image: acrhealthsync01.azurecr.io/team1tier/intelligence-service:1.0.0 + imagePullPolicy: Always + envFrom: + - configMapRef: + name: common-config + - configMapRef: + name: intelligence-service-config + - secretRef: + name: common-secret + - secretRef: + name: intelligence-secret + env: + - name: CLAUDE_API_KEY + valueFrom: + secretKeyRef: + name: intelligence-secret + key: CLAUDE_API_KEY + - name: CLAUDE_API_URL + valueFrom: + secretKeyRef: + name: intelligence-secret + key: CLAUDE_API_URL + - name: CLAUDE_MODEL + valueFrom: + secretKeyRef: + name: intelligence-secret + key: CLAUDE_MODEL + - name: CLAUDE_MAX_TOKENS + valueFrom: + secretKeyRef: + name: intelligence-secret + key: CLAUDE_MAX_TOKENS + resources: + requests: + cpu: 256m + memory: 256Mi + limits: + cpu: 1024m + memory: 1024Mi + ports: + - containerPort: 8083 + startupProbe: + httpGet: + path: /actuator/health + port: 8083 + failureThreshold: 30 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /actuator/health + port: 8083 + initialDelaySeconds: 60 + periodSeconds: 15 + readinessProbe: + httpGet: + path: /actuator/health + port: 8083 + initialDelaySeconds: 10 + periodSeconds: 5 diff --git a/healthsync/deployment/manifest/deployment/motivator-service-deployment.yaml b/healthsync/deployment/manifest/deployment/motivator-service-deployment.yaml new file mode 100644 index 0000000..b51de29 --- /dev/null +++ b/healthsync/deployment/manifest/deployment/motivator-service-deployment.yaml @@ -0,0 +1,57 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: motivator-service + namespace: team1tier-healthsync-ns +spec: + replicas: 2 + selector: + matchLabels: + app: motivator-service + template: + metadata: + labels: + app: motivator-service + spec: + imagePullSecrets: + - name: acr-secret + containers: + - name: motivator-service + image: acrhealthsync01.azurecr.io/team1tier/motivator-service:1.0.0 + imagePullPolicy: Always + envFrom: + - configMapRef: + name: common-config + - configMapRef: + name: motivator-service-config + - secretRef: + name: common-secret + - secretRef: + name: servicebus-secret + resources: + requests: + cpu: 256m + memory: 256Mi + limits: + cpu: 1024m + memory: 1024Mi + ports: + - containerPort: 8085 + startupProbe: + httpGet: + path: /actuator/health + port: 8085 + failureThreshold: 30 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /actuator/health + port: 8085 + initialDelaySeconds: 60 + periodSeconds: 15 + readinessProbe: + httpGet: + path: /actuator/health + port: 8085 + initialDelaySeconds: 10 + periodSeconds: 5 diff --git a/healthsync/deployment/manifest/deployment/user-service-deployment.yaml b/healthsync/deployment/manifest/deployment/user-service-deployment.yaml new file mode 100644 index 0000000..e9475cf --- /dev/null +++ b/healthsync/deployment/manifest/deployment/user-service-deployment.yaml @@ -0,0 +1,77 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: user-service + namespace: team1tier-healthsync-ns +spec: + replicas: 2 + selector: + matchLabels: + app: user-service + template: + metadata: + labels: + app: user-service + spec: + imagePullSecrets: + - name: acr-secret + containers: + - name: user-service + image: acrhealthsync01.azurecr.io/team1tier/user-service:1.0.0 + imagePullPolicy: Always + envFrom: + - configMapRef: + name: common-config + - configMapRef: + name: user-service-config + - secretRef: + name: common-secret + env: + # Google OAuth 관련 환경변수 + - name: GOOGLE_OAUTH_CLIENT_ID + valueFrom: + secretKeyRef: + name: google-oauth-secret + key: GOOGLE_OAUTH_CLIENT_ID + - name: GOOGLE_OAUTH_JAVASCRIPT_ORIGINS + valueFrom: + secretKeyRef: + name: google-oauth-secret + key: GOOGLE_OAUTH_JAVASCRIPT_ORIGINS + - name: GOOGLE_TOKEN_VERIFY_URL + valueFrom: + secretKeyRef: + name: google-oauth-secret + key: GOOGLE_TOKEN_VERIFY_URL + - name: GOOGLE_USERINFO_URL + valueFrom: + secretKeyRef: + name: google-oauth-secret + key: GOOGLE_USERINFO_URL + resources: + requests: + cpu: 256m + memory: 256Mi + limits: + cpu: 1024m + memory: 1024Mi + ports: + - containerPort: 8081 + startupProbe: + httpGet: + path: /actuator/health + port: 8081 + failureThreshold: 30 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /actuator/health + port: 8081 + initialDelaySeconds: 60 + periodSeconds: 15 + readinessProbe: + httpGet: + path: /actuator/health + port: 8081 + initialDelaySeconds: 10 + periodSeconds: 5 diff --git a/healthsync/deployment/manifest/ingress/healthsync-ingress.yaml b/healthsync/deployment/manifest/ingress/healthsync-ingress.yaml new file mode 100644 index 0000000..0810515 --- /dev/null +++ b/healthsync/deployment/manifest/ingress/healthsync-ingress.yaml @@ -0,0 +1,63 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: healthsync-ingress + namespace: team1tier-healthsync-ns + annotations: + nginx.ingress.kubernetes.io/ssl-redirect: "false" + nginx.ingress.kubernetes.io/use-regex: "true" +spec: + ingressClassName: nginx + rules: + - host: team1tier.20.214.196.128.nip.io + http: + paths: + - path: /api/auth + pathType: Prefix + backend: + service: + name: user-service + port: + number: 80 + - path: /api/users + pathType: Prefix + backend: + service: + name: user-service + port: + number: 80 + - path: /api/health + pathType: Prefix + backend: + service: + name: health-service + port: + number: 80 + - path: /api/intelligence + pathType: Prefix + backend: + service: + name: intelligence-service + port: + number: 80 + - path: /api/goals + pathType: Prefix + backend: + service: + name: goal-service + port: + number: 80 + - path: /api/motivator + pathType: Prefix + backend: + service: + name: motivator-service + port: + number: 80 + - path: / + pathType: Prefix + backend: + service: + name: api-gateway + port: + number: 80 diff --git a/healthsync/deployment/manifest/secret/common-secret.yaml b/healthsync/deployment/manifest/secret/common-secret.yaml new file mode 100644 index 0000000..93789c3 --- /dev/null +++ b/healthsync/deployment/manifest/secret/common-secret.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Secret +metadata: + name: common-secret + namespace: team1tier-healthsync-ns +type: Opaque +stringData: + POSTGRES_USER: "team1tier" + POSTGRES_PASSWORD: "Hi5Jessica!" + REDIS_PASSWORD: "Hi5Jessica!" + JWT_SECRET_KEY: "zAQ4ojw3Tu/OD9g2NHbnn/OErpmonxOWb9ROsDH9UOU=" diff --git a/healthsync/deployment/manifest/secret/google-oauth-secret.yaml b/healthsync/deployment/manifest/secret/google-oauth-secret.yaml new file mode 100644 index 0000000..9a04104 --- /dev/null +++ b/healthsync/deployment/manifest/secret/google-oauth-secret.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: Secret +metadata: + name: google-oauth-secret + namespace: team1tier-healthsync-ns +type: Opaque +stringData: + GOOGLE_OAUTH_CLIENT_ID: "198383870460-s1s72vgu91nq9qvg5dai28vafj7mlag1.apps.googleusercontent.com" + GOOGLE_OAUTH_CLIENT_SECRET: "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" + GOOGLE_OAUTH_JAVASCRIPT_ORIGINS: "http://team1tier.20.214.196.128.nip.io" + GOOGLE_OAUTH_SCOPE: "openid email profile" + GOOGLE_TOKEN_VERIFY_URL: "https://oauth2.googleapis.com/tokeninfo" + GOOGLE_USERINFO_URL: "https://www.googleapis.com/oauth2/v2/userinfo" diff --git a/healthsync/deployment/manifest/secret/intelligence-secret.yaml b/healthsync/deployment/manifest/secret/intelligence-secret.yaml new file mode 100644 index 0000000..e7fad63 --- /dev/null +++ b/healthsync/deployment/manifest/secret/intelligence-secret.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Secret +metadata: + name: intelligence-secret + namespace: team1tier-healthsync-ns +type: Opaque +stringData: + CLAUDE_API_KEY: "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" + CLAUDE_API_URL: "https://api.anthropic.com/v1/messages" + CLAUDE_MODEL: "claude-3-sonnet-20240229" + CLAUDE_MAX_TOKENS: "1024" diff --git a/healthsync/deployment/manifest/secret/servicebus-secret.yaml b/healthsync/deployment/manifest/secret/servicebus-secret.yaml new file mode 100644 index 0000000..128caa8 --- /dev/null +++ b/healthsync/deployment/manifest/secret/servicebus-secret.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: Secret +metadata: + name: servicebus-secret + namespace: team1tier-healthsync-ns +type: Opaque +stringData: + SERVICE_BUS_CONNECTION_STRING: "Endpoint=sb://sb-healthsync.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=Zur5rLIi8MNQ8sk3T/TvsdVu+i02bbxaE+ASbCAXvZI=" diff --git a/healthsync/deployment/manifest/service/services.yaml b/healthsync/deployment/manifest/service/services.yaml new file mode 100644 index 0000000..fe76d13 --- /dev/null +++ b/healthsync/deployment/manifest/service/services.yaml @@ -0,0 +1,89 @@ +--- +apiVersion: v1 +kind: Service +metadata: + name: api-gateway + namespace: team1tier-healthsync-ns +spec: + type: ClusterIP + ports: + - port: 80 + targetPort: 8080 + protocol: TCP + selector: + app: api-gateway + +--- +apiVersion: v1 +kind: Service +metadata: + name: user-service + namespace: team1tier-healthsync-ns +spec: + type: ClusterIP + ports: + - port: 80 + targetPort: 8081 + protocol: TCP + selector: + app: user-service + +--- +apiVersion: v1 +kind: Service +metadata: + name: health-service + namespace: team1tier-healthsync-ns +spec: + type: ClusterIP + ports: + - port: 80 + targetPort: 8082 + protocol: TCP + selector: + app: health-service + +--- +apiVersion: v1 +kind: Service +metadata: + name: intelligence-service + namespace: team1tier-healthsync-ns +spec: + type: ClusterIP + ports: + - port: 80 + targetPort: 8083 + protocol: TCP + selector: + app: intelligence-service + +--- +apiVersion: v1 +kind: Service +metadata: + name: goal-service + namespace: team1tier-healthsync-ns +spec: + type: ClusterIP + ports: + - port: 80 + targetPort: 8084 + protocol: TCP + selector: + app: goal-service + +--- +apiVersion: v1 +kind: Service +metadata: + name: motivator-service + namespace: team1tier-healthsync-ns +spec: + type: ClusterIP + ports: + - port: 80 + targetPort: 8085 + protocol: TCP + selector: + app: motivator-service diff --git a/motivator-service/build.gradle b/motivator-service/build.gradle new file mode 100644 index 0000000..89e80e5 --- /dev/null +++ b/motivator-service/build.gradle @@ -0,0 +1,39 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.4.0' + id 'io.spring.dependency-management' version '1.1.4' +} + +group = 'com.healthsync' +version = '1.0.0' + +java { + sourceCompatibility = '17' +} + +configurations { + compileOnly { + extendsFrom annotationProcessor + } +} + +dependencies { + implementation project(':common') + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-webflux' + implementation 'org.springframework.boot:spring-boot-starter-batch' + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0' + + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.batch:spring-batch-test' +} + +tasks.named('test') { + useJUnitPlatform() +} diff --git a/motivator-service/src/main/java/com/healthsync/motivator/MotivatorServiceApplication.java b/motivator-service/src/main/java/com/healthsync/motivator/MotivatorServiceApplication.java new file mode 100644 index 0000000..20f6bb0 --- /dev/null +++ b/motivator-service/src/main/java/com/healthsync/motivator/MotivatorServiceApplication.java @@ -0,0 +1,25 @@ +package com.healthsync.motivator; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +import org.springframework.batch.core.configuration.annotation.EnableBatchProcessing; +import org.springframework.scheduling.annotation.EnableScheduling; + +/** + * Motivator Service의 메인 애플리케이션 클래스입니다. + * 사용자 동기부여 메시지 생성 및 배치 알림 처리 기능을 제공합니다. + * + * @author healthsync-team + * @version 1.0 + */ +@SpringBootApplication(scanBasePackages = {"com.healthsync.motivator", "com.healthsync.common"}) +@ConfigurationPropertiesScan +@EnableBatchProcessing +@EnableScheduling +public class MotivatorServiceApplication { + + public static void main(String[] args) { + SpringApplication.run(MotivatorServiceApplication.class, args); + } +} diff --git a/motivator-service/src/main/java/com/healthsync/motivator/application_services/MotivationUseCase.java b/motivator-service/src/main/java/com/healthsync/motivator/application_services/MotivationUseCase.java new file mode 100644 index 0000000..342842a --- /dev/null +++ b/motivator-service/src/main/java/com/healthsync/motivator/application_services/MotivationUseCase.java @@ -0,0 +1,216 @@ +package com.healthsync.motivator.application_services; + +import com.healthsync.motivator.domain.services.UserAnalysisDomainService; +import com.healthsync.motivator.domain.services.MessageGenerationDomainService; +import com.healthsync.motivator.domain.services.BatchProcessingDomainService; +import com.healthsync.motivator.domain.repositories.NotificationRepository; +import com.healthsync.motivator.dto.*; +import com.healthsync.motivator.infrastructure.ports.*; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 동기부여 관련 유스케이스입니다. + * Clean Architecture의 Application Service 계층에 해당합니다. + * + * @author healthsync-team + * @version 1.0 + */ +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional +public class MotivationUseCase { + + private final UserAnalysisDomainService userAnalysisDomainService; + private final MessageGenerationDomainService messageGenerationDomainService; + private final BatchProcessingDomainService batchProcessingDomainService; + private final NotificationRepository notificationRepository; + private final GoalServicePort goalServicePort; + private final ClaudeApiPort claudeApiPort; + private final CachePort cachePort; + private final EventPublisherPort eventPublisherPort; + + /** + * 독려 메시지를 생성합니다. + * + * @param request 독려 요청 + * @return 독려 응답 + */ + public EncouragementResponse generateEncouragementMessage(EncouragementRequest request) { + log.info("독려 메시지 생성 시작: userId={}", request.getUserId()); + + // 요청 검증 + validateEncouragementRequest(request); + + // 캐시에서 기존 메시지 확인 + String cacheKey = generateCacheKey(request.getUserId(), request.getMissionsStatus()); + EncouragementResponse cachedResponse = cachePort.getCachedEncouragementMessage(cacheKey); + if (cachedResponse != null) { + log.info("캐시에서 독려 메시지 조회: userId={}", request.getUserId()); + return cachedResponse; + } + + // 사용자 미션 진행 상황 분석 + DailyProgress dailyProgress = goalServicePort.getUserDailyProgress(request.getUserId()); + ProgressAnalysis progressAnalysis = userAnalysisDomainService.analyzeMissionProgress( + request.getUserId(), request.getMissionsStatus(), dailyProgress); + + // AI 독려 메시지 생성 + String encouragementMessage = messageGenerationDomainService.generateEncouragementMessage( + progressAnalysis, claudeApiPort); + + // 응답 구성 + EncouragementResponse response = EncouragementResponse.builder() + .message(encouragementMessage) + .motivationType(progressAnalysis.getMotivationType().name()) + .timing(calculateOptimalTiming(progressAnalysis)) + .personalizedTip(generatePersonalizedTip(progressAnalysis)) + .priority(progressAnalysis.getUrgencyLevel().name()) + .build(); + + // 캐시에 저장 + cachePort.cacheEncouragementMessage(cacheKey, response); + + // 알림 로그 저장 + notificationRepository.saveNotificationLog(request.getUserId(), null, "encouragement", encouragementMessage); + + // 이벤트 발행 + eventPublisherPort.publishEncouragementSentEvent(request.getUserId(), "encouragement"); + + log.info("독려 메시지 생성 완료: userId={}, motivationType={}", + request.getUserId(), response.getMotivationType()); + return response; + } + + /** + * 배치 알림을 처리합니다. + * + * @param request 배치 알림 요청 + * @return 배치 처리 결과 + */ + public BatchNotificationResponse processBatchNotifications(BatchNotificationRequest request) { + log.info("배치 알림 처리 시작: triggerTime={}, targetCount={}", + request.getTriggerTime(), request.getTargetUsers().size()); + + // 배치 처리 시작 + String batchId = generateBatchId(); + LocalDateTime startTime = LocalDateTime.now(); + + // 전체 사용자 미션 상태 조회 + List allUsersStatus = goalServicePort.getAllUsersWithActiveMissions(); + + // 대상 사용자 필터링 + List targetUsers = batchProcessingDomainService + .filterUsersNeedingNotification(allUsersStatus, request); + + // 우선순위별 정렬 + List prioritizedUsers = batchProcessingDomainService + .prioritizeUsersByUrgency(targetUsers); + + // 배치 처리 실행 + BatchProcessingResult result = batchProcessingDomainService + .processBatchNotifications(prioritizedUsers, batchId, claudeApiPort, cachePort); + + // 처리 결과 로깅 + LocalDateTime endTime = LocalDateTime.now(); + log.info("배치 알림 처리 완료: batchId={}, processed={}, success={}, failed={}, duration={}ms", + batchId, result.getProcessedCount(), result.getSuccessCount(), + result.getFailedCount(), java.time.Duration.between(startTime, endTime).toMillis()); + + // 다음 스케줄 시간 계산 + String nextScheduledTime = calculateNextScheduledTime(request.getTriggerTime()); + + return BatchNotificationResponse.builder() + .batchId(batchId) + .processedCount(result.getProcessedCount()) + .successCount(result.getSuccessCount()) + .failedCount(result.getFailedCount()) + .nextScheduledTime(nextScheduledTime) + .build(); + } + + /** + * 독려 요청을 검증합니다. + * + * @param request 독려 요청 + */ + private void validateEncouragementRequest(EncouragementRequest request) { + if (request.getUserId() == null || request.getUserId().trim().isEmpty()) { + throw new IllegalArgumentException("사용자 ID는 필수입니다."); + } + + if (request.getMissionsStatus() == null || request.getMissionsStatus().isEmpty()) { + throw new IllegalArgumentException("미션 상태 정보는 필수입니다."); + } + } + + /** + * 캐시 키를 생성합니다. + * + * @param userId 사용자 ID + * @param missionsStatus 미션 상태 목록 + * @return 캐시 키 + */ + private String generateCacheKey(String userId, List missionsStatus) { + // 미션 완료 상태를 바탕으로 캐시 키 생성 + long completedCount = missionsStatus.stream().mapToLong(ms -> ms.isCompleted() ? 1 : 0).sum(); + double completionRate = (double) completedCount / missionsStatus.size(); + + return String.format("encouragement:%s:%.1f", userId, completionRate); + } + + /** + * 최적 타이밍을 계산합니다. + * + * @param progressAnalysis 진행 분석 + * @return 최적 타이밍 + */ + private String calculateOptimalTiming(ProgressAnalysis progressAnalysis) { + return switch (progressAnalysis.getUrgencyLevel()) { + case HIGH -> "즉시"; + case MEDIUM -> "1시간 후"; + case LOW -> "내일 오전"; + }; + } + + /** + * 개인화된 팁을 생성합니다. + * + * @param progressAnalysis 진행 분석 + * @return 개인화된 팁 + */ + private String generatePersonalizedTip(ProgressAnalysis progressAnalysis) { + return switch (progressAnalysis.getMotivationType()) { + case ACHIEVEMENT -> "작은 목표부터 차근차근 달성해보세요!"; + case SOCIAL -> "친구들과 함께 도전하면 더 재미있어요!"; + case HEALTH_BENEFIT -> "건강한 습관이 당신의 미래를 바꿉니다!"; + case HABIT_FORMATION -> "21일만 지속하면 습관이 됩니다!"; + }; + } + + /** + * 배치 ID를 생성합니다. + * + * @return 배치 ID + */ + private String generateBatchId() { + return "batch_" + System.currentTimeMillis(); + } + + /** + * 다음 스케줄 시간을 계산합니다. + * + * @param triggerTime 현재 트리거 시간 + * @return 다음 스케줄 시간 + */ + private String calculateNextScheduledTime(String triggerTime) { + // 실제 구현에서는 cron 표현식 등을 활용하여 계산 + return LocalDateTime.now().plusHours(2).toString(); + } +} diff --git a/motivator-service/src/main/java/com/healthsync/motivator/config/MotivatorSecurityConfig.java b/motivator-service/src/main/java/com/healthsync/motivator/config/MotivatorSecurityConfig.java new file mode 100644 index 0000000..60e6f45 --- /dev/null +++ b/motivator-service/src/main/java/com/healthsync/motivator/config/MotivatorSecurityConfig.java @@ -0,0 +1,65 @@ +package com.healthsync.motivator.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.Arrays; + +/** + * Motivator Service의 보안 설정을 관리하는 클래스입니다. + * + * @author healthsync-team + * @version 1.0 + */ +@Configuration +@EnableWebSecurity +public class MotivatorSecurityConfig { + + /** + * Security Filter Chain을 구성합니다. + * + * @param http HttpSecurity + * @return SecurityFilterChain + * @throws Exception 예외 + */ + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/swagger-ui/**", "/api-docs/**").permitAll() + .requestMatchers("/actuator/**").permitAll() + .anyRequest().authenticated() + ); + + return http.build(); + } + + /** + * CORS 설정을 구성합니다. + * + * @return CorsConfigurationSource + */ + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowedOriginPatterns(Arrays.asList("*")); + configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS")); + configuration.setAllowedHeaders(Arrays.asList("*")); + configuration.setAllowCredentials(true); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } +} diff --git a/motivator-service/src/main/java/com/healthsync/motivator/domain/repositories/NotificationRepository.java b/motivator-service/src/main/java/com/healthsync/motivator/domain/repositories/NotificationRepository.java new file mode 100644 index 0000000..888a6a4 --- /dev/null +++ b/motivator-service/src/main/java/com/healthsync/motivator/domain/repositories/NotificationRepository.java @@ -0,0 +1,43 @@ +package com.healthsync.motivator.domain.repositories; + +import com.healthsync.motivator.infrastructure.entities.NotificationLogEntity; + +import java.util.List; + +/** + * 알림 데이터 저장소 인터페이스입니다. + * Clean Architecture의 Domain 계층에서 정의합니다. + * + * @author healthsync-team + * @version 1.0 + */ +public interface NotificationRepository { + + /** + * 알림 로그를 저장합니다. + * + * @param userId 사용자 ID + * @param missionId 미션 ID + * @param notificationType 알림 유형 + * @param message 메시지 + * @return 저장된 알림 로그 엔티티 + */ + NotificationLogEntity saveNotificationLog(String userId, String missionId, String notificationType, String message); + + /** + * 사용자의 최근 알림 로그를 조회합니다. + * + * @param userId 사용자 ID + * @param limit 조회할 최대 수 + * @return 알림 로그 목록 + */ + List findRecentNotificationLogs(String userId, int limit); + + /** + * 배치 알림 로그를 조회합니다. + * + * @param batchId 배치 ID + * @return 알림 로그 목록 + */ + List findNotificationLogsByBatchId(String batchId); +} diff --git a/motivator-service/src/main/java/com/healthsync/motivator/domain/services/BatchProcessingDomainService.java b/motivator-service/src/main/java/com/healthsync/motivator/domain/services/BatchProcessingDomainService.java new file mode 100644 index 0000000..686981f --- /dev/null +++ b/motivator-service/src/main/java/com/healthsync/motivator/domain/services/BatchProcessingDomainService.java @@ -0,0 +1,285 @@ +package com.healthsync.motivator.domain.services; + +import com.healthsync.motivator.dto.*; +import com.healthsync.motivator.infrastructure.ports.ClaudeApiPort; +import com.healthsync.motivator.infrastructure.ports.CachePort; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.time.LocalTime; +import java.util.Comparator; +import java.util.List; +import java.util.stream.Collectors; + +/** + * 배치 처리를 담당하는 도메인 서비스입니다. + * + * @author healthsync-team + * @version 1.0 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class BatchProcessingDomainService { + + private final MessageGenerationDomainService messageGenerationDomainService; + private final UserAnalysisDomainService userAnalysisDomainService; + + /** + * 알림이 필요한 사용자를 필터링합니다. + * + * @param allUsers 전체 사용자 목록 + * @param request 배치 요청 + * @return 필터링된 사용자 목록 + */ + public List filterUsersNeedingNotification( + List allUsers, BatchNotificationRequest request) { + + LocalTime currentTime = LocalTime.now(); + + return allUsers.stream() + .filter(user -> isEligibleForNotification(user, currentTime)) + .filter(user -> request.getTargetUsers().isEmpty() || + request.getTargetUsers().contains(user.getUserId())) + .collect(Collectors.toList()); + } + + /** + * 사용자를 긴급도별로 우선순위를 정렬합니다. + * + * @param users 사용자 목록 + * @return 우선순위별 정렬된 사용자 목록 + */ + public List prioritizeUsersByUrgency(List users) { + return users.stream() + .sorted(Comparator + .comparing((UserMissionStatus u) -> calculateUrgencyScore(u)) + .reversed() + .thenComparing(UserMissionStatus::getLastActiveTime)) + .collect(Collectors.toList()); + } + + /** + * 배치 알림을 처리합니다. + * + * @param prioritizedUsers 우선순위별 사용자 목록 + * @param batchId 배치 ID + * @param claudeApiPort Claude API 포트 + * @param cachePort 캐시 포트 + * @return 배치 처리 결과 + */ + public BatchProcessingResult processBatchNotifications( + List prioritizedUsers, + String batchId, + ClaudeApiPort claudeApiPort, + CachePort cachePort) { + + log.info("배치 알림 처리 시작: batchId={}, userCount={}", batchId, prioritizedUsers.size()); + + int processedCount = 0; + int successCount = 0; + int failedCount = 0; + + for (UserMissionStatus userStatus : prioritizedUsers) { + try { + processedCount++; + + // 사용자별 알림 컨텍스트 분석 + UserNotificationContext context = analyzeUserNotificationContext(userStatus); + + // 개인화된 배치 메시지 생성 + String message = generateBatchEncouragementMessage(context, claudeApiPort); + + // 배치 메시지 캐시에 저장 + cachePort.storeBatchMessage(userStatus.getUserId(), message); + + successCount++; + + log.debug("배치 알림 처리 성공: userId={}, batchId={}", userStatus.getUserId(), batchId); + + } catch (Exception e) { + failedCount++; + log.error("배치 알림 처리 실패: userId={}, batchId={}, error={}", + userStatus.getUserId(), batchId, e.getMessage(), e); + } + } + + log.info("배치 알림 처리 완료: batchId={}, processed={}, success={}, failed={}", + batchId, processedCount, successCount, failedCount); + + return BatchProcessingResult.builder() + .batchId(batchId) + .processedCount(processedCount) + .successCount(successCount) + .failedCount(failedCount) + .build(); + } + + /** + * 알림 대상 여부를 확인합니다. + * + * @param user 사용자 미션 상태 + * @param currentTime 현재 시간 + * @return 알림 대상 여부 + */ + private boolean isEligibleForNotification(UserMissionStatus user, LocalTime currentTime) { + // 미션 완료율이 낮거나 연속 실패 시 알림 대상 + double completionRate = calculateCompletionRate(user); + int consecutiveFailures = calculateConsecutiveFailures(user); + + // 조용한 시간대 확인 (밤 10시 ~ 오전 8시) + boolean isQuietHours = currentTime.isBefore(LocalTime.of(8, 0)) || + currentTime.isAfter(LocalTime.of(22, 0)); + + return (completionRate < 0.6 || consecutiveFailures >= 2) && !isQuietHours; + } + + /** + * 긴급도 점수를 계산합니다. + * + * @param user 사용자 미션 상태 + * @return 긴급도 점수 (높을수록 우선순위 높음) + */ + private int calculateUrgencyScore(UserMissionStatus user) { + int score = 0; + + // 완료율이 낮을수록 높은 점수 + double completionRate = calculateCompletionRate(user); + score += (int) ((1.0 - completionRate) * 50); + + // 연속 실패 일수에 따른 점수 + int consecutiveFailures = calculateConsecutiveFailures(user); + score += consecutiveFailures * 20; + + // 마지막 활동 시간이 오래될수록 높은 점수 + long daysSinceLastActive = java.time.Duration.between( + user.getLastActiveTime(), java.time.LocalDateTime.now()).toDays(); + score += (int) Math.min(daysSinceLastActive * 5, 30); + + return score; + } + + /** + * 완료율을 계산합니다. + * + * @param user 사용자 미션 상태 + * @return 완료율 + */ + private double calculateCompletionRate(UserMissionStatus user) { + if (user.getTotalMissions() == 0) return 0.0; + return (double) user.getCompletedMissions() / user.getTotalMissions(); + } + + /** + * 연속 실패 일수를 계산합니다. + * + * @param user 사용자 미션 상태 + * @return 연속 실패 일수 + */ + private int calculateConsecutiveFailures(UserMissionStatus user) { + // 실제 구현에서는 사용자의 최근 미션 이력을 분석 + // Mock 데이터로 계산 + double completionRate = calculateCompletionRate(user); + if (completionRate < 0.3) return 3; + else if (completionRate < 0.5) return 2; + else if (completionRate < 0.7) return 1; + else return 0; + } + + /** + * 사용자 알림 컨텍스트를 분석합니다. + * + * @param userStatus 사용자 미션 상태 + * @return 사용자 알림 컨텍스트 + */ + private UserNotificationContext analyzeUserNotificationContext(UserMissionStatus userStatus) { + return UserNotificationContext.builder() + .userId(userStatus.getUserId()) + .completionRate(calculateCompletionRate(userStatus)) + .consecutiveFailures(calculateConsecutiveFailures(userStatus)) + .lastActiveTime(userStatus.getLastActiveTime()) + .totalMissions(userStatus.getTotalMissions()) + .completedMissions(userStatus.getCompletedMissions()) + .build(); + } + + /** + * 배치 독려 메시지를 생성합니다. + * + * @param context 사용자 알림 컨텍스트 + * @param claudeApiPort Claude API 포트 + * @return 배치 독려 메시지 + */ + private String generateBatchEncouragementMessage(UserNotificationContext context, ClaudeApiPort claudeApiPort) { + try { + String prompt = prepareBatchPrompt(context); + String aiResponse = claudeApiPort.callClaudeApi(prompt); + return addPersonalizedTouch(aiResponse, context); + } catch (Exception e) { + log.warn("배치 AI 메시지 생성 실패, 기본 메시지 사용: userId={}", context.getUserId()); + return generateFallbackBatchMessage(context); + } + } + + /** + * 배치 프롬프트를 준비합니다. + * + * @param context 사용자 알림 컨텍스트 + * @return 배치 프롬프트 + */ + private String prepareBatchPrompt(UserNotificationContext context) { + return String.format(""" + 건강 코치로서 사용자에게 따뜻한 배치 알림 메시지를 작성해주세요. + + [상황] + - 완료율: %.1f%% + - 연속 실패: %d일 + - 마지막 활동: %s + + [요구사항] + - 80자 이내 간결한 메시지 + - 부담스럽지 않고 격려하는 톤 + - 오늘 다시 시작할 수 있다는 희망적 메시지 + - 이모지 활용하여 친근함 표현 + """, + context.getCompletionRate() * 100, + context.getConsecutiveFailures(), + context.getLastActiveTime().toLocalDate() + ); + } + + /** + * 개인화된 터치를 추가합니다. + * + * @param message 기본 메시지 + * @param context 사용자 컨텍스트 + * @return 개인화된 메시지 + */ + private String addPersonalizedTouch(String message, UserNotificationContext context) { + // 메시지 길이 제한 및 개인화 요소 추가 + String personalizedMessage = message.trim(); + + if (personalizedMessage.length() > 80) { + personalizedMessage = personalizedMessage.substring(0, 77) + "..."; + } + + return personalizedMessage; + } + + /** + * 기본 배치 메시지를 생성합니다. + * + * @param context 사용자 컨텍스트 + * @return 기본 배치 메시지 + */ + private String generateFallbackBatchMessage(UserNotificationContext context) { + if (context.getConsecutiveFailures() >= 3) { + return "🌅 새로운 하루, 새로운 시작! 작은 한 걸음부터 다시 시작해봐요!"; + } else if (context.getCompletionRate() < 0.5) { + return "💪 포기하지 마세요! 오늘부터 다시 건강한 습관을 만들어가요!"; + } else { + return "🎯 조금만 더 힘내세요! 건강한 목표까지 거의 다 왔어요!"; + } + } +} diff --git a/motivator-service/src/main/java/com/healthsync/motivator/domain/services/MessageGenerationDomainService.java b/motivator-service/src/main/java/com/healthsync/motivator/domain/services/MessageGenerationDomainService.java new file mode 100644 index 0000000..b6e8f3c --- /dev/null +++ b/motivator-service/src/main/java/com/healthsync/motivator/domain/services/MessageGenerationDomainService.java @@ -0,0 +1,137 @@ +package com.healthsync.motivator.domain.services; + +import com.healthsync.motivator.dto.ProgressAnalysis; +import com.healthsync.motivator.infrastructure.ports.ClaudeApiPort; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +/** + * 메시지 생성을 담당하는 도메인 서비스입니다. + * + * @author healthsync-team + * @version 1.0 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class MessageGenerationDomainService { + + /** + * 독려 메시지를 생성합니다. + * + * @param progressAnalysis 진행 분석 + * @param claudeApiPort Claude API 포트 + * @return 독려 메시지 + */ + public String generateEncouragementMessage(ProgressAnalysis progressAnalysis, ClaudeApiPort claudeApiPort) { + log.info("독려 메시지 생성: userId={}, motivationType={}", + progressAnalysis.getUserId(), progressAnalysis.getMotivationType()); + + try { + // AI 프롬프트 준비 + String prompt = prepareEncouragementPrompt(progressAnalysis); + + // Claude API 호출 + String aiResponse = claudeApiPort.callClaudeApi(prompt); + + // 응답 포맷팅 + return formatEncouragementResponse(aiResponse); + + } catch (Exception e) { + log.warn("AI 메시지 생성 실패, 기본 메시지 사용: userId={}, error={}", + progressAnalysis.getUserId(), e.getMessage()); + return useFallbackMessage(progressAnalysis); + } + } + + /** + * 독려 프롬프트를 준비합니다. + * + * @param analysis 진행 분석 + * @return 프롬프트 + */ + private String prepareEncouragementPrompt(ProgressAnalysis analysis) { + return String.format(""" + 당신은 친근하고 따뜻한 건강 코치입니다. 다음 정보를 바탕으로 개인화된 독려 메시지를 생성해주세요. + + [사용자 진행 상황] + - 완료율: %.1f%% (%d/%d 미션 완료) + - 연속 달성 일수: %d일 + - 동기부여 유형: %s + - 긴급도: %s + - 주간 완료율: %.1f%% + + [실패한 미션] + %s + + [요구사항] + - 100자 이내의 간결한 메시지 + - %s 스타일의 동기부여 + - 구체적인 행동 제안 포함 + - 긍정적이고 격려하는 톤 + - 이모지 사용하여 친근감 표현 + """, + analysis.getCompletionRate() * 100, + analysis.getCompletedMissionsCount(), + analysis.getTotalMissionsCount(), + analysis.getStreakDays(), + analysis.getMotivationType(), + analysis.getUrgencyLevel(), + analysis.getWeeklyCompletionRate() * 100, + analysis.getFailurePoints().isEmpty() ? "없음" : String.join(", ", analysis.getFailurePoints()), + getMotivationStyleDescription(analysis.getMotivationType()) + ); + } + + /** + * AI 응답을 포맷팅합니다. + * + * @param aiResponse AI 응답 + * @return 포맷팅된 메시지 + */ + private String formatEncouragementResponse(String aiResponse) { + // AI 응답에서 불필요한 부분 제거 및 길이 제한 + String cleaned = aiResponse.trim() + .replaceAll("\\n+", " ") + .replaceAll("\\s+", " "); + + if (cleaned.length() > 100) { + cleaned = cleaned.substring(0, 97) + "..."; + } + + return cleaned; + } + + /** + * 기본 메시지를 사용합니다. + * + * @param analysis 진행 분석 + * @return 기본 독려 메시지 + */ + private String useFallbackMessage(ProgressAnalysis analysis) { + return switch (analysis.getMotivationType()) { + case ACHIEVEMENT -> String.format("🎯 현재 %.0f%% 달성! 목표까지 조금만 더 화이팅!", + analysis.getCompletionRate() * 100); + case HABIT_FORMATION -> String.format("💪 %d일 연속 도전 중! 습관 만들기까지 파이팅!", + analysis.getStreakDays()); + case SOCIAL -> "👥 함께라면 더 멀리 갈 수 있어요! 오늘도 건강한 하루 만들어봐요!"; + case HEALTH_BENEFIT -> "🌟 건강한 변화는 작은 실천에서 시작됩니다. 오늘 한 걸음 더!"; + }; + } + + /** + * 동기부여 스타일 설명을 반환합니다. + * + * @param motivationType 동기부여 유형 + * @return 스타일 설명 + */ + private String getMotivationStyleDescription(com.healthsync.motivator.enums.MotivationType motivationType) { + return switch (motivationType) { + case ACHIEVEMENT -> "성취감 중심"; + case HABIT_FORMATION -> "습관 형성 중심"; + case SOCIAL -> "사회적 동기 중심"; + case HEALTH_BENEFIT -> "건강 효과 중심"; + }; + } +} diff --git a/motivator-service/src/main/java/com/healthsync/motivator/domain/services/UserAnalysisDomainService.java b/motivator-service/src/main/java/com/healthsync/motivator/domain/services/UserAnalysisDomainService.java new file mode 100644 index 0000000..c84ed3d --- /dev/null +++ b/motivator-service/src/main/java/com/healthsync/motivator/domain/services/UserAnalysisDomainService.java @@ -0,0 +1,150 @@ +package com.healthsync.motivator.domain.services; + +import com.healthsync.motivator.dto.*; +import com.healthsync.motivator.enums.MotivationType; +import com.healthsync.motivator.enums.UrgencyLevel; +import com.healthsync.motivator.enums.EngagementLevel; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.List; + +/** + * 사용자 분석을 담당하는 도메인 서비스입니다. + * + * @author healthsync-team + * @version 1.0 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class UserAnalysisDomainService { + + /** + * 미션 진행 상황을 분석합니다. + * + * @param userId 사용자 ID + * @param missionsStatus 미션 상태 목록 + * @param dailyProgress 일일 진행 상황 + * @return 진행 분석 결과 + */ + public ProgressAnalysis analyzeMissionProgress(String userId, List missionsStatus, DailyProgress dailyProgress) { + log.info("미션 진행 상황 분석: userId={}, missionCount={}", userId, missionsStatus.size()); + + // 진행률 계산 + long completedCount = missionsStatus.stream().mapToLong(ms -> ms.isCompleted() ? 1 : 0).sum(); + double completionRate = (double) completedCount / missionsStatus.size(); + + // 실패 포인트 식별 + List failurePoints = identifyFailurePoints(missionsStatus); + + // 진행 패턴 계산 + String progressPattern = calculateProgressPatterns(dailyProgress); + + // 동기부여 유형 결정 + MotivationType motivationType = determineMotivationType(completionRate, progressPattern); + + // 긴급도 수준 결정 + UrgencyLevel urgencyLevel = determineUrgencyLevel(completionRate, failurePoints.size()); + + // 참여도 수준 결정 + EngagementLevel engagementLevel = determineEngagementLevel(dailyProgress); + + return ProgressAnalysis.builder() + .userId(userId) + .completionRate(completionRate) + .completedMissionsCount((int) completedCount) + .totalMissionsCount(missionsStatus.size()) + .failurePoints(failurePoints) + .progressPattern(progressPattern) + .motivationType(motivationType) + .urgencyLevel(urgencyLevel) + .engagementLevel(engagementLevel) + .streakDays(dailyProgress.getCurrentStreak()) + .build(); + } + + /** + * 실패 포인트를 식별합니다. + * + * @param missionsStatus 미션 상태 목록 + * @return 실패 포인트 목록 + */ + private List identifyFailurePoints(List missionsStatus) { + return missionsStatus.stream() + .filter(ms -> !ms.isCompleted()) + .map(MissionStatus::getMissionId) + .toList(); + } + + /** + * 진행 패턴을 계산합니다. + * + * @param dailyProgress 일일 진행 상황 + * @return 진행 패턴 + */ + private String calculateProgressPatterns(DailyProgress dailyProgress) { + if (dailyProgress.getCurrentStreak() >= 7) { + return "consistent_high_performer"; + } else if (dailyProgress.getCurrentStreak() >= 3) { + return "steady_improver"; + } else if (dailyProgress.getWeeklyCompletionRate() >= 0.7) { + return "weekend_warrior"; + } else { + return "needs_support"; + } + } + + /** + * 동기부여 유형을 결정합니다. + * + * @param completionRate 완료율 + * @param progressPattern 진행 패턴 + * @return 동기부여 유형 + */ + private MotivationType determineMotivationType(double completionRate, String progressPattern) { + if (completionRate >= 0.8) { + return MotivationType.ACHIEVEMENT; + } else if (completionRate >= 0.5) { + return MotivationType.HABIT_FORMATION; + } else if ("weekend_warrior".equals(progressPattern)) { + return MotivationType.SOCIAL; + } else { + return MotivationType.HEALTH_BENEFIT; + } + } + + /** + * 긴급도 수준을 결정합니다. + * + * @param completionRate 완료율 + * @param failureCount 실패 개수 + * @return 긴급도 수준 + */ + private UrgencyLevel determineUrgencyLevel(double completionRate, int failureCount) { + if (completionRate < 0.3 || failureCount >= 3) { + return UrgencyLevel.HIGH; + } else if (completionRate < 0.6 || failureCount >= 2) { + return UrgencyLevel.MEDIUM; + } else { + return UrgencyLevel.LOW; + } + } + + /** + * 참여도 수준을 결정합니다. + * + * @param dailyProgress 일일 진행 상황 + * @return 참여도 수준 + */ + private EngagementLevel determineEngagementLevel(DailyProgress dailyProgress) { + if (dailyProgress.getWeeklyCompletionRate() >= 0.8) { + return EngagementLevel.HIGH; + } else if (dailyProgress.getWeeklyCompletionRate() >= 0.5) { + return EngagementLevel.MEDIUM; + } else { + return EngagementLevel.LOW; + } + } +} diff --git a/motivator-service/src/main/java/com/healthsync/motivator/dto/BatchNotificationRequest.java b/motivator-service/src/main/java/com/healthsync/motivator/dto/BatchNotificationRequest.java new file mode 100644 index 0000000..75df8a2 --- /dev/null +++ b/motivator-service/src/main/java/com/healthsync/motivator/dto/BatchNotificationRequest.java @@ -0,0 +1,37 @@ +package com.healthsync.motivator.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import java.util.List; + +/** + * 배치 알림 요청 DTO 클래스입니다. + * + * @author healthsync-team + * @version 1.0 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "배치 알림 요청") +public class BatchNotificationRequest { + + @NotBlank(message = "트리거 시간은 필수입니다.") + @Schema(description = "트리거 시간") + private String triggerTime; + + @NotNull(message = "대상 사용자 목록은 필수입니다.") + @Schema(description = "대상 사용자 ID 목록") + private List targetUsers; + + @NotBlank(message = "알림 유형은 필수입니다.") + @Schema(description = "알림 유형") + private String notificationType; +} \ No newline at end of file diff --git a/motivator-service/src/main/java/com/healthsync/motivator/dto/BatchNotificationResponse.java b/motivator-service/src/main/java/com/healthsync/motivator/dto/BatchNotificationResponse.java new file mode 100644 index 0000000..ce2810a --- /dev/null +++ b/motivator-service/src/main/java/com/healthsync/motivator/dto/BatchNotificationResponse.java @@ -0,0 +1,36 @@ +package com.healthsync.motivator.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 배치 알림 응답 DTO 클래스입니다. + * + * @author healthsync-team + * @version 1.0 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "배치 알림 응답") +public class BatchNotificationResponse { + + @Schema(description = "배치 ID") + private String batchId; + + @Schema(description = "처리된 사용자 수") + private int processedCount; + + @Schema(description = "성공한 알림 수") + private int successCount; + + @Schema(description = "실패한 알림 수") + private int failedCount; + + @Schema(description = "다음 스케줄 시간") + private String nextScheduledTime; +} diff --git a/motivator-service/src/main/java/com/healthsync/motivator/dto/BatchProcessingResult.java b/motivator-service/src/main/java/com/healthsync/motivator/dto/BatchProcessingResult.java new file mode 100644 index 0000000..5eb2a38 --- /dev/null +++ b/motivator-service/src/main/java/com/healthsync/motivator/dto/BatchProcessingResult.java @@ -0,0 +1,33 @@ +package com.healthsync.motivator.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 배치 처리 결과 DTO 클래스입니다. + * + * @author healthsync-team + * @version 1.0 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "배치 처리 결과") +public class BatchProcessingResult { + + @Schema(description = "배치 ID") + private String batchId; + + @Schema(description = "처리된 수") + private int processedCount; + + @Schema(description = "성공한 수") + private int successCount; + + @Schema(description = "실패한 수") + private int failedCount; +} diff --git a/motivator-service/src/main/java/com/healthsync/motivator/dto/DailyProgress.java b/motivator-service/src/main/java/com/healthsync/motivator/dto/DailyProgress.java new file mode 100644 index 0000000..6f9109e --- /dev/null +++ b/motivator-service/src/main/java/com/healthsync/motivator/dto/DailyProgress.java @@ -0,0 +1,33 @@ +package com.healthsync.motivator.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 일일 진행 상황 DTO 클래스입니다. + * + * @author healthsync-team + * @version 1.0 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "일일 진행 상황") +public class DailyProgress { + + @Schema(description = "현재 연속 달성 일수") + private int currentStreak; + + @Schema(description = "주간 완료율") + private double weeklyCompletionRate; + + @Schema(description = "오늘 완료된 미션 수") + private int todayCompletedCount; + + @Schema(description = "오늘 총 미션 수") + private int todayTotalCount; +} diff --git a/motivator-service/src/main/java/com/healthsync/motivator/dto/EncouragementRequest.java b/motivator-service/src/main/java/com/healthsync/motivator/dto/EncouragementRequest.java new file mode 100644 index 0000000..9791351 --- /dev/null +++ b/motivator-service/src/main/java/com/healthsync/motivator/dto/EncouragementRequest.java @@ -0,0 +1,33 @@ +package com.healthsync.motivator.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import java.util.List; + +/** + * 독려 메시지 요청 DTO 클래스입니다. + * + * @author healthsync-team + * @version 1.0 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "독려 메시지 요청") +public class EncouragementRequest { + + @NotBlank(message = "사용자 ID는 필수입니다.") + @Schema(description = "사용자 ID") + private String userId; + + @NotEmpty(message = "미션 상태 정보는 필수입니다.") + @Valid + @Schema(description = "미션 상태 목록") + private List missionsStatus; +} diff --git a/motivator-service/src/main/java/com/healthsync/motivator/dto/EncouragementResponse.java b/motivator-service/src/main/java/com/healthsync/motivator/dto/EncouragementResponse.java new file mode 100644 index 0000000..4cf5dbf --- /dev/null +++ b/motivator-service/src/main/java/com/healthsync/motivator/dto/EncouragementResponse.java @@ -0,0 +1,36 @@ +package com.healthsync.motivator.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 독려 메시지 응답 DTO 클래스입니다. + * + * @author healthsync-team + * @version 1.0 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "독려 메시지 응답") +public class EncouragementResponse { + + @Schema(description = "독려 메시지") + private String message; + + @Schema(description = "동기부여 유형") + private String motivationType; + + @Schema(description = "최적 타이밍") + private String timing; + + @Schema(description = "개인화된 팁") + private String personalizedTip; + + @Schema(description = "우선순위") + private String priority; +} diff --git a/motivator-service/src/main/java/com/healthsync/motivator/dto/MissionStatus.java b/motivator-service/src/main/java/com/healthsync/motivator/dto/MissionStatus.java new file mode 100644 index 0000000..ca5d608 --- /dev/null +++ b/motivator-service/src/main/java/com/healthsync/motivator/dto/MissionStatus.java @@ -0,0 +1,27 @@ +package com.healthsync.motivator.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 미션 상태 DTO 클래스입니다. + * + * @author healthsync-team + * @version 1.0 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "미션 상태") +public class MissionStatus { + + @Schema(description = "미션 ID") + private String missionId; + + @Schema(description = "완료 여부") + private boolean completed; +} diff --git a/motivator-service/src/main/java/com/healthsync/motivator/dto/ProgressAnalysis.java b/motivator-service/src/main/java/com/healthsync/motivator/dto/ProgressAnalysis.java new file mode 100644 index 0000000..0034808 --- /dev/null +++ b/motivator-service/src/main/java/com/healthsync/motivator/dto/ProgressAnalysis.java @@ -0,0 +1,59 @@ +package com.healthsync.motivator.dto; + +import com.healthsync.motivator.enums.MotivationType; +import com.healthsync.motivator.enums.UrgencyLevel; +import com.healthsync.motivator.enums.EngagementLevel; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * 진행 분석 DTO 클래스입니다. + * + * @author healthsync-team + * @version 1.0 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "진행 분석") +public class ProgressAnalysis { + + @Schema(description = "사용자 ID") + private String userId; + + @Schema(description = "완료율") + private double completionRate; + + @Schema(description = "완료된 미션 수") + private int completedMissionsCount; + + @Schema(description = "전체 미션 수") + private int totalMissionsCount; + + @Schema(description = "실패 포인트 목록") + private List failurePoints; + + @Schema(description = "진행 패턴") + private String progressPattern; + + @Schema(description = "동기부여 유형") + private MotivationType motivationType; + + @Schema(description = "긴급도 수준") + private UrgencyLevel urgencyLevel; + + @Schema(description = "참여도 수준") + private EngagementLevel engagementLevel; + + @Schema(description = "연속 달성 일수") + private int streakDays; + + @Schema(description = "주간 완료율") + private double weeklyCompletionRate; +} diff --git a/motivator-service/src/main/java/com/healthsync/motivator/dto/UserMissionStatus.java b/motivator-service/src/main/java/com/healthsync/motivator/dto/UserMissionStatus.java new file mode 100644 index 0000000..92c5c5e --- /dev/null +++ b/motivator-service/src/main/java/com/healthsync/motivator/dto/UserMissionStatus.java @@ -0,0 +1,35 @@ +package com.healthsync.motivator.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 사용자 미션 상태 DTO 클래스입니다. + * + * @author healthsync-team + * @version 1.0 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "사용자 미션 상태") +public class UserMissionStatus { + + @Schema(description = "사용자 ID") + private String userId; + + @Schema(description = "총 미션 수") + private int totalMissions; + + @Schema(description = "완료된 미션 수") + private int completedMissions; + + @Schema(description = "마지막 활동 시간") + private LocalDateTime lastActiveTime; +} diff --git a/motivator-service/src/main/java/com/healthsync/motivator/dto/UserNotificationContext.java b/motivator-service/src/main/java/com/healthsync/motivator/dto/UserNotificationContext.java new file mode 100644 index 0000000..aa2a6f6 --- /dev/null +++ b/motivator-service/src/main/java/com/healthsync/motivator/dto/UserNotificationContext.java @@ -0,0 +1,41 @@ +package com.healthsync.motivator.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 사용자 알림 컨텍스트 DTO 클래스입니다. + * + * @author healthsync-team + * @version 1.0 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "사용자 알림 컨텍스트") +public class UserNotificationContext { + + @Schema(description = "사용자 ID") + private String userId; + + @Schema(description = "완료율") + private double completionRate; + + @Schema(description = "연속 실패 일수") + private int consecutiveFailures; + + @Schema(description = "마지막 활동 시간") + private LocalDateTime lastActiveTime; + + @Schema(description = "총 미션 수") + private int totalMissions; + + @Schema(description = "완료된 미션 수") + private int completedMissions; +} diff --git a/motivator-service/src/main/java/com/healthsync/motivator/enums/EngagementLevel.java b/motivator-service/src/main/java/com/healthsync/motivator/enums/EngagementLevel.java new file mode 100644 index 0000000..07696a0 --- /dev/null +++ b/motivator-service/src/main/java/com/healthsync/motivator/enums/EngagementLevel.java @@ -0,0 +1,25 @@ +package com.healthsync.motivator.enums; + +/** + * 참여도 수준을 나타내는 열거형입니다. + * + * @author healthsync-team + * @version 1.0 + */ +public enum EngagementLevel { + + /** + * 높은 참여도 + */ + HIGH, + + /** + * 보통 참여도 + */ + MEDIUM, + + /** + * 낮은 참여도 + */ + LOW +} diff --git a/motivator-service/src/main/java/com/healthsync/motivator/enums/MotivationType.java b/motivator-service/src/main/java/com/healthsync/motivator/enums/MotivationType.java new file mode 100644 index 0000000..8ce8331 --- /dev/null +++ b/motivator-service/src/main/java/com/healthsync/motivator/enums/MotivationType.java @@ -0,0 +1,30 @@ +package com.healthsync.motivator.enums; + +/** + * 동기부여 유형을 나타내는 열거형입니다. + * + * @author healthsync-team + * @version 1.0 + */ +public enum MotivationType { + + /** + * 성취감 중심 동기부여 + */ + ACHIEVEMENT, + + /** + * 습관 형성 중심 동기부여 + */ + HABIT_FORMATION, + + /** + * 사회적 동기부여 + */ + SOCIAL, + + /** + * 건강 효과 중심 동기부여 + */ + HEALTH_BENEFIT +} diff --git a/motivator-service/src/main/java/com/healthsync/motivator/enums/UrgencyLevel.java b/motivator-service/src/main/java/com/healthsync/motivator/enums/UrgencyLevel.java new file mode 100644 index 0000000..5cddb03 --- /dev/null +++ b/motivator-service/src/main/java/com/healthsync/motivator/enums/UrgencyLevel.java @@ -0,0 +1,25 @@ +package com.healthsync.motivator.enums; + +/** + * 긴급도 수준을 나타내는 열거형입니다. + * + * @author healthsync-team + * @version 1.0 + */ +public enum UrgencyLevel { + + /** + * 높은 긴급도 + */ + HIGH, + + /** + * 보통 긴급도 + */ + MEDIUM, + + /** + * 낮은 긴급도 + */ + LOW +} diff --git a/motivator-service/src/main/java/com/healthsync/motivator/infrastructure/adapters/CacheAdapter.java b/motivator-service/src/main/java/com/healthsync/motivator/infrastructure/adapters/CacheAdapter.java new file mode 100644 index 0000000..b0e5b38 --- /dev/null +++ b/motivator-service/src/main/java/com/healthsync/motivator/infrastructure/adapters/CacheAdapter.java @@ -0,0 +1,56 @@ +package com.healthsync.motivator.infrastructure.adapters; + +import com.healthsync.motivator.dto.EncouragementResponse; +import com.healthsync.motivator.infrastructure.ports.CachePort; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +import java.time.Duration; + +/** + * Redis 캐시와의 통신을 담당하는 어댑터 클래스입니다. + * Clean Architecture의 Infrastructure 계층에 해당합니다. + * + * @author healthsync-team + * @version 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class CacheAdapter implements CachePort { + + private final RedisTemplate redisTemplate; + + @Override + public EncouragementResponse getCachedEncouragementMessage(String cacheKey) { + try { + return (EncouragementResponse) redisTemplate.opsForValue().get(cacheKey); + } catch (Exception e) { + log.warn("독려 메시지 캐시 조회 실패: key={}, error={}", cacheKey, e.getMessage()); + return null; + } + } + + @Override + public void cacheEncouragementMessage(String cacheKey, EncouragementResponse response) { + try { + redisTemplate.opsForValue().set(cacheKey, response, Duration.ofMinutes(30)); + log.info("독려 메시지 캐시 저장: key={}", cacheKey); + } catch (Exception e) { + log.warn("독려 메시지 캐시 저장 실패: key={}, error={}", cacheKey, e.getMessage()); + } + } + + @Override + public void storeBatchMessage(String userId, String message) { + try { + String batchKey = "batch_message:" + userId; + redisTemplate.opsForValue().set(batchKey, message, Duration.ofHours(24)); + log.info("배치 메시지 저장: userId={}", userId); + } catch (Exception e) { + log.warn("배치 메시지 저장 실패: userId={}, error={}", userId, e.getMessage()); + } + } +} diff --git a/motivator-service/src/main/java/com/healthsync/motivator/infrastructure/adapters/ClaudeApiAdapter.java b/motivator-service/src/main/java/com/healthsync/motivator/infrastructure/adapters/ClaudeApiAdapter.java new file mode 100644 index 0000000..f3fb736 --- /dev/null +++ b/motivator-service/src/main/java/com/healthsync/motivator/infrastructure/adapters/ClaudeApiAdapter.java @@ -0,0 +1,71 @@ +package com.healthsync.motivator.infrastructure.adapters; + +import com.healthsync.common.exception.ExternalApiException; +import com.healthsync.motivator.infrastructure.ports.ClaudeApiPort; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; + +/** + * Claude API와의 통신을 담당하는 어댑터 클래스입니다. + * Clean Architecture의 Infrastructure 계층에 해당합니다. + * + * @author healthsync-team + * @version 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class ClaudeApiAdapter implements ClaudeApiPort { + + private final WebClient webClient = WebClient.builder().build(); + + @Value("${claude.api.url}") + private String claudeApiUrl; + + @Value("${claude.api.key}") + private String claudeApiKey; + + @Value("${claude.api.model}") + private String claudeModel; + + @Value("${claude.api.max-tokens}") + private int maxTokens; + + @Override + public String callClaudeApi(String prompt) { + try { + log.info("Claude API 호출: promptLength={}", prompt.length()); + + // 실제 구현에서는 Claude API 호출 + // Mock 응답 반환 + return generateMockMotivationMessage(prompt); + + } catch (Exception e) { + log.error("Claude API 호출 실패: error={}", e.getMessage(), e); + throw new ExternalApiException("AI 메시지 생성에 실패했습니다."); + } + } + + /** + * Mock 동기부여 메시지를 생성합니다. + * + * @param prompt 프롬프트 + * @return Mock 동기부여 메시지 + */ + private String generateMockMotivationMessage(String prompt) { + if (prompt.contains("완료율") && prompt.contains("낮")) { + return "🌟 포기하지 마세요! 작은 변화가 큰 결과를 만듭니다. 오늘 한 가지만 더 해볼까요?"; + } else if (prompt.contains("연속")) { + return "🔥 연속 달성 중이시군요! 이 멋진 흐름을 계속 이어가봐요!"; + } else if (prompt.contains("배치")) { + return "💪 새로운 하루가 시작됐어요! 건강한 습관으로 오늘도 화이팅!"; + } else if (prompt.contains("실패")) { + return "🌅 괜찮아요! 다시 시작하는 것이 중요해요. 오늘부터 새롭게 도전해봐요!"; + } else { + return "✨ 건강한 하루 만들기, 함께 해요! 작은 실천이 큰 변화를 만듭니다!"; + } + } +} diff --git a/motivator-service/src/main/java/com/healthsync/motivator/infrastructure/adapters/EventPublisherAdapter.java b/motivator-service/src/main/java/com/healthsync/motivator/infrastructure/adapters/EventPublisherAdapter.java new file mode 100644 index 0000000..847a21a --- /dev/null +++ b/motivator-service/src/main/java/com/healthsync/motivator/infrastructure/adapters/EventPublisherAdapter.java @@ -0,0 +1,41 @@ +package com.healthsync.motivator.infrastructure.adapters; + +import com.healthsync.motivator.infrastructure.ports.EventPublisherPort; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +/** + * 이벤트 발행을 담당하는 어댑터 클래스입니다. + * Clean Architecture의 Infrastructure 계층에 해당합니다. + * + * @author healthsync-team + * @version 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class EventPublisherAdapter implements EventPublisherPort { + + // 실제 구현에서는 Spring Cloud Stream 또는 Azure Service Bus 사용 + // private final ServiceBusTemplate serviceBusTemplate; + + @Override + public void publishEncouragementSentEvent(String userId, String messageType) { + try { + log.info("독려 메시지 전송 이벤트 발행: userId={}, messageType={}", userId, messageType); + + // 실제 구현에서는 이벤트 브로커에 발행 + // EncouragementSentEvent event = EncouragementSentEvent.builder() + // .userId(userId) + // .messageType(messageType) + // .timestamp(LocalDateTime.now()) + // .build(); + // serviceBusTemplate.send("encouragement-sent-topic", event); + + log.info("독려 메시지 전송 이벤트 발행 완료: userId={}", userId); + } catch (Exception e) { + log.error("독려 메시지 전송 이벤트 발행 실패: userId={}, error={}", userId, e.getMessage(), e); + } + } +} diff --git a/motivator-service/src/main/java/com/healthsync/motivator/infrastructure/adapters/GoalServiceAdapter.java b/motivator-service/src/main/java/com/healthsync/motivator/infrastructure/adapters/GoalServiceAdapter.java new file mode 100644 index 0000000..3546fff --- /dev/null +++ b/motivator-service/src/main/java/com/healthsync/motivator/infrastructure/adapters/GoalServiceAdapter.java @@ -0,0 +1,75 @@ +package com.healthsync.motivator.infrastructure.adapters; + +import com.healthsync.common.exception.ExternalApiException; +import com.healthsync.motivator.dto.DailyProgress; +import com.healthsync.motivator.dto.UserMissionStatus; +import com.healthsync.motivator.infrastructure.ports.GoalServicePort; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.IntStream; + +/** + * Goal Service와의 통신을 담당하는 어댑터 클래스입니다. + * Clean Architecture의 Infrastructure 계층에 해당합니다. + * + * @author healthsync-team + * @version 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class GoalServiceAdapter implements GoalServicePort { + + private final WebClient webClient = WebClient.builder().build(); + + @Value("${services.goal-service.url}") + private String goalServiceUrl; + + @Override + public DailyProgress getUserDailyProgress(String userId) { + try { + log.info("Goal Service 사용자 일일 진행 상황 조회: userId={}", userId); + + // 실제 구현에서는 Goal Service API 호출 + // Mock 데이터 반환 + return DailyProgress.builder() + .currentStreak(5) + .weeklyCompletionRate(0.75) + .todayCompletedCount(3) + .todayTotalCount(5) + .build(); + + } catch (Exception e) { + log.error("Goal Service 일일 진행 상황 조회 실패: userId={}, error={}", userId, e.getMessage(), e); + throw new ExternalApiException("사용자 일일 진행 상황 조회에 실패했습니다."); + } + } + + @Override + public List getAllUsersWithActiveMissions() { + try { + log.info("Goal Service 활성 미션 사용자 조회"); + + // 실제 구현에서는 Goal Service API 호출 + // Mock 데이터 반환 + return IntStream.range(1, 11) + .mapToObj(i -> UserMissionStatus.builder() + .userId("user_" + i) + .totalMissions(5) + .completedMissions(i % 3 + 1) // 1-3개 완료 + .lastActiveTime(LocalDateTime.now().minusHours(i)) + .build()) + .toList(); + + } catch (Exception e) { + log.error("Goal Service 활성 미션 사용자 조회 실패: error={}", e.getMessage(), e); + throw new ExternalApiException("활성 미션 사용자 조회에 실패했습니다."); + } + } +} diff --git a/motivator-service/src/main/java/com/healthsync/motivator/infrastructure/batch/NotificationBatchJob.java b/motivator-service/src/main/java/com/healthsync/motivator/infrastructure/batch/NotificationBatchJob.java new file mode 100644 index 0000000..c2e706b --- /dev/null +++ b/motivator-service/src/main/java/com/healthsync/motivator/infrastructure/batch/NotificationBatchJob.java @@ -0,0 +1,103 @@ +package com.healthsync.motivator.infrastructure.batch; + +import com.healthsync.motivator.application_services.MotivationUseCase; +import com.healthsync.motivator.dto.BatchNotificationRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.PlatformTransactionManager; + +import java.time.LocalDateTime; +import java.util.Collections; + +/** + * 알림 배치 작업을 정의하는 클래스입니다. + * + * @author healthsync-team + * @version 1.0 + */ +@Slf4j +@Configuration +@Component +@RequiredArgsConstructor +public class NotificationBatchJob { + + private final MotivationUseCase motivationUseCase; + + /** + * 알림 배치 작업을 정의합니다. + * + * @param jobRepository Job 저장소 + * @param transactionManager 트랜잭션 매니저 + * @return 알림 배치 Job + */ + @Bean + public Job notificationJob(JobRepository jobRepository, PlatformTransactionManager transactionManager) { + return new JobBuilder("notificationJob", jobRepository) + .start(notificationStep(jobRepository, transactionManager)) + .build(); + } + + /** + * 알림 배치 스텝을 정의합니다. + * + * @param jobRepository Job 저장소 + * @param transactionManager 트랜잭션 매니저 + * @return 알림 배치 Step + */ + @Bean + public Step notificationStep(JobRepository jobRepository, PlatformTransactionManager transactionManager) { + return new StepBuilder("notificationStep", jobRepository) + .tasklet(notificationTasklet(), transactionManager) + .build(); + } + + /** + * 알림 배치 태스클릿을 정의합니다. + * + * @return 알림 배치 Tasklet + */ + @Bean + public Tasklet notificationTasklet() { + return (contribution, chunkContext) -> { + log.info("배치 알림 작업 시작"); + + BatchNotificationRequest request = BatchNotificationRequest.builder() + .triggerTime(LocalDateTime.now().toString()) + .targetUsers(Collections.emptyList()) // 전체 사용자 대상 + .notificationType("daily_encouragement") + .build(); + + motivationUseCase.processBatchNotifications(request); + + log.info("배치 알림 작업 완료"); + return RepeatStatus.FINISHED; + }; + } + + /** + * 스케줄러를 통한 배치 작업 실행 (매일 오전 9시) + */ + @Scheduled(cron = "0 0 9 * * ?") + public void executeScheduledNotification() { + log.info("스케줄된 배치 알림 실행"); + + BatchNotificationRequest request = BatchNotificationRequest.builder() + .triggerTime(LocalDateTime.now().toString()) + .targetUsers(Collections.emptyList()) + .notificationType("scheduled_daily") + .build(); + + motivationUseCase.processBatchNotifications(request); + } +} diff --git a/motivator-service/src/main/java/com/healthsync/motivator/infrastructure/entities/NotificationLogEntity.java b/motivator-service/src/main/java/com/healthsync/motivator/infrastructure/entities/NotificationLogEntity.java new file mode 100644 index 0000000..b7abb56 --- /dev/null +++ b/motivator-service/src/main/java/com/healthsync/motivator/infrastructure/entities/NotificationLogEntity.java @@ -0,0 +1,104 @@ +package com.healthsync.motivator.infrastructure.entities; + +import com.healthsync.common.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 알림 로그를 저장하는 엔티티 클래스입니다. + * + * @author healthsync-team + * @version 1.0 + */ +@Entity +@Table(name = "notification_logs") +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class NotificationLogEntity extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false) + private String userId; + + @Column(name = "mission_id") + private String missionId; + + @Column(name = "notification_type", nullable = false) + private String notificationType; + + @Column(name = "message", nullable = false, columnDefinition = "TEXT") + private String message; + + @Column(name = "delivery_channel") + private String deliveryChannel; + + @Column(name = "scheduled_at") + private LocalDateTime scheduledAt; + + @Column(name = "sent_at") + private LocalDateTime sentAt; + + @Column(name = "delivery_status") + private String deliveryStatus; + + @Column(name = "response_action") + private String responseAction; + + @Column(name = "response_time") + private LocalDateTime responseTime; + + @Column(name = "effectiveness") + private Double effectiveness; + + @Column(name = "batch_id") + private String batchId; + + @Column(name = "metadata", columnDefinition = "TEXT") + private String metadata; + + /** + * 전송 완료 여부를 확인합니다. + * + * @return 전송 완료 여부 + */ + public boolean isDelivered() { + return "SENT".equals(deliveryStatus) || "DELIVERED".equals(deliveryStatus); + } + + /** + * 전송 완료로 마크합니다. + */ + public void markAsDelivered() { + this.deliveryStatus = "DELIVERED"; + this.sentAt = LocalDateTime.now(); + } + + /** + * 사용자 응답을 기록합니다. + * + * @param action 응답 액션 + */ + public void recordResponse(String action) { + this.responseAction = action; + this.responseTime = LocalDateTime.now(); + } + + /** + * 효과성 점수를 업데이트합니다. + * + * @param score 효과성 점수 + */ + public void updateEffectiveness(double score) { + this.effectiveness = score; + } +} diff --git a/motivator-service/src/main/java/com/healthsync/motivator/infrastructure/ports/CachePort.java b/motivator-service/src/main/java/com/healthsync/motivator/infrastructure/ports/CachePort.java new file mode 100644 index 0000000..f12bd63 --- /dev/null +++ b/motivator-service/src/main/java/com/healthsync/motivator/infrastructure/ports/CachePort.java @@ -0,0 +1,37 @@ +package com.healthsync.motivator.infrastructure.ports; + +import com.healthsync.motivator.dto.EncouragementResponse; + +/** + * 캐시와의 통신을 위한 포트 인터페이스입니다. + * Clean Architecture의 Domain 계층에서 정의합니다. + * + * @author healthsync-team + * @version 1.0 + */ +public interface CachePort { + + /** + * 캐시된 독려 메시지를 조회합니다. + * + * @param cacheKey 캐시 키 + * @return 독려 응답 (캐시 미스 시 null) + */ + EncouragementResponse getCachedEncouragementMessage(String cacheKey); + + /** + * 독려 메시지를 캐시에 저장합니다. + * + * @param cacheKey 캐시 키 + * @param response 독려 응답 + */ + void cacheEncouragementMessage(String cacheKey, EncouragementResponse response); + + /** + * 배치 메시지를 저장합니다. + * + * @param userId 사용자 ID + * @param message 메시지 + */ + void storeBatchMessage(String userId, String message); +} diff --git a/motivator-service/src/main/java/com/healthsync/motivator/infrastructure/ports/ClaudeApiPort.java b/motivator-service/src/main/java/com/healthsync/motivator/infrastructure/ports/ClaudeApiPort.java new file mode 100644 index 0000000..d424b73 --- /dev/null +++ b/motivator-service/src/main/java/com/healthsync/motivator/infrastructure/ports/ClaudeApiPort.java @@ -0,0 +1,19 @@ +package com.healthsync.motivator.infrastructure.ports; + +/** + * Claude API와의 통신을 위한 포트 인터페이스입니다. + * Clean Architecture의 Domain 계층에서 정의합니다. + * + * @author healthsync-team + * @version 1.0 + */ +public interface ClaudeApiPort { + + /** + * Claude API를 호출합니다. + * + * @param prompt 프롬프트 + * @return AI 응답 + */ + String callClaudeApi(String prompt); +} diff --git a/motivator-service/src/main/java/com/healthsync/motivator/infrastructure/ports/EventPublisherPort.java b/motivator-service/src/main/java/com/healthsync/motivator/infrastructure/ports/EventPublisherPort.java new file mode 100644 index 0000000..923a6d2 --- /dev/null +++ b/motivator-service/src/main/java/com/healthsync/motivator/infrastructure/ports/EventPublisherPort.java @@ -0,0 +1,19 @@ +package com.healthsync.motivator.infrastructure.ports; + +/** + * 이벤트 발행을 위한 포트 인터페이스입니다. + * Clean Architecture의 Domain 계층에서 정의합니다. + * + * @author healthsync-team + * @version 1.0 + */ +public interface EventPublisherPort { + + /** + * 독려 메시지 전송 이벤트를 발행합니다. + * + * @param userId 사용자 ID + * @param messageType 메시지 유형 + */ + void publishEncouragementSentEvent(String userId, String messageType); +} diff --git a/motivator-service/src/main/java/com/healthsync/motivator/infrastructure/ports/GoalServicePort.java b/motivator-service/src/main/java/com/healthsync/motivator/infrastructure/ports/GoalServicePort.java new file mode 100644 index 0000000..c347823 --- /dev/null +++ b/motivator-service/src/main/java/com/healthsync/motivator/infrastructure/ports/GoalServicePort.java @@ -0,0 +1,31 @@ +package com.healthsync.motivator.infrastructure.ports; + +import com.healthsync.motivator.dto.DailyProgress; +import com.healthsync.motivator.dto.UserMissionStatus; + +import java.util.List; + +/** + * Goal Service와의 통신을 위한 포트 인터페이스입니다. + * Clean Architecture의 Domain 계층에서 정의합니다. + * + * @author healthsync-team + * @version 1.0 + */ +public interface GoalServicePort { + + /** + * 사용자의 일일 진행 상황을 조회합니다. + * + * @param userId 사용자 ID + * @return 일일 진행 상황 + */ + DailyProgress getUserDailyProgress(String userId); + + /** + * 활성 미션이 있는 모든 사용자를 조회합니다. + * + * @return 사용자 미션 상태 목록 + */ + List getAllUsersWithActiveMissions(); +} diff --git a/motivator-service/src/main/java/com/healthsync/motivator/infrastructure/repositories/NotificationLogJpaRepository.java b/motivator-service/src/main/java/com/healthsync/motivator/infrastructure/repositories/NotificationLogJpaRepository.java new file mode 100644 index 0000000..ee29904 --- /dev/null +++ b/motivator-service/src/main/java/com/healthsync/motivator/infrastructure/repositories/NotificationLogJpaRepository.java @@ -0,0 +1,35 @@ +package com.healthsync.motivator.infrastructure.repositories; + +import com.healthsync.motivator.infrastructure.entities.NotificationLogEntity; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +/** + * 알림 로그를 위한 JPA 리포지토리입니다. + * + * @author healthsync-team + * @version 1.0 + */ +@Repository +public interface NotificationLogJpaRepository extends JpaRepository { + + /** + * 사용자의 알림 로그를 시간 역순으로 조회합니다. + * + * @param userId 사용자 ID + * @param pageable 페이징 정보 + * @return 알림 로그 목록 + */ + List findByUserIdOrderBySentAtDesc(String userId, Pageable pageable); + + /** + * 배치 ID로 알림 로그를 조회합니다. + * + * @param batchId 배치 ID + * @return 알림 로그 목록 + */ + List findByBatchId(String batchId); +} diff --git a/motivator-service/src/main/java/com/healthsync/motivator/infrastructure/repositories/NotificationRepositoryImpl.java b/motivator-service/src/main/java/com/healthsync/motivator/infrastructure/repositories/NotificationRepositoryImpl.java new file mode 100644 index 0000000..2f705c1 --- /dev/null +++ b/motivator-service/src/main/java/com/healthsync/motivator/infrastructure/repositories/NotificationRepositoryImpl.java @@ -0,0 +1,56 @@ +package com.healthsync.motivator.infrastructure.repositories; + +import com.healthsync.motivator.domain.repositories.NotificationRepository; +import com.healthsync.motivator.infrastructure.entities.NotificationLogEntity; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 알림 데이터 저장소 구현체입니다. + * Clean Architecture의 Infrastructure 계층에 해당합니다. + * + * @author healthsync-team + * @version 1.0 + */ +@Slf4j +@Repository +@RequiredArgsConstructor +public class NotificationRepositoryImpl implements NotificationRepository { + + private final NotificationLogJpaRepository notificationLogJpaRepository; + + @Override + public NotificationLogEntity saveNotificationLog(String userId, String missionId, String notificationType, String message) { + NotificationLogEntity entity = NotificationLogEntity.builder() + .userId(userId) + .missionId(missionId) + .notificationType(notificationType) + .message(message) + .scheduledAt(LocalDateTime.now()) + .sentAt(LocalDateTime.now()) + .deliveryStatus("SENT") + .build(); + + NotificationLogEntity savedEntity = notificationLogJpaRepository.save(entity); + + log.info("알림 로그 저장 완료: userId={}, notificationType={}", userId, notificationType); + return savedEntity; + } + + @Override + public List findRecentNotificationLogs(String userId, int limit) { + PageRequest pageRequest = PageRequest.of(0, limit, Sort.by(Sort.Direction.DESC, "sentAt")); + return notificationLogJpaRepository.findByUserIdOrderBySentAtDesc(userId, pageRequest); + } + + @Override + public List findNotificationLogsByBatchId(String batchId) { + return notificationLogJpaRepository.findByBatchId(batchId); + } +} diff --git a/motivator-service/src/main/java/com/healthsync/motivator/interface_adapters/controllers/NotificationController.java b/motivator-service/src/main/java/com/healthsync/motivator/interface_adapters/controllers/NotificationController.java new file mode 100644 index 0000000..0cf5585 --- /dev/null +++ b/motivator-service/src/main/java/com/healthsync/motivator/interface_adapters/controllers/NotificationController.java @@ -0,0 +1,69 @@ +package com.healthsync.motivator.interface_adapters.controllers; + +import com.healthsync.common.dto.ApiResponse; +import com.healthsync.motivator.application_services.MotivationUseCase; +import com.healthsync.motivator.dto.EncouragementRequest; +import com.healthsync.motivator.dto.EncouragementResponse; +import com.healthsync.motivator.dto.BatchNotificationRequest; +import com.healthsync.motivator.dto.BatchNotificationResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import jakarta.validation.Valid; + +/** + * 동기부여 알림 관련 API를 제공하는 컨트롤러입니다. + * Clean Architecture의 Interface Adapter 계층에 해당합니다. + * + * @author healthsync-team + * @version 1.0 + */ +@Slf4j +@RestController +@RequestMapping("/api/motivator") +@RequiredArgsConstructor +@Tag(name = "동기부여 알림", description = "사용자 동기부여 메시지 생성 및 배치 알림 API") +public class NotificationController { + + private final MotivationUseCase motivationUseCase; + + /** + * 미션 독려 메시지를 생성합니다. + * + * @param request 독려 요청 + * @return 독려 메시지 + */ + @PostMapping("/notifications/encouragement") + @Operation(summary = "미션 독려 메시지 생성", description = "사용자의 미션 진행 상황을 바탕으로 개인화된 독려 메시지를 생성합니다") + public ResponseEntity> generateEncouragementMessage(@Valid @RequestBody EncouragementRequest request) { + log.info("독려 메시지 생성 요청: userId={}, missionCount={}", request.getUserId(), request.getMissionsStatus().size()); + + EncouragementResponse response = motivationUseCase.generateEncouragementMessage(request); + + log.info("독려 메시지 생성 완료: userId={}, motivationType={}", request.getUserId(), response.getMotivationType()); + return ResponseEntity.ok(ApiResponse.success("독려 메시지가 생성되었습니다.", response)); + } + + /** + * 배치 알림을 처리합니다. + * + * @param request 배치 알림 요청 + * @return 배치 처리 결과 + */ + @PostMapping("/batch/notifications") + @Operation(summary = "주기적 AI 알림 트리거", description = "전체 사용자를 대상으로 배치 형태의 동기부여 알림을 처리합니다") + public ResponseEntity> processBatchNotifications(@Valid @RequestBody BatchNotificationRequest request) { + log.info("배치 알림 처리 요청: triggerTime={}, targetUsers={}, type={}", + request.getTriggerTime(), request.getTargetUsers().size(), request.getNotificationType()); + + BatchNotificationResponse response = motivationUseCase.processBatchNotifications(request); + + log.info("배치 알림 처리 완료: processedCount={}, successCount={}", + response.getProcessedCount(), response.getSuccessCount()); + return ResponseEntity.ok(ApiResponse.success("배치 알림 처리가 완료되었습니다.", response)); + } +} diff --git a/motivator-service/src/main/resources/application.yml b/motivator-service/src/main/resources/application.yml new file mode 100644 index 0000000..11f4674 --- /dev/null +++ b/motivator-service/src/main/resources/application.yml @@ -0,0 +1,76 @@ +spring: + application: + name: motivator-service + + datasource: + url: ${DB_URL:jdbc:postgresql://localhost:5432/healthsync_motivator} + username: ${DB_USERNAME:team1tier} + password: ${DB_PASSWORD:Hi5Jessica!} + driver-class-name: org.postgresql.Driver + + jpa: + hibernate: + ddl-auto: ${DDL_AUTO:update} + show-sql: ${SHOW_SQL:false} + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + format_sql: true + + data: + redis: + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6379} + password: ${REDIS_PASSWORD:HUezXQsxbphIeBy8FV9JDA3WaZDwOozGEAzCaByUk40=} + timeout: 2000ms + lettuce: + pool: + max-active: 8 + max-idle: 8 + min-idle: 0 + + batch: + job: + enabled: true + jdbc: + initialize-schema: always + +server: + port: ${SERVER_PORT:8085} + +# 외부 서비스 URL +services: + goal-service: + url: ${GOAL_SERVICE_URL:http://localhost:8084} + intelligence-service: + url: ${INTELLIGENCE_SERVICE_URL:http://localhost:8083} + +# Claude API 설정 +claude: + api: + url: ${CLAUDE_API_URL:https://api.anthropic.com/v1/messages} + key: ${CLAUDE_API_KEY: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} + model: ${CLAUDE_MODEL:claude-3-sonnet-20240229} + max-tokens: ${CLAUDE_MAX_TOKENS:512} + +# 배치 처리 설정 +batch: + notification: + batch-size: ${BATCH_SIZE:100} + max-processing-time: ${MAX_PROCESSING_TIME:PT30M} + retry-attempts: ${RETRY_ATTEMPTS:3} + +# JWT 설정 +jwt: + secret-key: ${JWT_SECRET:healthsync-secret-key-2024-very-long-secret-key} + access-token-validity: ${JWT_ACCESS_VALIDITY:3600000} + refresh-token-validity: ${JWT_REFRESH_VALIDITY:86400000} + +# 로깅 설정 +logging: + level: + com.healthsync.motivator: ${LOG_LEVEL:INFO} + org.springframework.web: ${WEB_LOG_LEVEL:INFO} + org.springframework.batch: ${BATCH_LOG_LEVEL:INFO} + pattern: + console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n" diff --git a/scripts/init-db.sql b/scripts/init-db.sql new file mode 100644 index 0000000..0bc37b2 --- /dev/null +++ b/scripts/init-db.sql @@ -0,0 +1,23 @@ +-- HealthSync 데이터베이스 초기화 스크립트 + +-- User Service 데이터베이스 +CREATE DATABASE healthsync_user; + +-- Health Service 데이터베이스 +CREATE DATABASE healthsync_health; + +-- Intelligence Service 데이터베이스 +CREATE DATABASE healthsync_intelligence; + +-- Goal Service 데이터베이스 +CREATE DATABASE healthsync_goal; + +-- Motivator Service 데이터베이스 +CREATE DATABASE healthsync_motivator; + +-- 사용자 권한 설정 +GRANT ALL PRIVILEGES ON DATABASE healthsync_user TO healthsync; +GRANT ALL PRIVILEGES ON DATABASE healthsync_health TO healthsync; +GRANT ALL PRIVILEGES ON DATABASE healthsync_intelligence TO healthsync; +GRANT ALL PRIVILEGES ON DATABASE healthsync_goal TO healthsync; +GRANT ALL PRIVILEGES ON DATABASE healthsync_motivator TO healthsync; diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..10288c2 --- /dev/null +++ b/settings.gradle @@ -0,0 +1,9 @@ +rootProject.name = 'healthsync-backend' + +include 'common' +include 'user-service' +include 'health-service' +include 'intelligence-service' +include 'goal-service' +include 'motivator-service' +include 'api-gateway' diff --git a/user-service/build.gradle b/user-service/build.gradle new file mode 100644 index 0000000..328a272 --- /dev/null +++ b/user-service/build.gradle @@ -0,0 +1,22 @@ +// user-service/build.gradle +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-security' + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' + implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0' + + runtimeOnly 'org.postgresql:postgresql' + + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.security:spring-security-test' +} + +// jar version +allprojects { + group = 'com.healthsync' + version = '1.0.0' // 원하는 버전으로 설정 +} diff --git a/user-service/src/main/java/com/healthsync/UserServiceApplication.java b/user-service/src/main/java/com/healthsync/UserServiceApplication.java new file mode 100644 index 0000000..e45924a --- /dev/null +++ b/user-service/src/main/java/com/healthsync/UserServiceApplication.java @@ -0,0 +1,11 @@ +package com.healthsync; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class UserServiceApplication { + public static void main(String[] args) { + SpringApplication.run(UserServiceApplication.class, args); + } +} diff --git a/user-service/src/main/java/com/healthsync/common/dto/ApiResponse.java b/user-service/src/main/java/com/healthsync/common/dto/ApiResponse.java new file mode 100644 index 0000000..3cb5cbd --- /dev/null +++ b/user-service/src/main/java/com/healthsync/common/dto/ApiResponse.java @@ -0,0 +1,42 @@ +package com.healthsync.common.dto; + +public class ApiResponse { + private boolean success; + private String message; + private T data; + private String error; + + public ApiResponse() {} + + public ApiResponse(boolean success, String message, T data) { + this.success = success; + this.message = message; + this.data = data; + } + + public ApiResponse(boolean success, String message, String error) { + this.success = success; + this.message = message; + this.error = error; + } + + public static ApiResponse success(T data, String message) { + return new ApiResponse<>(true, message, data); + } + + public static ApiResponse error(String message, String error) { + return new ApiResponse<>(false, message, error); + } + + public boolean isSuccess() { return success; } + public void setSuccess(boolean success) { this.success = success; } + + public String getMessage() { return message; } + public void setMessage(String message) { this.message = message; } + + public T getData() { return data; } + public void setData(T data) { this.data = data; } + + public String getError() { return error; } + public void setError(String error) { this.error = error; } +} diff --git a/user-service/src/main/java/com/healthsync/common/exception/CustomException.java b/user-service/src/main/java/com/healthsync/common/exception/CustomException.java new file mode 100644 index 0000000..a4d969d --- /dev/null +++ b/user-service/src/main/java/com/healthsync/common/exception/CustomException.java @@ -0,0 +1,11 @@ +package com.healthsync.common.exception; + +public class CustomException extends RuntimeException { + public CustomException(String message) { + super(message); + } + + public CustomException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/user-service/src/main/java/com/healthsync/common/exception/GlobalExceptionHandler.java b/user-service/src/main/java/com/healthsync/common/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..7b434e6 --- /dev/null +++ b/user-service/src/main/java/com/healthsync/common/exception/GlobalExceptionHandler.java @@ -0,0 +1,42 @@ +package com.healthsync.common.exception; + +import com.healthsync.common.dto.ApiResponse; +import com.healthsync.common.response.ResponseHelper; +import com.healthsync.user.exception.AuthenticationException; +import com.healthsync.user.exception.UserNotFoundException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class); + + @ExceptionHandler(UserNotFoundException.class) + public ResponseEntity> handleUserNotFoundException(UserNotFoundException e) { + logger.error("User not found: {}", e.getMessage()); + return ResponseHelper.notFound("사용자를 찾을 수 없습니다", e.getMessage()); + } + + @ExceptionHandler(AuthenticationException.class) + public ResponseEntity> handleAuthenticationException(AuthenticationException e) { + logger.error("Authentication error: {}", e.getMessage()); + return ResponseHelper.unauthorized("인증에 실패했습니다", e.getMessage()); + } + + @ExceptionHandler(AccessDeniedException.class) + public ResponseEntity> handleAccessDeniedException(AccessDeniedException e) { + logger.error("Access denied: {}", e.getMessage()); + return ResponseHelper.forbidden("접근이 거부되었습니다", e.getMessage()); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity> handleGenericException(Exception e) { + logger.error("Unexpected error: {}", e.getMessage(), e); + return ResponseHelper.internalServerError("서버 오류가 발생했습니다", e.getMessage()); + } +} diff --git a/user-service/src/main/java/com/healthsync/common/response/ResponseHelper.java b/user-service/src/main/java/com/healthsync/common/response/ResponseHelper.java new file mode 100644 index 0000000..fd21ea5 --- /dev/null +++ b/user-service/src/main/java/com/healthsync/common/response/ResponseHelper.java @@ -0,0 +1,36 @@ +package com.healthsync.common.response; + +import com.healthsync.common.dto.ApiResponse; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +public class ResponseHelper { + + public static ResponseEntity> success(T data, String message) { + return ResponseEntity.ok(ApiResponse.success(data, message)); + } + + public static ResponseEntity> created(T data, String message) { + return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.success(data, message)); + } + + public static ResponseEntity> badRequest(String message, String error) { + return ResponseEntity.badRequest().body(ApiResponse.error(message, error)); + } + + public static ResponseEntity> unauthorized(String message, String error) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(ApiResponse.error(message, error)); + } + + public static ResponseEntity> forbidden(String message, String error) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(ApiResponse.error(message, error)); + } + + public static ResponseEntity> notFound(String message, String error) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(ApiResponse.error(message, error)); + } + + public static ResponseEntity> internalServerError(String message, String error) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(ApiResponse.error(message, error)); + } +} diff --git a/user-service/src/main/java/com/healthsync/common/util/JwtUtil.java b/user-service/src/main/java/com/healthsync/common/util/JwtUtil.java new file mode 100644 index 0000000..2dc330c --- /dev/null +++ b/user-service/src/main/java/com/healthsync/common/util/JwtUtil.java @@ -0,0 +1,85 @@ +package com.healthsync.common.util; + +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; + +@Component +public class JwtUtil { + + private final JwtDecoder jwtDecoder; + + public JwtUtil(JwtDecoder jwtDecoder) { + this.jwtDecoder = jwtDecoder; + } + + public Jwt parseToken(String token) { + return jwtDecoder.decode(token); + } + + public String getUserIdFromToken(String token) { + Jwt jwt = parseToken(token); + return jwt.getSubject(); + } + + public String getEmailFromToken(String token) { + Jwt jwt = parseToken(token); + return jwt.getClaimAsString("email"); + } + + public String getNameFromToken(String token) { + Jwt jwt = parseToken(token); + return jwt.getClaimAsString("name"); + } + + public String getRoleFromToken(String token) { + Jwt jwt = parseToken(token); + return jwt.getClaimAsString("role"); + } + + /** + * JWT 토큰에서 생년월일 추출 + */ + public LocalDate getBirthDateFromToken(String token) { + Jwt jwt = parseToken(token); + String birthDateStr = jwt.getClaimAsString("birthDate"); + if (birthDateStr != null) { + return LocalDate.parse(birthDateStr); + } + return null; + } + + /** + * JWT 토큰에서 생년월일 추출 (Jwt 객체 사용) + */ + public LocalDate getBirthDateFromJwt(Jwt jwt) { + String birthDateStr = jwt.getClaimAsString("birthDate"); + if (birthDateStr != null) { + return LocalDate.parse(birthDateStr); + } + return null; + } + + /** + * JWT 토큰에서 이름 추출 (Jwt 객체 사용) + */ + public String getNameFromJwt(Jwt jwt) { + return jwt.getClaimAsString("name"); + } + + /** + * JWT 토큰에서 Google ID 추출 (Jwt 객체 사용) + */ + public String getGoogleIdFromJwt(Jwt jwt) { + return jwt.getClaimAsString("googleId"); + } + + /** + * JWT 토큰에서 사용자 ID 추출 (Jwt 객체 사용) + */ + public Long getMemberSerialNumberFromJwt(Jwt jwt) { + return Long.valueOf(jwt.getSubject()); + } +} \ No newline at end of file diff --git a/user-service/src/main/java/com/healthsync/user/config/OAuth2Config.java b/user-service/src/main/java/com/healthsync/user/config/OAuth2Config.java new file mode 100644 index 0000000..c038ab9 --- /dev/null +++ b/user-service/src/main/java/com/healthsync/user/config/OAuth2Config.java @@ -0,0 +1,8 @@ +package com.healthsync.user.config; + +import org.springframework.context.annotation.Configuration; + +@Configuration +public class OAuth2Config { + // OAuth2 관련 추가 설정이 필요한 경우 여기에 추가 +} diff --git a/user-service/src/main/java/com/healthsync/user/config/OAuth2LoginSuccessHandler.java b/user-service/src/main/java/com/healthsync/user/config/OAuth2LoginSuccessHandler.java new file mode 100644 index 0000000..df8105c --- /dev/null +++ b/user-service/src/main/java/com/healthsync/user/config/OAuth2LoginSuccessHandler.java @@ -0,0 +1,123 @@ +package com.healthsync.user.config; + +import com.healthsync.user.domain.Oauth.User; +import com.healthsync.user.domain.Oauth.RefreshToken; +import com.healthsync.user.service.Oauth.JwtTokenService; +import com.healthsync.user.service.UserProfile.UserService; +import com.healthsync.user.service.Oauth.RefreshTokenService; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.time.LocalDate; +import java.util.Optional; + +@Component +public class OAuth2LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler { + + private static final Logger logger = LoggerFactory.getLogger(OAuth2LoginSuccessHandler.class); + + private final UserService userService; + private final JwtTokenService jwtTokenService; + private final RefreshTokenService refreshTokenService; + + @Value("${app.oauth2.redirect-url}") + private String redirectUrl; + + public OAuth2LoginSuccessHandler(UserService userService, + JwtTokenService jwtTokenService, + RefreshTokenService refreshTokenService) { + this.userService = userService; + this.jwtTokenService = jwtTokenService; + this.refreshTokenService = refreshTokenService; + + // redirectUrl은 @PostConstruct에서 설정 + } + + @jakarta.annotation.PostConstruct + public void init() { + // application.yml에서 주입받은 값으로 기본 URL 설정 + setDefaultTargetUrl(redirectUrl); + setAlwaysUseDefaultTargetUrl(true); + logger.info("OAuth2 리다이렉트 URL 설정: {}", redirectUrl); + } + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, + HttpServletResponse response, + Authentication authentication) throws IOException, ServletException { + + logger.info("OAuth2 로그인 성공 처리 시작"); + + if (authentication.getPrincipal() instanceof OAuth2User oauth2User) { + try { + String googleId = oauth2User.getAttribute("sub"); + String name = oauth2User.getAttribute("name"); + + logger.info("OAuth2 사용자 정보 - Google ID: {}, Name: {}", googleId, name); + + // 기존 사용자 확인 + Optional existingUser = userService.findByGoogleId(googleId); + boolean isNewUser = existingUser.isEmpty(); + + User user; + if (isNewUser) { + logger.info("신규 사용자 생성 - Google ID: {}", googleId); + User newUser = new User(googleId, name, LocalDate.of(1000, 1, 1), null); + user = userService.saveUser(newUser); + logger.info("신규 사용자 저장 완료 - Member Serial Number: {}", user.getMemberSerialNumber()); + } else { + user = existingUser.get(); + logger.info("기존 사용자 로그인 - Member Serial Number: {}", user.getMemberSerialNumber()); + userService.updateLastLoginAt(user.getMemberSerialNumber()); + user = userService.findById(user.getMemberSerialNumber()).orElse(user); + + boolean isDeault = user.getBirthDate().equals(LocalDate.of(1000, 1, 1)); + if(isDeault){ + isNewUser = true; + } + } + + // JWT 토큰 생성 + String accessToken = jwtTokenService.generateAccessToken(user); + RefreshToken refreshToken = refreshTokenService.createRefreshToken(user.getMemberSerialNumber()); + + // URL에 토큰 정보 추가하여 리다이렉트 + String finalRedirectUrl = String.format( + "%s?success=true&isNewUser=%s&accessToken=%s&refreshToken=%s&userId=%d", + redirectUrl, + isNewUser, + accessToken, + refreshToken.getToken(), + user.getMemberSerialNumber() + ); + + logger.info("OAuth2 로그인 처리 완료 - Member Serial Number: {}, 신규 사용자: {}, 리다이렉트: {}", + user.getMemberSerialNumber(), isNewUser, redirectUrl); + + // SimpleUrlAuthenticationSuccessHandler의 리다이렉트 기능 사용 + getRedirectStrategy().sendRedirect(request, response, finalRedirectUrl); + + } catch (Exception e) { + logger.error("OAuth2 로그인 처리 중 오류 발생", e); + // 에러 시에도 프론트엔드로 리다이렉트 + String errorUrl = redirectUrl + "?success=false&error=" + + java.net.URLEncoder.encode(e.getMessage(), "UTF-8"); + getRedirectStrategy().sendRedirect(request, response, errorUrl); + } + } else { + logger.error("OAuth2User 정보를 가져올 수 없음"); + String errorUrl = redirectUrl + "?success=false&error=" + + java.net.URLEncoder.encode("OAuth2 인증 정보를 찾을 수 없습니다", "UTF-8"); + getRedirectStrategy().sendRedirect(request, response, errorUrl); + } + } +} \ No newline at end of file diff --git a/user-service/src/main/java/com/healthsync/user/config/ObjectMapperConfig.java b/user-service/src/main/java/com/healthsync/user/config/ObjectMapperConfig.java new file mode 100644 index 0000000..cb880d2 --- /dev/null +++ b/user-service/src/main/java/com/healthsync/user/config/ObjectMapperConfig.java @@ -0,0 +1,17 @@ +package com.healthsync.user.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class ObjectMapperConfig { + + @Bean + public ObjectMapper objectMapper() { + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.registerModule(new JavaTimeModule()); + return objectMapper; + } +} \ No newline at end of file diff --git a/user-service/src/main/java/com/healthsync/user/config/UserJwtConfig.java b/user-service/src/main/java/com/healthsync/user/config/UserJwtConfig.java new file mode 100644 index 0000000..7729205 --- /dev/null +++ b/user-service/src/main/java/com/healthsync/user/config/UserJwtConfig.java @@ -0,0 +1,92 @@ +package com.healthsync.user.config; + +import com.nimbusds.jose.jwk.JWK; +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jose.jwk.source.ImmutableJWKSet; +import com.nimbusds.jose.jwk.source.JWKSource; +import com.nimbusds.jose.proc.SecurityContext; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtEncoder; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; +import org.springframework.security.oauth2.jwt.NimbusJwtEncoder; + +import java.security.KeyFactory; +import java.security.interfaces.RSAPrivateKey; +import java.security.interfaces.RSAPublicKey; +import java.security.spec.PKCS8EncodedKeySpec; +import java.security.spec.X509EncodedKeySpec; +import java.util.Base64; + +@Configuration +public class UserJwtConfig { + + + @Value("${jwt.private-key}") + private String privateKeyString; + + @Value("${jwt.public-key}") + private String publicKeyString; + + @Bean + public RSAPrivateKey rsaPrivateKey() { + try { + // "-----BEGIN PRIVATE KEY-----"와 "-----END PRIVATE KEY-----" 제거 + String privateKeyPEM = privateKeyString + .replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replaceAll("\\s", ""); + + byte[] decoded = Base64.getDecoder().decode(privateKeyPEM); + PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(decoded); + KeyFactory keyFactory = KeyFactory.getInstance("RSA"); + + return (RSAPrivateKey) keyFactory.generatePrivate(spec); + } catch (Exception e) { + throw new RuntimeException("Unable to load RSA private key", e); + } + } + + @Bean + public RSAPublicKey rsaPublicKey() { + try { + // "-----BEGIN PUBLIC KEY-----"와 "-----END PUBLIC KEY-----" 제거 + String publicKeyPEM = publicKeyString + .replace("-----BEGIN PUBLIC KEY-----", "") + .replace("-----END PUBLIC KEY-----", "") + .replaceAll("\\s", ""); + + byte[] decoded = Base64.getDecoder().decode(publicKeyPEM); + X509EncodedKeySpec spec = new X509EncodedKeySpec(decoded); + KeyFactory keyFactory = KeyFactory.getInstance("RSA"); + + return (RSAPublicKey) keyFactory.generatePublic(spec); + } catch (Exception e) { + throw new RuntimeException("Unable to load RSA public key", e); + } + } + + @Bean + public JWKSource jwkSource(RSAPublicKey publicKey, RSAPrivateKey privateKey) { + JWK jwk = new RSAKey.Builder(publicKey) + .privateKey(privateKey) + .keyID("healthsync-key-id") + .build(); + + JWKSet jwkSet = new JWKSet(jwk); + return new ImmutableJWKSet<>(jwkSet); + } + + @Bean + public JwtEncoder jwtEncoder(JWKSource jwkSource) { + return new NimbusJwtEncoder(jwkSource); + } + + @Bean + public JwtDecoder jwtDecoder(RSAPublicKey publicKey) { + return NimbusJwtDecoder.withPublicKey(publicKey).build(); + } +} diff --git a/user-service/src/main/java/com/healthsync/user/config/UserSecurityConfig.java b/user-service/src/main/java/com/healthsync/user/config/UserSecurityConfig.java new file mode 100644 index 0000000..58efc30 --- /dev/null +++ b/user-service/src/main/java/com/healthsync/user/config/UserSecurityConfig.java @@ -0,0 +1,94 @@ +package com.healthsync.user.config; + +import com.healthsync.user.service.Oauth.OAuth2UserService; +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.http.SessionCreationPolicy; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; +import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter; +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; + +@Configuration +@EnableWebSecurity +public class UserSecurityConfig { + + private final OAuth2UserService oAuth2UserService; + private final OAuth2LoginSuccessHandler oAuth2LoginSuccessHandler; + + public UserSecurityConfig(OAuth2UserService oAuth2UserService, OAuth2LoginSuccessHandler oAuth2LoginSuccessHandler) { + this.oAuth2UserService = oAuth2UserService; + this.oAuth2LoginSuccessHandler = oAuth2LoginSuccessHandler; + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .csrf(csrf -> csrf.disable()) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + // 공개 접근 허용 + .requestMatchers( + "/", + "/swagger-ui/**", + "/swagger-ui.html", + "/v3/api-docs/**", + "/swagger-resources/**", + "/webjars/**" + ).permitAll() + // OAuth2 및 인증 관련 + .requestMatchers( + "/login/**", + "/oauth2/**", + "/api/auth/refresh" + ).permitAll() + // 인증이 필요한 API + .requestMatchers("/api/users/**").authenticated() + .requestMatchers("/api/auth/logout/**").authenticated() + // 나머지는 인증 필요 + .anyRequest().authenticated() + ) + .oauth2Login(oauth2 -> oauth2 + .userInfoEndpoint(userInfo -> userInfo.userService(oAuth2UserService)) + .successHandler(oAuth2LoginSuccessHandler) // 커스텀 Success Handler 사용 + ) + .oauth2ResourceServer(oauth2 -> oauth2 + .jwt(jwt -> jwt + .jwtAuthenticationConverter(jwtAuthenticationConverter()) + ) + ); + + return http.build(); + } + + @Bean + public JwtAuthenticationConverter jwtAuthenticationConverter() { + JwtGrantedAuthoritiesConverter authoritiesConverter = new JwtGrantedAuthoritiesConverter(); + authoritiesConverter.setAuthorityPrefix(""); + authoritiesConverter.setAuthoritiesClaimName("role"); + + JwtAuthenticationConverter converter = new JwtAuthenticationConverter(); + converter.setJwtGrantedAuthoritiesConverter(authoritiesConverter); + return converter; + } + + @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; + } +} \ No newline at end of file diff --git a/user-service/src/main/java/com/healthsync/user/config/UserSwaggerConfig.java b/user-service/src/main/java/com/healthsync/user/config/UserSwaggerConfig.java new file mode 100644 index 0000000..03e8850 --- /dev/null +++ b/user-service/src/main/java/com/healthsync/user/config/UserSwaggerConfig.java @@ -0,0 +1,54 @@ +package com.healthsync.user.config; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.info.Contact; +import io.swagger.v3.oas.models.info.License; +import io.swagger.v3.oas.models.servers.Server; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import io.swagger.v3.oas.models.Components; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.List; + +@Configuration +public class UserSwaggerConfig { + + @Bean + public OpenAPI healthServiceOpenAPI() { + return new OpenAPI() + .info(apiInfo()) + .servers(List.of( + new Server().url("http://localhost:8081").description("개발 서버"), + new Server().url("https://api.healthsync.com").description("운영 서버") + )) + .addSecurityItem(new SecurityRequirement().addList("Bearer Authentication")) + .components(new Components() + .addSecuritySchemes("Bearer Authentication", + new SecurityScheme() + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT") + .description("JWT 토큰을 입력하세요. 'Bearer ' 접두사는 자동으로 추가됩니다.") + ) + ); + } + + private Info apiInfo() { + return new Info() + .title("HealthSync User API") + .description("HealthSync 사용자 관리 및 인증 API 문서") + .version("1.0.0") + .contact(new Contact() + .name("HealthSync Team") + .email("support@healthsync.com") + .url("https://healthsync.com") + ) + .license(new License() + .name("MIT License") + .url("https://opensource.org/licenses/MIT") + ); + } +} \ No newline at end of file diff --git a/user-service/src/main/java/com/healthsync/user/controller/AuthController.java b/user-service/src/main/java/com/healthsync/user/controller/AuthController.java new file mode 100644 index 0000000..26e2866 --- /dev/null +++ b/user-service/src/main/java/com/healthsync/user/controller/AuthController.java @@ -0,0 +1,227 @@ +// src/main/java/com/healthsync/user/controller/AuthController.java - 수정된 버전 +package com.healthsync.user.controller; + +import com.healthsync.user.domain.Oauth.User; +import com.healthsync.user.domain.Oauth.RefreshToken; +import com.healthsync.user.dto.Oauth.TokenResponse; +import com.healthsync.user.dto.Oauth.TokenRefreshRequest; +import com.healthsync.user.service.Oauth.JwtTokenService; +import com.healthsync.user.service.UserProfile.UserService; +import com.healthsync.user.service.Oauth.RefreshTokenService; +import com.healthsync.user.exception.UserNotFoundException; +import com.healthsync.user.exception.AuthenticationException; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.web.bind.annotation.*; + +import jakarta.validation.Valid; + +@RestController +@RequestMapping("/api/auth") +@Tag(name = "인증", description = "사용자 인증 관련 API") +public class AuthController { + + private static final Logger logger = LoggerFactory.getLogger(AuthController.class); + + private final UserService userService; + private final JwtTokenService jwtTokenService; + private final RefreshTokenService refreshTokenService; + + public AuthController(UserService userService, JwtTokenService jwtTokenService, RefreshTokenService refreshTokenService) { + this.userService = userService; + this.jwtTokenService = jwtTokenService; + this.refreshTokenService = refreshTokenService; + } + + @PostMapping("/refresh") + @Operation( + summary = "토큰 갱신", + description = "리프레시 토큰을 사용하여 새로운 액세스 토큰과 리프레시 토큰을 발급받습니다." + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "토큰 갱신 성공", + content = @Content(schema = @Schema(implementation = TokenResponse.class))), + @ApiResponse(responseCode = "401", description = "유효하지 않거나 만료된 리프레시 토큰"), + @ApiResponse(responseCode = "400", description = "잘못된 요청 형식"), + @ApiResponse(responseCode = "404", description = "사용자를 찾을 수 없음") + }) + public ResponseEntity> refreshToken( + @Parameter(description = "리프레시 토큰 요청 객체", required = true) + @Valid @RequestBody TokenRefreshRequest request) { + + logger.info("토큰 갱신 요청 - Refresh Token: {}", request.getRefreshToken().substring(0, 8) + "..."); + + String requestRefreshToken = request.getRefreshToken(); + + RefreshToken refreshToken = refreshTokenService.findByToken(requestRefreshToken) + .orElseThrow(() -> { + logger.warn("유효하지 않은 리프레시 토큰: {}", requestRefreshToken.substring(0, 8) + "..."); + return new AuthenticationException("유효하지 않은 리프레시 토큰입니다"); + }); + + refreshToken = refreshTokenService.verifyExpiration(refreshToken); + + RefreshToken finalRefreshToken = refreshToken; + User user = userService.findById(refreshToken.getMemberSerialNumber()) + .orElseThrow(() -> { + logger.error("리프레시 토큰의 사용자를 찾을 수 없음 - Member Serial Number: {}", finalRefreshToken.getMemberSerialNumber()); + return new UserNotFoundException("사용자를 찾을 수 없습니다"); + }); + + logger.info("토큰 갱신 대상 사용자 - Member Serial Number: {}, Google ID: {}", user.getMemberSerialNumber(), user.getGoogleId()); + + String newAccessToken = jwtTokenService.generateAccessToken(user); + RefreshToken newRefreshToken = refreshTokenService.createRefreshToken(user.getMemberSerialNumber()); + + TokenResponse tokenResponse = new TokenResponse( + newAccessToken, + newRefreshToken.getToken() + ); + + logger.info("토큰 갱신 완료 - Member Serial Number: {}", user.getMemberSerialNumber()); + + return com.healthsync.common.response.ResponseHelper.success(tokenResponse, "토큰 갱신 성공"); + } + + @PostMapping("/logout") + @Operation( + summary = "로그아웃", + description = "현재 사용자를 로그아웃합니다. 모든 리프레시 토큰이 삭제됩니다." + ) + @SecurityRequirement(name = "Bearer Authentication") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "로그아웃 성공"), + @ApiResponse(responseCode = "401", description = "인증 실패") + }) + public ResponseEntity> logout(@AuthenticationPrincipal Jwt jwt) { + Long memberSerialNumber = Long.valueOf(jwt.getSubject()); + String googleId = jwt.getClaimAsString("googleId"); + + logger.info("로그아웃 요청 - Member Serial Number: {}, Google ID: {}", memberSerialNumber, googleId); + + // 해당 사용자의 모든 리프레시 토큰 삭제 + refreshTokenService.deleteByMemberSerialNumber(memberSerialNumber); + + logger.info("로그아웃 완료 - Member Serial Number: {}", memberSerialNumber); + + return com.healthsync.common.response.ResponseHelper.success(null, "로그아웃 성공"); + } + + @PostMapping("/logout/{memberSerialNumber}") + @Operation( + summary = "특정 사용자 강제 로그아웃", + description = "관리자가 특정 사용자를 강제 로그아웃합니다. 해당 사용자의 모든 리프레시 토큰이 삭제됩니다.", + hidden = true + ) + @SecurityRequirement(name = "Bearer Authentication") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "강제 로그아웃 성공"), + @ApiResponse(responseCode = "401", description = "인증 실패"), + @ApiResponse(responseCode = "403", description = "권한 없음"), + @ApiResponse(responseCode = "404", description = "대상 사용자를 찾을 수 없음") + }) + public ResponseEntity> forceLogout( + @AuthenticationPrincipal Jwt jwt, + @Parameter(description = "강제 로그아웃할 사용자의 회원 일련번호", required = true, example = "1") + @PathVariable Long memberSerialNumber) { + + Long currentUserSerialNumber = Long.valueOf(jwt.getSubject()); + String currentGoogleId = jwt.getClaimAsString("googleId"); + + logger.info("강제 로그아웃 요청 - 요청자: {} ({}), 대상: {}", currentUserSerialNumber, currentGoogleId, memberSerialNumber); + + // 대상 사용자가 존재하는지 확인 + User targetUser = userService.findById(memberSerialNumber) + .orElseThrow(() -> new UserNotFoundException("대상 사용자를 찾을 수 없습니다: " + memberSerialNumber)); + + // 자기 자신을 로그아웃하는 경우 방지 (일반 로그아웃 사용 권장) + if (currentUserSerialNumber.equals(memberSerialNumber)) { + logger.warn("자기 자신에 대한 강제 로그아웃 시도 - Member Serial Number: {}", currentUserSerialNumber); + return com.healthsync.common.response.ResponseHelper.badRequest( + "자기 자신에 대해서는 일반 로그아웃을 사용해주세요", + "SELF_LOGOUT_NOT_ALLOWED" + ); + } + + // 대상 사용자의 모든 리프레시 토큰 삭제 + refreshTokenService.deleteByMemberSerialNumber(memberSerialNumber); + + logger.info("강제 로그아웃 완료 - 대상: {} ({})", targetUser.getMemberSerialNumber(), targetUser.getGoogleId()); + + return com.healthsync.common.response.ResponseHelper.success(null, "강제 로그아웃 성공"); + } + + @GetMapping("/verify") + @Operation( + summary = "토큰 검증", + description = "현재 JWT 토큰의 유효성을 검증하고 사용자 정보를 반환합니다." + ) + @SecurityRequirement(name = "Bearer Authentication") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "토큰 검증 성공"), + @ApiResponse(responseCode = "401", description = "유효하지 않은 토큰") + }) + public ResponseEntity> verifyToken(@AuthenticationPrincipal Jwt jwt) { + Long memberSerialNumber = Long.valueOf(jwt.getSubject()); + String googleId = jwt.getClaimAsString("googleId"); + String name = jwt.getClaimAsString("name"); + + logger.debug("토큰 검증 요청 - Member Serial Number: {}, Google ID: {}", memberSerialNumber, googleId); + + // 사용자 존재 여부 확인 (DB에서 삭제된 사용자의 토큰인지 체크) + boolean userExists = userService.findById(memberSerialNumber).isPresent(); + + if (!userExists) { + logger.warn("삭제된 사용자의 토큰 - Member Serial Number: {}", memberSerialNumber); + throw new UserNotFoundException("사용자가 존재하지 않습니다"); + } + + TokenVerificationResponse response = new TokenVerificationResponse( + memberSerialNumber, + googleId, + name, + jwt.getIssuedAt(), + jwt.getExpiresAt() + ); + + return com.healthsync.common.response.ResponseHelper.success(response, "토큰 검증 성공"); + } + + // 토큰 검증 응답 DTO + public static class TokenVerificationResponse { + private Long memberSerialNumber; + private String googleId; + private String name; + private java.time.Instant issuedAt; + private java.time.Instant expiresAt; + + public TokenVerificationResponse(Long memberSerialNumber, String googleId, String name, + java.time.Instant issuedAt, java.time.Instant expiresAt) { + this.memberSerialNumber = memberSerialNumber; + this.googleId = googleId; + this.name = name; + this.issuedAt = issuedAt; + this.expiresAt = expiresAt; + } + + // Getters + public Long getMemberSerialNumber() { return memberSerialNumber; } + public String getGoogleId() { return googleId; } + public String getName() { return name; } + public java.time.Instant getIssuedAt() { return issuedAt; } + public java.time.Instant getExpiresAt() { return expiresAt; } + } +} \ No newline at end of file diff --git a/user-service/src/main/java/com/healthsync/user/controller/UserController.java b/user-service/src/main/java/com/healthsync/user/controller/UserController.java new file mode 100644 index 0000000..a4518b9 --- /dev/null +++ b/user-service/src/main/java/com/healthsync/user/controller/UserController.java @@ -0,0 +1,132 @@ +package com.healthsync.user.controller; + +import com.healthsync.user.domain.Oauth.User; +import com.healthsync.user.dto.UserProfile.UserProfileResponse; +import com.healthsync.user.dto.UserProfile.UserUpdateRequest; +import com.healthsync.user.dto.UserProfile.OccupationDto; +import com.healthsync.user.service.UserProfile.UserService; +import com.healthsync.user.exception.UserNotFoundException; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.web.bind.annotation.*; + +import jakarta.validation.Valid; +import java.util.List; + +@RestController +@RequestMapping("/api/user") +@Tag(name = "사용자", description = "사용자 관리 API") +@SecurityRequirement(name = "Bearer Authentication") +public class UserController { + + private final UserService userService; + + public UserController(UserService userService) { + this.userService = userService; + } + + @GetMapping("/me") + @Operation( + summary = "내 정보 조회", + description = "현재 로그인한 사용자의 프로필 정보를 조회합니다. 직업은 코드가 아닌 직업명으로 반환됩니다." + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "사용자 정보 조회 성공", + content = @Content(schema = @Schema(implementation = UserProfileResponse.class))), + @ApiResponse(responseCode = "401", description = "인증 실패 - 유효하지 않은 토큰"), + @ApiResponse(responseCode = "404", description = "사용자를 찾을 수 없음") + }) + public ResponseEntity> getCurrentUser(@AuthenticationPrincipal Jwt jwt) { + Long memberSerialNumber = Long.valueOf(jwt.getSubject()); + + User user = userService.findById(memberSerialNumber) + .orElseThrow(() -> new UserNotFoundException("사용자를 찾을 수 없습니다: " + memberSerialNumber)); + + UserProfileResponse response = new UserProfileResponse(user); + return com.healthsync.common.response.ResponseHelper.success(response, "사용자 정보 조회 성공"); + } + + @GetMapping("/{id}") + @Operation( + summary = "사용자 정보 조회 : 안 씀.", + description = "특정 사용자의 프로필 정보를 조회합니다. 직업은 코드가 아닌 직업명으로 반환됩니다." + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "사용자 정보 조회 성공", + content = @Content(schema = @Schema(implementation = UserProfileResponse.class))), + @ApiResponse(responseCode = "401", description = "인증 실패"), + @ApiResponse(responseCode = "404", description = "사용자를 찾을 수 없음") + }) + public ResponseEntity> getUserById( + @Parameter(description = "조회할 사용자의 회원 일련번호", required = true, example = "1") + @PathVariable Long id) { + User user = userService.findById(id) + .orElseThrow(() -> new UserNotFoundException("사용자를 찾을 수 없습니다: " + id)); + + UserProfileResponse response = new UserProfileResponse(user); + return com.healthsync.common.response.ResponseHelper.success(response, "사용자 정보 조회 성공"); + } + + @PostMapping("/register") + @Operation( + summary = "내 정보 수정", + description = "현재 로그인한 사용자의 프로필 정보를 수정합니다. " + + "이름, 생년월일, 직업을 수정할 수 있습니다. " + + "직업은 직업명으로 입력하면 자동으로 코드로 변환되어 저장됩니다." + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "사용자 정보 수정 성공", + content = @Content(schema = @Schema(implementation = UserProfileResponse.class))), + @ApiResponse(responseCode = "400", description = "잘못된 요청 - 입력값 검증 실패"), + @ApiResponse(responseCode = "401", description = "인증 실패"), + @ApiResponse(responseCode = "404", description = "사용자를 찾을 수 없음") + }) + public ResponseEntity> updateCurrentUser( + @AuthenticationPrincipal Jwt jwt, + @Parameter(description = "사용자 정보 수정 요청 객체", required = true) + @Valid @RequestBody UserUpdateRequest request) { + + Long memberSerialNumber = Long.valueOf(jwt.getSubject()); + + User user = userService.findById(memberSerialNumber) + .orElseThrow(() -> new UserNotFoundException("사용자를 찾을 수 없습니다: " + memberSerialNumber)); + + user.setName(request.getName()); + user.setBirthDate(request.getBirthDate()); + // occupation은 프론트에서 직업명으로 온 것을 서비스에서 코드로 변환하여 저장 + user.setOccupation(request.getOccupation()); + + // updateUser 메서드에서 직업명 -> 코드 변환 후 저장, 응답 시 코드 -> 직업명 변환 + User updatedUser = userService.updateUser(user); + UserProfileResponse response = new UserProfileResponse(updatedUser); + + return com.healthsync.common.response.ResponseHelper.success(response, "사용자 정보 업데이트 성공"); + } + + @GetMapping("/occupations") + @Operation( + summary = "직업 목록 조회", + description = "사용자가 선택할 수 있는 직업 목록을 조회합니다. " + + "프론트엔드에서 직업 선택 드롭다운 등에 사용할 수 있습니다." + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "직업 목록 조회 성공"), + @ApiResponse(responseCode = "401", description = "인증 실패") + }) + public ResponseEntity>> getOccupations() { + List occupations = userService.getAllOccupations(); + return com.healthsync.common.response.ResponseHelper.success(occupations, "직업 목록 조회 성공"); + } + +} \ No newline at end of file diff --git a/user-service/src/main/java/com/healthsync/user/domain/HealthCheck/Gender.java b/user-service/src/main/java/com/healthsync/user/domain/HealthCheck/Gender.java new file mode 100644 index 0000000..7d1a377 --- /dev/null +++ b/user-service/src/main/java/com/healthsync/user/domain/HealthCheck/Gender.java @@ -0,0 +1,35 @@ +package com.healthsync.user.domain.HealthCheck; + +public enum Gender { + MALE(1, "남성"), + FEMALE(2, "여성"); + + private final int code; + private final String description; + + Gender(int code, String description) { + this.code = code; + this.description = description; + } + + public int getCode() { + return code; + } + + public String getDescription() { + return description; + } + + public static Gender fromCode(Integer code) { + if (code == null) { + return null; + } + + for (Gender gender : Gender.values()) { + if (gender.code == code) { + return gender; + } + } + return null; + } +} diff --git a/user-service/src/main/java/com/healthsync/user/domain/HealthCheck/HealthCheckupRaw.java b/user-service/src/main/java/com/healthsync/user/domain/HealthCheck/HealthCheckupRaw.java new file mode 100644 index 0000000..7da3566 --- /dev/null +++ b/user-service/src/main/java/com/healthsync/user/domain/HealthCheck/HealthCheckupRaw.java @@ -0,0 +1,159 @@ +package com.healthsync.user.domain.HealthCheck; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +public class HealthCheckupRaw { + private Long rawId; + private Integer referenceYear; + private LocalDate birthDate; + private String name; + private Integer regionCode; + private Integer genderCode; + private Integer age; + private Integer height; + private Integer weight; + private Integer waistCircumference; + private BigDecimal visualAcuityLeft; + private BigDecimal visualAcuityRight; + private Integer hearingLeft; + private Integer hearingRight; + private Integer systolicBp; + private Integer diastolicBp; + private Integer fastingGlucose; + private Integer totalCholesterol; + private Integer triglyceride; + private Integer hdlCholesterol; + private Integer ldlCholesterol; + private BigDecimal hemoglobin; + private Integer urineProtein; + private BigDecimal serumCreatinine; + private Integer ast; + private Integer alt; + private Integer gammaGtp; + private Integer smokingStatus; + private Integer drinkingStatus; + private LocalDateTime createdAt; + + public HealthCheckupRaw() {} + + // BMI 계산 메서드 + public BigDecimal calculateBMI() { + if (height != null && weight != null && height > 0) { + double heightInM = height / 100.0; + double bmi = weight / (heightInM * heightInM); + return BigDecimal.valueOf(bmi).setScale(1, BigDecimal.ROUND_HALF_UP); + } + return null; + } + + // 혈압 문자열 반환 메서드 + public String getBloodPressureString() { + if (systolicBp != null && diastolicBp != null) { + return systolicBp + "/" + diastolicBp; + } + return null; + } + + // Getters and Setters + public Long getRawId() { return rawId; } + public void setRawId(Long rawId) { this.rawId = rawId; } + + public Integer getReferenceYear() { return referenceYear; } + public void setReferenceYear(Integer referenceYear) { this.referenceYear = referenceYear; } + + public LocalDate getBirthDate() { return birthDate; } + public void setBirthDate(LocalDate birthDate) { this.birthDate = birthDate; } + + public String getName() { return name; } + public void setName(String name) { this.name = name; } + + public Integer getRegionCode() { return regionCode; } + public void setRegionCode(Integer regionCode) { this.regionCode = regionCode; } + + public Integer getGenderCode() { return genderCode; } + public void setGenderCode(Integer genderCode) { this.genderCode = genderCode; } + + public Integer getAge() { return age; } + public void setAge(Integer age) { this.age = age; } + + public Integer getHeight() { return height; } + public void setHeight(Integer height) { this.height = height; } + + public Integer getWeight() { return weight; } + public void setWeight(Integer weight) { this.weight = weight; } + + public Integer getWaistCircumference() { return waistCircumference; } + public void setWaistCircumference(Integer waistCircumference) { this.waistCircumference = waistCircumference; } + + public BigDecimal getVisualAcuityLeft() { return visualAcuityLeft; } + public void setVisualAcuityLeft(BigDecimal visualAcuityLeft) { this.visualAcuityLeft = visualAcuityLeft; } + + public BigDecimal getVisualAcuityRight() { return visualAcuityRight; } + public void setVisualAcuityRight(BigDecimal visualAcuityRight) { this.visualAcuityRight = visualAcuityRight; } + + public Integer getHearingLeft() { return hearingLeft; } + public void setHearingLeft(Integer hearingLeft) { this.hearingLeft = hearingLeft; } + + public Integer getHearingRight() { return hearingRight; } + public void setHearingRight(Integer hearingRight) { this.hearingRight = hearingRight; } + + public Integer getSystolicBp() { return systolicBp; } + public void setSystolicBp(Integer systolicBp) { this.systolicBp = systolicBp; } + + public Integer getDiastolicBp() { return diastolicBp; } + public void setDiastolicBp(Integer diastolicBp) { this.diastolicBp = diastolicBp; } + + public Integer getFastingGlucose() { return fastingGlucose; } + public void setFastingGlucose(Integer fastingGlucose) { this.fastingGlucose = fastingGlucose; } + + public Integer getTotalCholesterol() { return totalCholesterol; } + public void setTotalCholesterol(Integer totalCholesterol) { this.totalCholesterol = totalCholesterol; } + + public Integer getTriglyceride() { return triglyceride; } + public void setTriglyceride(Integer triglyceride) { this.triglyceride = triglyceride; } + + public Integer getHdlCholesterol() { return hdlCholesterol; } + public void setHdlCholesterol(Integer hdlCholesterol) { this.hdlCholesterol = hdlCholesterol; } + + public Integer getLdlCholesterol() { return ldlCholesterol; } + public void setLdlCholesterol(Integer ldlCholesterol) { this.ldlCholesterol = ldlCholesterol; } + + public BigDecimal getHemoglobin() { return hemoglobin; } + public void setHemoglobin(BigDecimal hemoglobin) { this.hemoglobin = hemoglobin; } + + public Integer getUrineProtein() { return urineProtein; } + public void setUrineProtein(Integer urineProtein) { this.urineProtein = urineProtein; } + + public BigDecimal getSerumCreatinine() { return serumCreatinine; } + public void setSerumCreatinine(BigDecimal serumCreatinine) { this.serumCreatinine = serumCreatinine; } + + public Integer getAst() { return ast; } + public void setAst(Integer ast) { this.ast = ast; } + + public Integer getAlt() { return alt; } + public void setAlt(Integer alt) { this.alt = alt; } + + public Integer getGammaGtp() { return gammaGtp; } + public void setGammaGtp(Integer gammaGtp) { this.gammaGtp = gammaGtp; } + + public Integer getSmokingStatus() { return smokingStatus; } + public void setSmokingStatus(Integer smokingStatus) { this.smokingStatus = smokingStatus; } + + public Integer getDrinkingStatus() { return drinkingStatus; } + public void setDrinkingStatus(Integer drinkingStatus) { this.drinkingStatus = drinkingStatus; } + + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } + + // 성별 관련 메서드 추가 + public Gender getGender() { + return Gender.fromCode(this.genderCode); + } + + public String getGenderDescription() { + Gender gender = getGender(); + return gender != null ? gender.getDescription() : "미상"; + } +} \ No newline at end of file diff --git a/user-service/src/main/java/com/healthsync/user/domain/HealthCheck/HealthNormalRange.java b/user-service/src/main/java/com/healthsync/user/domain/HealthCheck/HealthNormalRange.java new file mode 100644 index 0000000..53a8542 --- /dev/null +++ b/user-service/src/main/java/com/healthsync/user/domain/HealthCheck/HealthNormalRange.java @@ -0,0 +1,49 @@ +package com.healthsync.user.domain.HealthCheck; + +import java.time.LocalDateTime; + +public class HealthNormalRange { + private Integer rangeId; + private String healthItemCode; + private String healthItemName; + private Integer genderCode; + private String unit; + private String normalRange; + private String warningRange; + private String dangerRange; + private String note; + private LocalDateTime createdAt; + + public HealthNormalRange() {} + + // Getters and Setters + public Integer getRangeId() { return rangeId; } + public void setRangeId(Integer rangeId) { this.rangeId = rangeId; } + + public String getHealthItemCode() { return healthItemCode; } + public void setHealthItemCode(String healthItemCode) { this.healthItemCode = healthItemCode; } + + public String getHealthItemName() { return healthItemName; } + public void setHealthItemName(String healthItemName) { this.healthItemName = healthItemName; } + + public Integer getGenderCode() { return genderCode; } + public void setGenderCode(Integer genderCode) { this.genderCode = genderCode; } + + public String getUnit() { return unit; } + public void setUnit(String unit) { this.unit = unit; } + + public String getNormalRange() { return normalRange; } + public void setNormalRange(String normalRange) { this.normalRange = normalRange; } + + public String getWarningRange() { return warningRange; } + public void setWarningRange(String warningRange) { this.warningRange = warningRange; } + + public String getDangerRange() { return dangerRange; } + public void setDangerRange(String dangerRange) { this.dangerRange = dangerRange; } + + public String getNote() { return note; } + public void setNote(String note) { this.note = note; } + + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } +} \ No newline at end of file diff --git a/user-service/src/main/java/com/healthsync/user/domain/Oauth/JobCategory.java b/user-service/src/main/java/com/healthsync/user/domain/Oauth/JobCategory.java new file mode 100644 index 0000000..bca9b9e --- /dev/null +++ b/user-service/src/main/java/com/healthsync/user/domain/Oauth/JobCategory.java @@ -0,0 +1,36 @@ +package com.healthsync.user.domain.Oauth; + +public enum JobCategory { + DEVELOPER(1, "개발"), + PM(2, "PM"), + MARKETING(3, "마케팅"), + SALES(4, "영업"), + INFRA_OPERATION(5, "인프라운영"), + CUSTOMER_SERVICE(6, "고객상담"), + ETC(7, "기타"); + + private final int code; + private final String name; + + JobCategory(int code, String name) { + this.code = code; + this.name = name; + } + + public int getCode() { + return code; + } + + public String getName() { + return name; + } + + public static JobCategory fromCode(int code) { + for (JobCategory category : JobCategory.values()) { + if (category.code == code) { + return category; + } + } + return ETC; // 기본값 + } +} diff --git a/user-service/src/main/java/com/healthsync/user/domain/Oauth/RefreshToken.java b/user-service/src/main/java/com/healthsync/user/domain/Oauth/RefreshToken.java new file mode 100644 index 0000000..fd373a7 --- /dev/null +++ b/user-service/src/main/java/com/healthsync/user/domain/Oauth/RefreshToken.java @@ -0,0 +1,42 @@ +package com.healthsync.user.domain.Oauth; + +import java.time.LocalDateTime; + +public class RefreshToken { + private Long id; + private String token; + private Long memberSerialNumber; // memberId -> memberSerialNumber로 변경 + private LocalDateTime expiryDate; + private LocalDateTime createdAt; + + public RefreshToken() { + this.createdAt = LocalDateTime.now(); + } + + public RefreshToken(String token, Long memberSerialNumber, LocalDateTime expiryDate) { + this(); + this.token = token; + this.memberSerialNumber = memberSerialNumber; + this.expiryDate = expiryDate; + } + + public boolean isExpired() { + return LocalDateTime.now().isAfter(expiryDate); + } + + // Getters and Setters + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + + public String getToken() { return token; } + public void setToken(String token) { this.token = token; } + + public Long getMemberSerialNumber() { return memberSerialNumber; } + public void setMemberSerialNumber(Long memberSerialNumber) { this.memberSerialNumber = memberSerialNumber; } + + public LocalDateTime getExpiryDate() { return expiryDate; } + public void setExpiryDate(LocalDateTime expiryDate) { this.expiryDate = expiryDate; } + + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } +} diff --git a/user-service/src/main/java/com/healthsync/user/domain/Oauth/User.java b/user-service/src/main/java/com/healthsync/user/domain/Oauth/User.java new file mode 100644 index 0000000..6c08c07 --- /dev/null +++ b/user-service/src/main/java/com/healthsync/user/domain/Oauth/User.java @@ -0,0 +1,71 @@ +package com.healthsync.user.domain.Oauth; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +public class User { + private Long memberSerialNumber; // ID는 DB에서 자동 생성 + private String googleId; + private String name; + private LocalDate birthDate; + private String occupation; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + private LocalDateTime lastLoginAt; + + public User() { + this.createdAt = LocalDateTime.now(); + this.updatedAt = LocalDateTime.now(); + } + + // 신규 사용자 생성용 생성자 (ID 없음) + public User(String googleId, String name, LocalDate birthDate, String occupation) { + this(); // 기본 생성자 호출 + this.googleId = googleId; + this.name = name; + this.birthDate = birthDate; + this.occupation = occupation; + this.lastLoginAt = LocalDateTime.now(); + // memberSerialNumber는 설정하지 않음 - DB에서 자동 생성 + } + + // 로그인 시간 업데이트 + public void updateLastLoginAt() { + this.lastLoginAt = LocalDateTime.now(); + this.updatedAt = LocalDateTime.now(); + } + + // Getters and Setters + public Long getMemberSerialNumber() { return memberSerialNumber; } + public void setMemberSerialNumber(Long memberSerialNumber) { this.memberSerialNumber = memberSerialNumber; } + + public String getGoogleId() { return googleId; } + public void setGoogleId(String googleId) { this.googleId = googleId; } + + public String getName() { return name; } + public void setName(String name) { + this.name = name; + this.updatedAt = LocalDateTime.now(); + } + + public LocalDate getBirthDate() { return birthDate; } + public void setBirthDate(LocalDate birthDate) { + this.birthDate = birthDate; + this.updatedAt = LocalDateTime.now(); + } + + public String getOccupation() { return occupation; } + public void setOccupation(String occupation) { + this.occupation = occupation; + this.updatedAt = LocalDateTime.now(); + } + + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } + + public LocalDateTime getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; } + + public LocalDateTime getLastLoginAt() { return lastLoginAt; } + public void setLastLoginAt(LocalDateTime lastLoginAt) { this.lastLoginAt = lastLoginAt; } +} \ No newline at end of file diff --git a/user-service/src/main/java/com/healthsync/user/domain/Oauth/UserRole.java b/user-service/src/main/java/com/healthsync/user/domain/Oauth/UserRole.java new file mode 100644 index 0000000..2b33e5a --- /dev/null +++ b/user-service/src/main/java/com/healthsync/user/domain/Oauth/UserRole.java @@ -0,0 +1,5 @@ +package com.healthsync.user.domain.Oauth; + +public enum UserRole { + USER, ADMIN +} diff --git a/user-service/src/main/java/com/healthsync/user/dto/HealthCheck/HealthCheckupHistoryResponse.java b/user-service/src/main/java/com/healthsync/user/dto/HealthCheck/HealthCheckupHistoryResponse.java new file mode 100644 index 0000000..e2481ab --- /dev/null +++ b/user-service/src/main/java/com/healthsync/user/dto/HealthCheck/HealthCheckupHistoryResponse.java @@ -0,0 +1,32 @@ +package com.healthsync.user.dto.HealthCheck; + +import com.healthsync.user.domain.HealthCheck.HealthCheckupRaw; +import com.healthsync.user.domain.HealthCheck.HealthNormalRange; + +import java.util.List; + +public class HealthCheckupHistoryResponse { + private HealthCheckupRaw recentCheckup; + private List normalRanges; + private String statusNote; + + public HealthCheckupHistoryResponse() {} + + public HealthCheckupHistoryResponse(HealthCheckupRaw recentCheckup, + List normalRanges, + String statusNote) { + this.recentCheckup = recentCheckup; + this.normalRanges = normalRanges; + this.statusNote = statusNote; + } + + // Getters and Setters + public HealthCheckupRaw getRecentCheckup() { return recentCheckup; } + public void setRecentCheckup(HealthCheckupRaw recentCheckup) { this.recentCheckup = recentCheckup; } + + public List getNormalRanges() { return normalRanges; } + public void setNormalRanges(List normalRanges) { this.normalRanges = normalRanges; } + + public String getStatusNote() { return statusNote; } + public void setStatusNote(String statusNote) { this.statusNote = statusNote; } +} diff --git a/user-service/src/main/java/com/healthsync/user/dto/HealthCheck/HealthProfileSummaryResponse.java b/user-service/src/main/java/com/healthsync/user/dto/HealthCheck/HealthProfileSummaryResponse.java new file mode 100644 index 0000000..c74fb79 --- /dev/null +++ b/user-service/src/main/java/com/healthsync/user/dto/HealthCheck/HealthProfileSummaryResponse.java @@ -0,0 +1,47 @@ +package com.healthsync.user.dto.HealthCheck; + +import com.healthsync.user.domain.HealthCheck.HealthCheckupRaw; +import com.healthsync.user.domain.HealthCheck.HealthNormalRange; +import com.healthsync.user.domain.HealthCheck.Gender; + +import java.util.List; + +public class HealthProfileSummaryResponse { + private HealthCheckupRaw recentCheckup; + private List normalRanges; + private Gender gender; + private String genderDescription; + + public HealthProfileSummaryResponse() {} + + + public HealthProfileSummaryResponse(HealthCheckupRaw recentCheckup) { + this.recentCheckup = recentCheckup; + } + + public HealthProfileSummaryResponse(HealthCheckupRaw recentCheckup, List normalRanges) { + this.recentCheckup = recentCheckup; + this.normalRanges = normalRanges; + this.gender = recentCheckup.getGender(); + this.genderDescription = recentCheckup.getGenderDescription(); + } + + // Getters and Setters + public HealthCheckupRaw getRecentCheckup() { return recentCheckup; } + public void setRecentCheckup(HealthCheckupRaw recentCheckup) { + this.recentCheckup = recentCheckup; + if (recentCheckup != null) { + this.gender = recentCheckup.getGender(); + this.genderDescription = recentCheckup.getGenderDescription(); + } + } + + public List getNormalRanges() { return normalRanges; } + public void setNormalRanges(List normalRanges) { this.normalRanges = normalRanges; } + + public Gender getGender() { return gender; } + public void setGender(Gender gender) { this.gender = gender; } + + public String getGenderDescription() { return genderDescription; } + public void setGenderDescription(String genderDescription) { this.genderDescription = genderDescription; } +} \ No newline at end of file diff --git a/user-service/src/main/java/com/healthsync/user/dto/Oauth/OAuth2UserInfo.java b/user-service/src/main/java/com/healthsync/user/dto/Oauth/OAuth2UserInfo.java new file mode 100644 index 0000000..f6d6628 --- /dev/null +++ b/user-service/src/main/java/com/healthsync/user/dto/Oauth/OAuth2UserInfo.java @@ -0,0 +1,31 @@ +package com.healthsync.user.dto.Oauth; + +import java.util.Map; + +public class OAuth2UserInfo { + private Map attributes; + + public OAuth2UserInfo(Map attributes) { + this.attributes = attributes; + } + + public String getId() { + return (String) attributes.get("sub"); + } + + public String getName() { + return (String) attributes.get("name"); + } + + public String getEmail() { + return (String) attributes.get("email"); + } + + public String getImageUrl() { + return (String) attributes.get("picture"); + } + + public Map getAttributes() { + return attributes; + } +} \ No newline at end of file diff --git a/user-service/src/main/java/com/healthsync/user/dto/Oauth/TokenRefreshRequest.java b/user-service/src/main/java/com/healthsync/user/dto/Oauth/TokenRefreshRequest.java new file mode 100644 index 0000000..3100a7e --- /dev/null +++ b/user-service/src/main/java/com/healthsync/user/dto/Oauth/TokenRefreshRequest.java @@ -0,0 +1,19 @@ +package com.healthsync.user.dto.Oauth; + +import jakarta.validation.constraints.NotBlank; + +public class TokenRefreshRequest { + @NotBlank(message = "리프레시 토큰은 필수입니다") + private String refreshToken; + + public TokenRefreshRequest() {} + + public TokenRefreshRequest(String refreshToken) { + this.refreshToken = refreshToken; + } + + public String getRefreshToken() { return refreshToken; } + public void setRefreshToken(String refreshToken) { this.refreshToken = refreshToken; } +} + + diff --git a/user-service/src/main/java/com/healthsync/user/dto/Oauth/TokenResponse.java b/user-service/src/main/java/com/healthsync/user/dto/Oauth/TokenResponse.java new file mode 100644 index 0000000..de26345 --- /dev/null +++ b/user-service/src/main/java/com/healthsync/user/dto/Oauth/TokenResponse.java @@ -0,0 +1,23 @@ +package com.healthsync.user.dto.Oauth; + +public class TokenResponse { + private String accessToken; + private String refreshToken; + private String tokenType = "Bearer"; + + public TokenResponse() {} + + public TokenResponse(String accessToken, String refreshToken) { + this.accessToken = accessToken; + this.refreshToken = refreshToken; + } + + public String getAccessToken() { return accessToken; } + public void setAccessToken(String accessToken) { this.accessToken = accessToken; } + + public String getRefreshToken() { return refreshToken; } + public void setRefreshToken(String refreshToken) { this.refreshToken = refreshToken; } + + public String getTokenType() { return tokenType; } + public void setTokenType(String tokenType) { this.tokenType = tokenType; } +} \ No newline at end of file diff --git a/user-service/src/main/java/com/healthsync/user/dto/UserProfile/LoginResponse.java b/user-service/src/main/java/com/healthsync/user/dto/UserProfile/LoginResponse.java new file mode 100644 index 0000000..f464e16 --- /dev/null +++ b/user-service/src/main/java/com/healthsync/user/dto/UserProfile/LoginResponse.java @@ -0,0 +1,30 @@ +package com.healthsync.user.dto.UserProfile; + +public class LoginResponse { + + private String accessToken; + private String refreshToken; + private UserProfileResponse user; + private boolean isNewUser; + + public LoginResponse() {} + + public LoginResponse(String accessToken, String refreshToken, UserProfileResponse user, boolean isNewUser) { + this.accessToken = accessToken; + this.refreshToken = refreshToken; + this.user = user; + this.isNewUser = isNewUser; + } + + public String getAccessToken() { return accessToken; } + public void setAccessToken(String accessToken) { this.accessToken = accessToken; } + + public String getRefreshToken() { return refreshToken; } + public void setRefreshToken(String refreshToken) { this.refreshToken = refreshToken; } + + public UserProfileResponse getUser() { return user; } + public void setUser(UserProfileResponse user) { this.user = user; } + + public boolean isNewUser() { return isNewUser; } + public void setNewUser(boolean isNewUser) { this.isNewUser = isNewUser; } +} \ No newline at end of file diff --git a/user-service/src/main/java/com/healthsync/user/dto/UserProfile/OccupationDto.java b/user-service/src/main/java/com/healthsync/user/dto/UserProfile/OccupationDto.java new file mode 100644 index 0000000..11c1d5b --- /dev/null +++ b/user-service/src/main/java/com/healthsync/user/dto/UserProfile/OccupationDto.java @@ -0,0 +1,43 @@ +package com.healthsync.user.dto.UserProfile; + +/** + * 직업 정보 DTO + */ +public class OccupationDto { + private String code; + private String name; + private String category; + + public OccupationDto() {} + + public OccupationDto(String code, String name, String category) { + this.code = code; + this.name = name; + this.category = category; + } + + // Getters and Setters + public String getCode() { + return code; + } + + public void setCode(String code) { + this.code = code; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getCategory() { + return category; + } + + public void setCategory(String category) { + this.category = category; + } +} \ No newline at end of file diff --git a/user-service/src/main/java/com/healthsync/user/dto/UserProfile/UserProfileResponse.java b/user-service/src/main/java/com/healthsync/user/dto/UserProfile/UserProfileResponse.java new file mode 100644 index 0000000..9c04a18 --- /dev/null +++ b/user-service/src/main/java/com/healthsync/user/dto/UserProfile/UserProfileResponse.java @@ -0,0 +1,50 @@ +package com.healthsync.user.dto.UserProfile; + +import com.healthsync.user.domain.Oauth.User; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +public class UserProfileResponse { + private Long memberSerialNumber; + private String googleId; + private String name; + private LocalDate birthDate; + private String occupation; + private LocalDateTime createdAt; + private LocalDateTime lastLoginAt; + + public UserProfileResponse() {} + + public UserProfileResponse(User user) { + this.memberSerialNumber = user.getMemberSerialNumber(); + this.googleId = user.getGoogleId(); + this.name = user.getName(); + this.birthDate = user.getBirthDate(); + this.occupation = user.getOccupation(); + this.createdAt = user.getCreatedAt(); + this.lastLoginAt = user.getLastLoginAt(); + } + + // Getters and Setters + public Long getMemberSerialNumber() { return memberSerialNumber; } + public void setMemberSerialNumber(Long memberSerialNumber) { this.memberSerialNumber = memberSerialNumber; } + + public String getGoogleId() { return googleId; } + public void setGoogleId(String googleId) { this.googleId = googleId; } + + public String getName() { return name; } + public void setName(String name) { this.name = name; } + + public LocalDate getBirthDate() { return birthDate; } + public void setBirthDate(LocalDate birthDate) { this.birthDate = birthDate; } + + public String getOccupation() { return occupation; } + public void setOccupation(String occupation) { this.occupation = occupation; } + + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } + + public LocalDateTime getLastLoginAt() { return lastLoginAt; } + public void setLastLoginAt(LocalDateTime lastLoginAt) { this.lastLoginAt = lastLoginAt; } +} \ No newline at end of file diff --git a/user-service/src/main/java/com/healthsync/user/dto/UserProfile/UserUpdateRequest.java b/user-service/src/main/java/com/healthsync/user/dto/UserProfile/UserUpdateRequest.java new file mode 100644 index 0000000..6c49221 --- /dev/null +++ b/user-service/src/main/java/com/healthsync/user/dto/UserProfile/UserUpdateRequest.java @@ -0,0 +1,58 @@ +package com.healthsync.user.dto.UserProfile; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Past; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + +public class UserUpdateRequest { + + @NotBlank(message = "이름은 필수입니다") + private String name; + + @NotBlank(message = "생년월일은 필수입니다") + private String birthDate; + + private String occupation; + + public UserUpdateRequest() { + } + + public UserUpdateRequest(String name, String birthDate, String occupation) { + this.name = name; + this.birthDate = birthDate; + this.occupation = occupation; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public LocalDate getBirthDate() { + if (birthDate == null || birthDate.trim().isEmpty()) { + return null; + } + return LocalDate.parse(birthDate, DateTimeFormatter.ISO_LOCAL_DATE); + } + + public String getBirthDateString() { + return birthDate; + } + + public void setBirthDate(String birthDate) { + this.birthDate = birthDate; + } + + public String getOccupation() { + return occupation; + } + + public void setOccupation(String occupation) { + this.occupation = occupation; + } +} \ No newline at end of file diff --git a/user-service/src/main/java/com/healthsync/user/exception/AuthenticationException.java b/user-service/src/main/java/com/healthsync/user/exception/AuthenticationException.java new file mode 100644 index 0000000..c7d2f70 --- /dev/null +++ b/user-service/src/main/java/com/healthsync/user/exception/AuthenticationException.java @@ -0,0 +1,13 @@ +package com.healthsync.user.exception; + +import com.healthsync.common.exception.CustomException; + +public class AuthenticationException extends CustomException { + public AuthenticationException(String message) { + super(message); + } + + public AuthenticationException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/user-service/src/main/java/com/healthsync/user/exception/TokenExpiredException.java b/user-service/src/main/java/com/healthsync/user/exception/TokenExpiredException.java new file mode 100644 index 0000000..220c762 --- /dev/null +++ b/user-service/src/main/java/com/healthsync/user/exception/TokenExpiredException.java @@ -0,0 +1,13 @@ +package com.healthsync.user.exception; + +import com.healthsync.common.exception.CustomException; + +public class TokenExpiredException extends CustomException { + public TokenExpiredException(String message) { + super(message); + } + + public TokenExpiredException(String message, Throwable cause) { + super(message, cause); + } +} \ No newline at end of file diff --git a/user-service/src/main/java/com/healthsync/user/exception/UserNotFoundException.java b/user-service/src/main/java/com/healthsync/user/exception/UserNotFoundException.java new file mode 100644 index 0000000..d18d58c --- /dev/null +++ b/user-service/src/main/java/com/healthsync/user/exception/UserNotFoundException.java @@ -0,0 +1,13 @@ +package com.healthsync.user.exception; + +import com.healthsync.common.exception.CustomException; + +public class UserNotFoundException extends CustomException { + public UserNotFoundException(String message) { + super(message); + } + + public UserNotFoundException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/user-service/src/main/java/com/healthsync/user/repository/entity/HealthCheckupRawEntity.java b/user-service/src/main/java/com/healthsync/user/repository/entity/HealthCheckupRawEntity.java new file mode 100644 index 0000000..775c9c5 --- /dev/null +++ b/user-service/src/main/java/com/healthsync/user/repository/entity/HealthCheckupRawEntity.java @@ -0,0 +1,269 @@ +package com.healthsync.user.repository.entity; + +import com.healthsync.user.domain.HealthCheck.HealthCheckupRaw; +import jakarta.persistence.*; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Entity +@Table(name = "health_checkup_raw", schema = "health_service") +public class HealthCheckupRawEntity { + + @Id + @Column(name = "raw_id") + private Long rawId; + + @Column(name = "reference_year", nullable = false) + private Integer referenceYear; + + @Column(name = "birth_date", nullable = false) + private LocalDate birthDate; + + @Column(name = "name", nullable = false, length = 50) + private String name; + + @Column(name = "region_code") + private Integer regionCode; + + @Column(name = "gender_code") + private Integer genderCode; + + @Column(name = "age") + private Integer age; + + @Column(name = "height") + private Integer height; + + @Column(name = "weight") + private Integer weight; + + @Column(name = "waist_circumference") + private Integer waistCircumference; + + @Column(name = "visual_acuity_left", precision = 3, scale = 1) + private BigDecimal visualAcuityLeft; + + @Column(name = "visual_acuity_right", precision = 3, scale = 1) + private BigDecimal visualAcuityRight; + + @Column(name = "hearing_left") + private Integer hearingLeft; + + @Column(name = "hearing_right") + private Integer hearingRight; + + @Column(name = "systolic_bp") + private Integer systolicBp; + + @Column(name = "diastolic_bp") + private Integer diastolicBp; + + @Column(name = "fasting_glucose") + private Integer fastingGlucose; + + @Column(name = "total_cholesterol") + private Integer totalCholesterol; + + @Column(name = "triglyceride") + private Integer triglyceride; + + @Column(name = "hdl_cholesterol") + private Integer hdlCholesterol; + + @Column(name = "ldl_cholesterol") + private Integer ldlCholesterol; + + @Column(name = "hemoglobin", precision = 4, scale = 1) + private BigDecimal hemoglobin; + + @Column(name = "urine_protein") + private Integer urineProtein; + + @Column(name = "serum_creatinine", precision = 4, scale = 1) + private BigDecimal serumCreatinine; + + @Column(name = "ast") + private Integer ast; + + @Column(name = "alt") + private Integer alt; + + @Column(name = "gamma_gtp") + private Integer gammaGtp; + + @Column(name = "smoking_status") + private Integer smokingStatus; + + @Column(name = "drinking_status") + private Integer drinkingStatus; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + protected HealthCheckupRawEntity() {} + + // Getters and Setters + public Long getRawId() { return rawId; } + public void setRawId(Long rawId) { this.rawId = rawId; } + + public Integer getReferenceYear() { return referenceYear; } + public void setReferenceYear(Integer referenceYear) { this.referenceYear = referenceYear; } + + public LocalDate getBirthDate() { return birthDate; } + public void setBirthDate(LocalDate birthDate) { this.birthDate = birthDate; } + + public String getName() { return name; } + public void setName(String name) { this.name = name; } + + public Integer getRegionCode() { return regionCode; } + public void setRegionCode(Integer regionCode) { this.regionCode = regionCode; } + + public Integer getGenderCode() { return genderCode; } + public void setGenderCode(Integer genderCode) { this.genderCode = genderCode; } + + public Integer getAge() { return age; } + public void setAge(Integer age) { this.age = age; } + + public Integer getHeight() { return height; } + public void setHeight(Integer height) { this.height = height; } + + public Integer getWeight() { return weight; } + public void setWeight(Integer weight) { this.weight = weight; } + + public Integer getWaistCircumference() { return waistCircumference; } + public void setWaistCircumference(Integer waistCircumference) { this.waistCircumference = waistCircumference; } + + public BigDecimal getVisualAcuityLeft() { return visualAcuityLeft; } + public void setVisualAcuityLeft(BigDecimal visualAcuityLeft) { this.visualAcuityLeft = visualAcuityLeft; } + + public BigDecimal getVisualAcuityRight() { return visualAcuityRight; } + public void setVisualAcuityRight(BigDecimal visualAcuityRight) { this.visualAcuityRight = visualAcuityRight; } + + public Integer getHearingLeft() { return hearingLeft; } + public void setHearingLeft(Integer hearingLeft) { this.hearingLeft = hearingLeft; } + + public Integer getHearingRight() { return hearingRight; } + public void setHearingRight(Integer hearingRight) { this.hearingRight = hearingRight; } + + public Integer getSystolicBp() { return systolicBp; } + public void setSystolicBp(Integer systolicBp) { this.systolicBp = systolicBp; } + + public Integer getDiastolicBp() { return diastolicBp; } + public void setDiastolicBp(Integer diastolicBp) { this.diastolicBp = diastolicBp; } + + public Integer getFastingGlucose() { return fastingGlucose; } + public void setFastingGlucose(Integer fastingGlucose) { this.fastingGlucose = fastingGlucose; } + + public Integer getTotalCholesterol() { return totalCholesterol; } + public void setTotalCholesterol(Integer totalCholesterol) { this.totalCholesterol = totalCholesterol; } + + public Integer getTriglyceride() { return triglyceride; } + public void setTriglyceride(Integer triglyceride) { this.triglyceride = triglyceride; } + + public Integer getHdlCholesterol() { return hdlCholesterol; } + public void setHdlCholesterol(Integer hdlCholesterol) { this.hdlCholesterol = hdlCholesterol; } + + public Integer getLdlCholesterol() { return ldlCholesterol; } + public void setLdlCholesterol(Integer ldlCholesterol) { this.ldlCholesterol = ldlCholesterol; } + + public BigDecimal getHemoglobin() { return hemoglobin; } + public void setHemoglobin(BigDecimal hemoglobin) { this.hemoglobin = hemoglobin; } + + public Integer getUrineProtein() { return urineProtein; } + public void setUrineProtein(Integer urineProtein) { this.urineProtein = urineProtein; } + + public BigDecimal getSerumCreatinine() { return serumCreatinine; } + public void setSerumCreatinine(BigDecimal serumCreatinine) { this.serumCreatinine = serumCreatinine; } + + public Integer getAst() { return ast; } + public void setAst(Integer ast) { this.ast = ast; } + + public Integer getAlt() { return alt; } + public void setAlt(Integer alt) { this.alt = alt; } + + public Integer getGammaGtp() { return gammaGtp; } + public void setGammaGtp(Integer gammaGtp) { this.gammaGtp = gammaGtp; } + + public Integer getSmokingStatus() { return smokingStatus; } + public void setSmokingStatus(Integer smokingStatus) { this.smokingStatus = smokingStatus; } + + public Integer getDrinkingStatus() { return drinkingStatus; } + public void setDrinkingStatus(Integer drinkingStatus) { this.drinkingStatus = drinkingStatus; } + + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } + + // Entity ↔ Domain 변환 메서드 + public static HealthCheckupRawEntity fromDomain(HealthCheckupRaw healthCheckupRaw) { + if (healthCheckupRaw == null) return null; + + HealthCheckupRawEntity entity = new HealthCheckupRawEntity(); + entity.rawId = healthCheckupRaw.getRawId(); + entity.referenceYear = healthCheckupRaw.getReferenceYear(); + entity.birthDate = healthCheckupRaw.getBirthDate(); + entity.name = healthCheckupRaw.getName(); + entity.regionCode = healthCheckupRaw.getRegionCode(); + entity.genderCode = healthCheckupRaw.getGenderCode(); + entity.age = healthCheckupRaw.getAge(); + entity.height = healthCheckupRaw.getHeight(); + entity.weight = healthCheckupRaw.getWeight(); + entity.waistCircumference = healthCheckupRaw.getWaistCircumference(); + entity.visualAcuityLeft = healthCheckupRaw.getVisualAcuityLeft(); + entity.visualAcuityRight = healthCheckupRaw.getVisualAcuityRight(); + entity.hearingLeft = healthCheckupRaw.getHearingLeft(); + entity.hearingRight = healthCheckupRaw.getHearingRight(); + entity.systolicBp = healthCheckupRaw.getSystolicBp(); + entity.diastolicBp = healthCheckupRaw.getDiastolicBp(); + entity.fastingGlucose = healthCheckupRaw.getFastingGlucose(); + entity.totalCholesterol = healthCheckupRaw.getTotalCholesterol(); + entity.triglyceride = healthCheckupRaw.getTriglyceride(); + entity.hdlCholesterol = healthCheckupRaw.getHdlCholesterol(); + entity.ldlCholesterol = healthCheckupRaw.getLdlCholesterol(); + entity.hemoglobin = healthCheckupRaw.getHemoglobin(); + entity.urineProtein = healthCheckupRaw.getUrineProtein(); + entity.serumCreatinine = healthCheckupRaw.getSerumCreatinine(); + entity.ast = healthCheckupRaw.getAst(); + entity.alt = healthCheckupRaw.getAlt(); + entity.gammaGtp = healthCheckupRaw.getGammaGtp(); + entity.smokingStatus = healthCheckupRaw.getSmokingStatus(); + entity.drinkingStatus = healthCheckupRaw.getDrinkingStatus(); + entity.createdAt = healthCheckupRaw.getCreatedAt(); + return entity; + } + + public HealthCheckupRaw toDomain() { + HealthCheckupRaw domain = new HealthCheckupRaw(); + domain.setRawId(this.rawId); + domain.setReferenceYear(this.referenceYear); + domain.setBirthDate(this.birthDate); + domain.setName(this.name); + domain.setRegionCode(this.regionCode); + domain.setGenderCode(this.genderCode); + domain.setAge(this.age); + domain.setHeight(this.height); + domain.setWeight(this.weight); + domain.setWaistCircumference(this.waistCircumference); + domain.setVisualAcuityLeft(this.visualAcuityLeft); + domain.setVisualAcuityRight(this.visualAcuityRight); + domain.setHearingLeft(this.hearingLeft); + domain.setHearingRight(this.hearingRight); + domain.setSystolicBp(this.systolicBp); + domain.setDiastolicBp(this.diastolicBp); + domain.setFastingGlucose(this.fastingGlucose); + domain.setTotalCholesterol(this.totalCholesterol); + domain.setTriglyceride(this.triglyceride); + domain.setHdlCholesterol(this.hdlCholesterol); + domain.setLdlCholesterol(this.ldlCholesterol); + domain.setHemoglobin(this.hemoglobin); + domain.setUrineProtein(this.urineProtein); + domain.setSerumCreatinine(this.serumCreatinine); + domain.setAst(this.ast); + domain.setAlt(this.alt); + domain.setGammaGtp(this.gammaGtp); + domain.setSmokingStatus(this.smokingStatus); + domain.setDrinkingStatus(this.drinkingStatus); + domain.setCreatedAt(this.createdAt); + return domain; + } +} \ No newline at end of file diff --git a/user-service/src/main/java/com/healthsync/user/repository/entity/HealthNormalRangeEntity.java b/user-service/src/main/java/com/healthsync/user/repository/entity/HealthNormalRangeEntity.java new file mode 100644 index 0000000..e4ef2f3 --- /dev/null +++ b/user-service/src/main/java/com/healthsync/user/repository/entity/HealthNormalRangeEntity.java @@ -0,0 +1,107 @@ +package com.healthsync.user.repository.entity; + +import com.healthsync.user.domain.HealthCheck.HealthNormalRange; +import jakarta.persistence.*; +import java.time.LocalDateTime; + +@Entity +@Table(name = "health_normal_range", schema = "health_service") +public class HealthNormalRangeEntity { + + @Id + @Column(name = "range_id") + private Integer rangeId; + + @Column(name = "health_item_code", length = 25) + private String healthItemCode; + + @Column(name = "health_item_name", length = 30) + private String healthItemName; + + @Column(name = "gender_code") + private Integer genderCode; + + @Column(name = "unit", length = 10) + private String unit; + + @Column(name = "normal_range", length = 15) + private String normalRange; + + @Column(name = "warning_range", length = 15) + private String warningRange; + + @Column(name = "danger_range", length = 15) + private String dangerRange; + + @Column(name = "note", length = 50) + private String note; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + protected HealthNormalRangeEntity() {} + + // Getters and Setters + public Integer getRangeId() { return rangeId; } + public void setRangeId(Integer rangeId) { this.rangeId = rangeId; } + + public String getHealthItemCode() { return healthItemCode; } + public void setHealthItemCode(String healthItemCode) { this.healthItemCode = healthItemCode; } + + public String getHealthItemName() { return healthItemName; } + public void setHealthItemName(String healthItemName) { this.healthItemName = healthItemName; } + + public Integer getGenderCode() { return genderCode; } + public void setGenderCode(Integer genderCode) { this.genderCode = genderCode; } + + public String getUnit() { return unit; } + public void setUnit(String unit) { this.unit = unit; } + + public String getNormalRange() { return normalRange; } + public void setNormalRange(String normalRange) { this.normalRange = normalRange; } + + public String getWarningRange() { return warningRange; } + public void setWarningRange(String warningRange) { this.warningRange = warningRange; } + + public String getDangerRange() { return dangerRange; } + public void setDangerRange(String dangerRange) { this.dangerRange = dangerRange; } + + public String getNote() { return note; } + public void setNote(String note) { this.note = note; } + + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } + + // Entity ↔ Domain 변환 메서드 + public static HealthNormalRangeEntity fromDomain(HealthNormalRange healthNormalRange) { + if (healthNormalRange == null) return null; + + HealthNormalRangeEntity entity = new HealthNormalRangeEntity(); + entity.rangeId = healthNormalRange.getRangeId(); + entity.healthItemCode = healthNormalRange.getHealthItemCode(); + entity.healthItemName = healthNormalRange.getHealthItemName(); + entity.genderCode = healthNormalRange.getGenderCode(); + entity.unit = healthNormalRange.getUnit(); + entity.normalRange = healthNormalRange.getNormalRange(); + entity.warningRange = healthNormalRange.getWarningRange(); + entity.dangerRange = healthNormalRange.getDangerRange(); + entity.note = healthNormalRange.getNote(); + entity.createdAt = healthNormalRange.getCreatedAt(); + return entity; + } + + public HealthNormalRange toDomain() { + HealthNormalRange domain = new HealthNormalRange(); + domain.setRangeId(this.rangeId); + domain.setHealthItemCode(this.healthItemCode); + domain.setHealthItemName(this.healthItemName); + domain.setGenderCode(this.genderCode); + domain.setUnit(this.unit); + domain.setNormalRange(this.normalRange); + domain.setWarningRange(this.warningRange); + domain.setDangerRange(this.dangerRange); + domain.setNote(this.note); + domain.setCreatedAt(this.createdAt); + return domain; + } +} \ No newline at end of file diff --git a/user-service/src/main/java/com/healthsync/user/repository/entity/OccupationTypeEntity.java b/user-service/src/main/java/com/healthsync/user/repository/entity/OccupationTypeEntity.java new file mode 100644 index 0000000..2011672 --- /dev/null +++ b/user-service/src/main/java/com/healthsync/user/repository/entity/OccupationTypeEntity.java @@ -0,0 +1,55 @@ +package com.healthsync.user.repository.entity; + +import jakarta.persistence.*; + +/** + * 직업 유형 정보를 담는 엔티티 + * occupation_type 테이블과 매핑 + */ +@Entity +@Table(name = "occupation_type", schema = "user_service") +public class OccupationTypeEntity { + + @Id + @Column(name = "occupation_code", length = 20) + private String occupationCode; + + @Column(name = "occupation_name", length = 100, nullable = false) + private String occupationName; + + @Column(name = "category", length = 50) + private String category; + + protected OccupationTypeEntity() {} + + public OccupationTypeEntity(String occupationCode, String occupationName, String category) { + this.occupationCode = occupationCode; + this.occupationName = occupationName; + this.category = category; + } + + // Getters and Setters + public String getOccupationCode() { + return occupationCode; + } + + public void setOccupationCode(String occupationCode) { + this.occupationCode = occupationCode; + } + + public String getOccupationName() { + return occupationName; + } + + public void setOccupationName(String occupationName) { + this.occupationName = occupationName; + } + + public String getCategory() { + return category; + } + + public void setCategory(String category) { + this.category = category; + } +} \ No newline at end of file diff --git a/user-service/src/main/java/com/healthsync/user/repository/entity/RefreshTokenEntity.java b/user-service/src/main/java/com/healthsync/user/repository/entity/RefreshTokenEntity.java new file mode 100644 index 0000000..e221376 --- /dev/null +++ b/user-service/src/main/java/com/healthsync/user/repository/entity/RefreshTokenEntity.java @@ -0,0 +1,75 @@ +package com.healthsync.user.repository.entity; + +import com.healthsync.user.domain.Oauth.RefreshToken; +import jakarta.persistence.*; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "refresh_tokens", schema = "user_service") +@EntityListeners(AuditingEntityListener.class) +public class RefreshTokenEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(unique = true, nullable = false, length = 500) + private String token; + + @Column(name = "member_serial_number", nullable = false) + private Long memberSerialNumber; + + @Column(name = "expiry_date", nullable = false) + private LocalDateTime expiryDate; + + @CreatedDate + @Column(name = "created_at", updatable = false) + private LocalDateTime createdAt; + + protected RefreshTokenEntity() {} + + public RefreshTokenEntity(String token, Long memberSerialNumber, LocalDateTime expiryDate) { + this.token = token; + this.memberSerialNumber = memberSerialNumber; + this.expiryDate = expiryDate; + } + + public static RefreshTokenEntity fromDomain(RefreshToken refreshToken) { + RefreshTokenEntity entity = new RefreshTokenEntity(); + entity.id = refreshToken.getId(); + entity.token = refreshToken.getToken(); + entity.memberSerialNumber = refreshToken.getMemberSerialNumber(); + entity.expiryDate = refreshToken.getExpiryDate(); + entity.createdAt = refreshToken.getCreatedAt(); + return entity; + } + + public RefreshToken toDomain() { + RefreshToken refreshToken = new RefreshToken(); + refreshToken.setId(this.id); + refreshToken.setToken(this.token); + refreshToken.setMemberSerialNumber(this.memberSerialNumber); + refreshToken.setExpiryDate(this.expiryDate); + refreshToken.setCreatedAt(this.createdAt); + return refreshToken; + } + + // Getters and Setters + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + + public String getToken() { return token; } + public void setToken(String token) { this.token = token; } + + public Long getMemberSerialNumber() { return memberSerialNumber; } + public void setMemberSerialNumber(Long memberSerialNumber) { this.memberSerialNumber = memberSerialNumber; } + + public LocalDateTime getExpiryDate() { return expiryDate; } + public void setExpiryDate(LocalDateTime expiryDate) { this.expiryDate = expiryDate; } + + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } +} diff --git a/user-service/src/main/java/com/healthsync/user/repository/entity/UserEntity.java b/user-service/src/main/java/com/healthsync/user/repository/entity/UserEntity.java new file mode 100644 index 0000000..a0c5a1c --- /dev/null +++ b/user-service/src/main/java/com/healthsync/user/repository/entity/UserEntity.java @@ -0,0 +1,109 @@ +package com.healthsync.user.repository.entity; + +import com.healthsync.user.domain.Oauth.User; +import jakarta.persistence.*; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Entity +@Table(name = "user", schema = "user_service") +public class UserEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "member_serial_number") + private Long memberSerialNumber; + + @Column(name = "google_id", length = 255, unique = true, nullable = false) + private String googleId; + + @Column(name = "name", length = 100, nullable = false) + private String name; + + @Column(name = "birth_date", nullable = false) + private LocalDate birthDate; + + @Column(name = "occupation", length = 50) + private String occupation; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + @Column(name = "last_login_at") + private LocalDateTime lastLoginAt; + + protected UserEntity() {} + + public UserEntity(String googleId, String name, LocalDate birthDate, String occupation) { + this.googleId = googleId; + this.name = name; + this.birthDate = birthDate; + this.occupation = occupation; + this.createdAt = LocalDateTime.now(); + this.updatedAt = LocalDateTime.now(); + this.lastLoginAt = LocalDateTime.now(); + } + + public static UserEntity fromDomain(User user) { + UserEntity entity = new UserEntity(); + // 핵심 수정: 새 엔티티인 경우 ID를 설정하지 않음 (BIGSERIAL이 자동 생성) + if (user.getMemberSerialNumber() != null) { + entity.memberSerialNumber = user.getMemberSerialNumber(); + } + entity.googleId = user.getGoogleId(); + entity.name = user.getName(); + entity.birthDate = user.getBirthDate(); + entity.occupation = user.getOccupation(); + entity.createdAt = user.getCreatedAt(); + entity.updatedAt = user.getUpdatedAt(); + entity.lastLoginAt = user.getLastLoginAt(); + return entity; + } + + public User toDomain() { + User user = new User(); + user.setMemberSerialNumber(this.memberSerialNumber); + user.setGoogleId(this.googleId); + user.setName(this.name); + user.setBirthDate(this.birthDate); + user.setOccupation(this.occupation); + user.setCreatedAt(this.createdAt); + user.setUpdatedAt(this.updatedAt); + user.setLastLoginAt(this.lastLoginAt); + return user; + } + + public void updateLastLoginAt() { + this.lastLoginAt = LocalDateTime.now(); + this.updatedAt = LocalDateTime.now(); + } + + // Getters and Setters + public Long getMemberSerialNumber() { return memberSerialNumber; } + public void setMemberSerialNumber(Long memberSerialNumber) { this.memberSerialNumber = memberSerialNumber; } + + public String getGoogleId() { return googleId; } + public void setGoogleId(String googleId) { this.googleId = googleId; } + + public String getName() { return name; } + public void setName(String name) { this.name = name; } + + public LocalDate getBirthDate() { return birthDate; } + public void setBirthDate(LocalDate birthDate) { this.birthDate = birthDate; } + + public String getOccupation() { return occupation; } + public void setOccupation(String occupation) { this.occupation = occupation; } + + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } + + public LocalDateTime getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; } + + public LocalDateTime getLastLoginAt() { return lastLoginAt; } + public void setLastLoginAt(LocalDateTime lastLoginAt) { this.lastLoginAt = lastLoginAt; } +} \ No newline at end of file diff --git a/user-service/src/main/java/com/healthsync/user/repository/jpa/HealthCheckupRawRepository.java b/user-service/src/main/java/com/healthsync/user/repository/jpa/HealthCheckupRawRepository.java new file mode 100644 index 0000000..ed8e55c --- /dev/null +++ b/user-service/src/main/java/com/healthsync/user/repository/jpa/HealthCheckupRawRepository.java @@ -0,0 +1,49 @@ +package com.healthsync.user.repository.jpa; + +import com.healthsync.user.repository.entity.HealthCheckupRawEntity; +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; + +@Repository +public interface HealthCheckupRawRepository extends JpaRepository { + + // 이름과 생년월일로 최근 건강검진 데이터 조회 (가장 최근 연도) + @Query("SELECT h FROM HealthCheckupRawEntity h " + + "WHERE h.name = :name AND h.birthDate = :birthDate " + + "ORDER BY h.referenceYear DESC, h.createdAt DESC") + List findByNameAndBirthDateOrderByReferenceYearDesc( + @Param("name") String name, + @Param("birthDate") LocalDate birthDate); + + // 이름과 생년월일로 최근 건강검진 데이터 1개만 조회 (첫 번째 결과만) + @Query(value = "SELECT * FROM health_service.health_checkup_raw h " + + "WHERE h.name = :name AND h.birth_date = :birthDate " + + "ORDER BY h.reference_year DESC, h.created_at DESC " + + "LIMIT 1", nativeQuery = true) + Optional findMostRecentByNameAndBirthDate( + @Param("name") String name, + @Param("birthDate") LocalDate birthDate); + + // 특정 연도의 건강검진 데이터 조회 + @Query("SELECT h FROM HealthCheckupRawEntity h " + + "WHERE h.name = :name AND h.birthDate = :birthDate AND h.referenceYear = :year " + + "ORDER BY h.createdAt DESC") + List findByNameAndBirthDateAndYear( + @Param("name") String name, + @Param("birthDate") LocalDate birthDate, + @Param("year") Integer year); + + // 이름과 생년월일로 모든 건강검진 이력 조회 + @Query("SELECT h FROM HealthCheckupRawEntity h " + + "WHERE h.name = :name AND h.birthDate = :birthDate " + + "ORDER BY h.referenceYear DESC, h.createdAt DESC") + List findAllByNameAndBirthDate( + @Param("name") String name, + @Param("birthDate") LocalDate birthDate); +} \ No newline at end of file diff --git a/user-service/src/main/java/com/healthsync/user/repository/jpa/HealthNormalRangeRepository.java b/user-service/src/main/java/com/healthsync/user/repository/jpa/HealthNormalRangeRepository.java new file mode 100644 index 0000000..2d364a2 --- /dev/null +++ b/user-service/src/main/java/com/healthsync/user/repository/jpa/HealthNormalRangeRepository.java @@ -0,0 +1,40 @@ +package com.healthsync.user.repository.jpa; + +import com.healthsync.user.repository.entity.HealthNormalRangeEntity; +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.util.List; +import java.util.Optional; + +@Repository +public interface HealthNormalRangeRepository extends JpaRepository { + + // 모든 정상 범위 데이터 조회 + List findAll(); + + // 특정 건강 항목 코드로 조회 + Optional findByHealthItemCode(String healthItemCode); + + // 성별 코드로 필터링하여 조회 + List findByGenderCode(Integer genderCode); + + // 특정 건강 항목 코드와 성별 코드로 조회 + @Query("SELECT h FROM HealthNormalRangeEntity h " + + "WHERE h.healthItemCode = :healthItemCode " + + "AND (h.genderCode = :genderCode OR h.genderCode IS NULL)") + Optional findByHealthItemCodeAndGenderCode( + @Param("healthItemCode") String healthItemCode, + @Param("genderCode") Integer genderCode); + + // 건강 항목명으로 조회 + List findByHealthItemName(String healthItemName); + + // 성별에 맞는 정상 범위 조회 (해당 성별 + 범용(null)) + @Query("SELECT h FROM HealthNormalRangeEntity h " + + "WHERE h.genderCode = :genderCode OR h.genderCode IS NULL " + + "ORDER BY h.healthItemCode, h.genderCode DESC") + List findRelevantByGenderCode(@Param("genderCode") Integer genderCode); +} \ No newline at end of file diff --git a/user-service/src/main/java/com/healthsync/user/repository/jpa/OccupationTypeRepository.java b/user-service/src/main/java/com/healthsync/user/repository/jpa/OccupationTypeRepository.java new file mode 100644 index 0000000..e808a44 --- /dev/null +++ b/user-service/src/main/java/com/healthsync/user/repository/jpa/OccupationTypeRepository.java @@ -0,0 +1,24 @@ +package com.healthsync.user.repository.jpa; + +import com.healthsync.user.repository.entity.OccupationTypeEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +/** + * 직업 유형 정보 조회를 위한 리포지토리 + */ +@Repository +public interface OccupationTypeRepository extends JpaRepository { + + /** + * 직업 코드로 직업 정보 조회 (조회 시 사용) + */ + Optional findByOccupationCode(String occupationCode); + + /** + * 직업명으로 직업 정보 조회 (저장 시 사용) + */ + Optional findByOccupationName(String occupationName); +} \ No newline at end of file diff --git a/user-service/src/main/java/com/healthsync/user/repository/jpa/RefreshTokenRepository.java b/user-service/src/main/java/com/healthsync/user/repository/jpa/RefreshTokenRepository.java new file mode 100644 index 0000000..9e9bf77 --- /dev/null +++ b/user-service/src/main/java/com/healthsync/user/repository/jpa/RefreshTokenRepository.java @@ -0,0 +1,25 @@ +package com.healthsync.user.repository.jpa; + +import com.healthsync.user.repository.entity.RefreshTokenEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.Optional; + +@Repository +public interface RefreshTokenRepository extends JpaRepository { + Optional findByToken(String token); + Optional findByMemberSerialNumber(Long memberSerialNumber); + + @Modifying + @Query("DELETE FROM RefreshTokenEntity r WHERE r.memberSerialNumber = :memberSerialNumber") + void deleteByMemberSerialNumber(@Param("memberSerialNumber") Long memberSerialNumber); + + @Modifying + @Query("DELETE FROM RefreshTokenEntity r WHERE r.expiryDate < :now") + void deleteExpiredTokens(@Param("now") LocalDateTime now); +} diff --git a/user-service/src/main/java/com/healthsync/user/repository/jpa/UserRepository.java b/user-service/src/main/java/com/healthsync/user/repository/jpa/UserRepository.java new file mode 100644 index 0000000..1fd4005 --- /dev/null +++ b/user-service/src/main/java/com/healthsync/user/repository/jpa/UserRepository.java @@ -0,0 +1,86 @@ +package com.healthsync.user.repository.jpa; + +import com.healthsync.user.repository.entity.UserEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +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.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +@Repository +public interface UserRepository extends JpaRepository { + + // 기본 조회 메서드 + Optional findByGoogleId(String googleId); + boolean existsByGoogleId(String googleId); + + // 이름으로 검색 + List findByNameContaining(String name); + + // 직업으로 검색 + List findByOccupation(String occupation); + List findByOccupationContaining(String occupation); + + // 생년월일 범위 검색 + List findByBirthDateBetween(LocalDate startDate, LocalDate endDate); + + // 가입일 범위 검색 + List findByCreatedAtBetween(LocalDateTime startDate, LocalDateTime endDate); + + // 최근 로그인한 사용자들 + @Query("SELECT u FROM UserEntity u WHERE u.lastLoginAt >= :since ORDER BY u.lastLoginAt DESC") + List findRecentlyLoggedInUsers(@Param("since") LocalDateTime since); + + // 특정 기간 동안 로그인하지 않은 사용자들 + @Query("SELECT u FROM UserEntity u WHERE u.lastLoginAt < :before OR u.lastLoginAt IS NULL") + List findInactiveUsers(@Param("before") LocalDateTime before); + + // 로그인 시간 업데이트 + @Modifying + @Query("UPDATE UserEntity u SET u.lastLoginAt = :lastLoginAt, u.updatedAt = :updatedAt WHERE u.memberSerialNumber = :memberSerialNumber") + void updateLastLoginAt(@Param("memberSerialNumber") Long memberSerialNumber, + @Param("lastLoginAt") LocalDateTime lastLoginAt, + @Param("updatedAt") LocalDateTime updatedAt); + + // 사용자 정보 부분 업데이트 + @Modifying + @Query("UPDATE UserEntity u SET u.name = :name, u.updatedAt = :updatedAt WHERE u.memberSerialNumber = :memberSerialNumber") + void updateUserName(@Param("memberSerialNumber") Long memberSerialNumber, + @Param("name") String name, + @Param("updatedAt") LocalDateTime updatedAt); + + @Modifying + @Query("UPDATE UserEntity u SET u.birthDate = :birthDate, u.updatedAt = :updatedAt WHERE u.memberSerialNumber = :memberSerialNumber") + void updateUserBirthDate(@Param("memberSerialNumber") Long memberSerialNumber, + @Param("birthDate") LocalDate birthDate, + @Param("updatedAt") LocalDateTime updatedAt); + + @Modifying + @Query("UPDATE UserEntity u SET u.occupation = :occupation, u.updatedAt = :updatedAt WHERE u.memberSerialNumber = :memberSerialNumber") + void updateUserOccupation(@Param("memberSerialNumber") Long memberSerialNumber, + @Param("occupation") String occupation, + @Param("updatedAt") LocalDateTime updatedAt); + + // 통계 관련 쿼리 + @Query("SELECT COUNT(u) FROM UserEntity u WHERE u.createdAt >= :startDate") + long countNewUsersFrom(@Param("startDate") LocalDateTime startDate); + + @Query("SELECT COUNT(u) FROM UserEntity u WHERE u.lastLoginAt >= :startDate") + long countActiveUsersFrom(@Param("startDate") LocalDateTime startDate); + + @Query("SELECT u.occupation, COUNT(u) FROM UserEntity u WHERE u.occupation IS NOT NULL GROUP BY u.occupation") + List countUsersByOccupation(); + + // 생년월일이 설정되지 않은 사용자들 (임시값 사용자들) + @Query("SELECT u FROM UserEntity u WHERE u.birthDate = :defaultDate") + List findUsersWithDefaultBirthDate(@Param("defaultDate") LocalDate defaultDate); + + // 프로필이 완성되지 않은 사용자들 + @Query("SELECT u FROM UserEntity u WHERE u.birthDate = :defaultDate OR u.occupation IS NULL") + List findIncompleteProfiles(@Param("defaultDate") LocalDate defaultDate); +} \ No newline at end of file diff --git a/user-service/src/main/java/com/healthsync/user/service/HealthProfile/HealthProfileService.java b/user-service/src/main/java/com/healthsync/user/service/HealthProfile/HealthProfileService.java new file mode 100644 index 0000000..c88b294 --- /dev/null +++ b/user-service/src/main/java/com/healthsync/user/service/HealthProfile/HealthProfileService.java @@ -0,0 +1,41 @@ +package com.healthsync.user.service.HealthProfile; + +import com.healthsync.user.domain.HealthCheck.HealthCheckupRaw; +import com.healthsync.user.domain.HealthCheck.HealthNormalRange; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +public interface HealthProfileService { + + /** + * 이름과 생년월일로 최근 건강검진 데이터 조회 + */ + Optional getMostRecentHealthCheckup(String name, LocalDate birthDate); + + /** + * 이름과 생년월일로 모든 건강검진 이력 조회 + */ + List getHealthCheckupHistory(String name, LocalDate birthDate); + + /** + * 모든 건강 정상 범위 데이터 조회 + */ + List getAllHealthNormalRanges(); + + /** + * 성별 코드에 따른 건강 정상 범위 데이터 조회 + */ + List getHealthNormalRangesByGender(Integer genderCode); + + /** + * 특정 건강 항목의 정상 범위 조회 + */ + Optional getHealthNormalRangeByItemCode(String healthItemCode, Integer genderCode); + + /** + * 성별에 맞는 건강 정상 범위 데이터 조회 (null 허용하는 범용 범위 포함) + */ + List getRelevantHealthNormalRanges(Integer genderCode); +} \ No newline at end of file diff --git a/user-service/src/main/java/com/healthsync/user/service/HealthProfile/HealthProfileServiceImpl.java b/user-service/src/main/java/com/healthsync/user/service/HealthProfile/HealthProfileServiceImpl.java new file mode 100644 index 0000000..1d85d6b --- /dev/null +++ b/user-service/src/main/java/com/healthsync/user/service/HealthProfile/HealthProfileServiceImpl.java @@ -0,0 +1,121 @@ +package com.healthsync.user.service.HealthProfile; + +import com.healthsync.user.domain.HealthCheck.HealthCheckupRaw; +import com.healthsync.user.domain.HealthCheck.HealthNormalRange; +import com.healthsync.user.repository.entity.HealthCheckupRawEntity; +import com.healthsync.user.repository.entity.HealthNormalRangeEntity; +import com.healthsync.user.repository.jpa.HealthCheckupRawRepository; +import com.healthsync.user.repository.jpa.HealthNormalRangeRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +@Service +@Transactional(readOnly = true) +public class HealthProfileServiceImpl implements HealthProfileService { + + private static final Logger logger = LoggerFactory.getLogger(HealthProfileServiceImpl.class); + + private final HealthCheckupRawRepository healthCheckupRawRepository; + private final HealthNormalRangeRepository healthNormalRangeRepository; + + public HealthProfileServiceImpl(HealthCheckupRawRepository healthCheckupRawRepository, + HealthNormalRangeRepository healthNormalRangeRepository) { + this.healthCheckupRawRepository = healthCheckupRawRepository; + this.healthNormalRangeRepository = healthNormalRangeRepository; + } + + @Override + public Optional getMostRecentHealthCheckup(String name, LocalDate birthDate) { + logger.info("최근 건강검진 데이터 조회 - 이름: {}, 생년월일: {}", name, birthDate); + + Optional entity = healthCheckupRawRepository + .findMostRecentByNameAndBirthDate(name, birthDate); + + if (entity.isPresent()) { + logger.info("건강검진 데이터 발견 - 검진년도: {}, Raw ID: {}, 성별 코드: {}", + entity.get().getReferenceYear(), entity.get().getRawId(), entity.get().getGenderCode()); + return Optional.of(entity.get().toDomain()); + } else { + logger.warn("해당 사용자의 건강검진 데이터를 찾을 수 없음 - 이름: {}, 생년월일: {}", name, birthDate); + return Optional.empty(); + } + } + + @Override + public List getHealthCheckupHistory(String name, LocalDate birthDate) { + logger.info("건강검진 이력 조회 - 이름: {}, 생년월일: {}", name, birthDate); + + List entities = healthCheckupRawRepository + .findAllByNameAndBirthDate(name, birthDate); + + logger.info("건강검진 이력 {}건 발견", entities.size()); + + return entities.stream() + .map(HealthCheckupRawEntity::toDomain) + .collect(Collectors.toList()); + } + + @Override + public List getAllHealthNormalRanges() { + logger.info("모든 건강 정상 범위 데이터 조회"); + + List entities = healthNormalRangeRepository.findAll(); + + logger.info("건강 정상 범위 데이터 {}건 조회됨", entities.size()); + + return entities.stream() + .map(HealthNormalRangeEntity::toDomain) + .collect(Collectors.toList()); + } + + @Override + public List getHealthNormalRangesByGender(Integer genderCode) { + logger.info("성별별 건강 정상 범위 데이터 조회 - 성별 코드: {}", genderCode); + + List entities = healthNormalRangeRepository.findByGenderCode(genderCode); + + logger.info("성별별 건강 정상 범위 데이터 {}건 조회됨", entities.size()); + + return entities.stream() + .map(HealthNormalRangeEntity::toDomain) + .collect(Collectors.toList()); + } + + @Override + public Optional getHealthNormalRangeByItemCode(String healthItemCode, Integer genderCode) { + logger.info("특정 건강 항목 정상 범위 조회 - 항목 코드: {}, 성별 코드: {}", healthItemCode, genderCode); + + Optional entity = healthNormalRangeRepository + .findByHealthItemCodeAndGenderCode(healthItemCode, genderCode); + + if (entity.isPresent()) { + logger.info("건강 항목 정상 범위 발견 - 항목명: {}", entity.get().getHealthItemName()); + return Optional.of(entity.get().toDomain()); + } else { + logger.warn("해당 건강 항목의 정상 범위를 찾을 수 없음 - 항목 코드: {}, 성별 코드: {}", + healthItemCode, genderCode); + return Optional.empty(); + } + } + + @Override + public List getRelevantHealthNormalRanges(Integer genderCode) { + logger.info("성별에 맞는 건강 정상 범위 데이터 조회 - 성별 코드: {}", genderCode); + + List entities = healthNormalRangeRepository + .findRelevantByGenderCode(genderCode); + + logger.info("관련 건강 정상 범위 데이터 {}건 조회됨", entities.size()); + + return entities.stream() + .map(HealthNormalRangeEntity::toDomain) + .collect(Collectors.toList()); + } +} \ No newline at end of file diff --git a/user-service/src/main/java/com/healthsync/user/service/Oauth/JwtTokenService.java b/user-service/src/main/java/com/healthsync/user/service/Oauth/JwtTokenService.java new file mode 100644 index 0000000..22f637e --- /dev/null +++ b/user-service/src/main/java/com/healthsync/user/service/Oauth/JwtTokenService.java @@ -0,0 +1,57 @@ +package com.healthsync.user.service.Oauth; + +import com.healthsync.user.domain.Oauth.User; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.oauth2.jwt.JwtClaimsSet; +import org.springframework.security.oauth2.jwt.JwtEncoder; +import org.springframework.security.oauth2.jwt.JwtEncoderParameters; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; + +@Service +public class JwtTokenService { + + private final JwtEncoder jwtEncoder; + private final long accessTokenExpiration; + private final long refreshTokenExpiration; + + public JwtTokenService(JwtEncoder jwtEncoder, + @Value("${jwt.access-token-expiration}") long accessTokenExpiration, + @Value("${jwt.refresh-token-expiration}") long refreshTokenExpiration) { + this.jwtEncoder = jwtEncoder; + this.accessTokenExpiration = accessTokenExpiration; + this.refreshTokenExpiration = refreshTokenExpiration; + } + + public String generateAccessToken(User user) { + Instant now = Instant.now(); + Instant expiry = now.plus(accessTokenExpiration, ChronoUnit.MILLIS); + + JwtClaimsSet.Builder claimsBuilder = JwtClaimsSet.builder() + .issuer("healthsync") + .issuedAt(now) + .expiresAt(expiry) + .subject(user.getMemberSerialNumber().toString()) // memberSerialNumber를 subject로 + .claim("googleId", user.getGoogleId()) // googleId + .claim("name", user.getName()); // name + + // 생년월일 추가 (null 체크) + if (user.getBirthDate() != null) { + claimsBuilder.claim("birthDate", user.getBirthDate().toString()); + } + + JwtClaimsSet claims = claimsBuilder.build(); + + return jwtEncoder.encode(JwtEncoderParameters.from(claims)).getTokenValue(); + } + + public long getAccessTokenExpirationTime() { + return accessTokenExpiration; + } + + public long getRefreshTokenExpirationTime() { + return refreshTokenExpiration; + } +} \ No newline at end of file diff --git a/user-service/src/main/java/com/healthsync/user/service/Oauth/OAuth2UserService.java b/user-service/src/main/java/com/healthsync/user/service/Oauth/OAuth2UserService.java new file mode 100644 index 0000000..058f82e --- /dev/null +++ b/user-service/src/main/java/com/healthsync/user/service/Oauth/OAuth2UserService.java @@ -0,0 +1,61 @@ +package com.healthsync.user.service.Oauth; + +import com.healthsync.user.dto.Oauth.OAuth2UserInfo; +import com.healthsync.user.repository.jpa.UserRepository; +import com.healthsync.user.repository.entity.UserEntity; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.user.DefaultOAuth2User; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; + +import java.util.Collections; +import java.util.Optional; + +@Service +public class OAuth2UserService extends DefaultOAuth2UserService { + + private static final Logger logger = LoggerFactory.getLogger(OAuth2UserService.class); + private final UserRepository userRepository; + + public OAuth2UserService(UserRepository userRepository) { + this.userRepository = userRepository; + } + + @Override + public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + OAuth2User oauth2User = super.loadUser(userRequest); + + logger.info("OAuth2 사용자 정보 로드 시작"); + logger.info("사용자 속성: {}", oauth2User.getAttributes()); + + return processOAuth2User(userRequest, oauth2User); + } + + private OAuth2User processOAuth2User(OAuth2UserRequest userRequest, OAuth2User oauth2User) { + OAuth2UserInfo oauth2UserInfo = new OAuth2UserInfo(oauth2User.getAttributes()); + + String googleId = oauth2UserInfo.getId(); + + logger.info("처리할 사용자 정보 - Google ID: {}", googleId); + + // 기존 사용자 확인만 수행 (생성은 Handler에서 처리) + Optional existingUser = userRepository.findByGoogleId(googleId); + + if (existingUser.isPresent()) { + logger.info("기존 사용자 확인됨 - ID: {}", existingUser.get().getMemberSerialNumber()); + } else { + logger.info("신규 사용자 - Handler에서 생성될 예정"); + } + + // role 정보 없이 기본 권한만 부여 + return new DefaultOAuth2User( + Collections.singleton(() -> "ROLE_USER"), + oauth2User.getAttributes(), + "sub" + ); + } +} \ No newline at end of file diff --git a/user-service/src/main/java/com/healthsync/user/service/Oauth/RefreshTokenService.java b/user-service/src/main/java/com/healthsync/user/service/Oauth/RefreshTokenService.java new file mode 100644 index 0000000..d642861 --- /dev/null +++ b/user-service/src/main/java/com/healthsync/user/service/Oauth/RefreshTokenService.java @@ -0,0 +1,13 @@ +package com.healthsync.user.service.Oauth; + +import com.healthsync.user.domain.Oauth.RefreshToken; + +import java.util.Optional; + +public interface RefreshTokenService { + RefreshToken createRefreshToken(Long memberSerialNumber); + Optional findByToken(String token); + RefreshToken verifyExpiration(RefreshToken token); + void deleteByMemberSerialNumber(Long memberSerialNumber); + void deleteExpiredTokens(); +} \ No newline at end of file diff --git a/user-service/src/main/java/com/healthsync/user/service/Oauth/RefreshTokenServiceImpl.java b/user-service/src/main/java/com/healthsync/user/service/Oauth/RefreshTokenServiceImpl.java new file mode 100644 index 0000000..28f4b7e --- /dev/null +++ b/user-service/src/main/java/com/healthsync/user/service/Oauth/RefreshTokenServiceImpl.java @@ -0,0 +1,69 @@ +package com.healthsync.user.service.Oauth; + +import com.healthsync.user.domain.Oauth.RefreshToken; +import com.healthsync.user.repository.entity.RefreshTokenEntity; +import com.healthsync.user.repository.jpa.RefreshTokenRepository; +import com.healthsync.user.exception.AuthenticationException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.Optional; +import java.util.UUID; + +@Service +@Transactional +public class RefreshTokenServiceImpl implements RefreshTokenService { + + private final RefreshTokenRepository refreshTokenRepository; + private final long refreshTokenExpiration; + + public RefreshTokenServiceImpl(RefreshTokenRepository refreshTokenRepository, + @Value("${jwt.refresh-token-expiration}") long refreshTokenExpiration) { + this.refreshTokenRepository = refreshTokenRepository; + this.refreshTokenExpiration = refreshTokenExpiration; + } + + @Override + public RefreshToken createRefreshToken(Long memberSerialNumber) { + // 기존 리프레시 토큰 삭제 + refreshTokenRepository.deleteByMemberSerialNumber(memberSerialNumber); + + // 새 리프레시 토큰 생성 + String token = UUID.randomUUID().toString(); + LocalDateTime expiryDate = LocalDateTime.now().plusSeconds(refreshTokenExpiration / 1000); + + RefreshToken refreshToken = new RefreshToken(token, memberSerialNumber, expiryDate); + RefreshTokenEntity entity = RefreshTokenEntity.fromDomain(refreshToken); + RefreshTokenEntity savedEntity = refreshTokenRepository.save(entity); + + return savedEntity.toDomain(); + } + + @Override + @Transactional(readOnly = true) + public Optional findByToken(String token) { + return refreshTokenRepository.findByToken(token) + .map(RefreshTokenEntity::toDomain); + } + + @Override + public RefreshToken verifyExpiration(RefreshToken token) { + if (token.isExpired()) { + refreshTokenRepository.deleteByMemberSerialNumber(token.getMemberSerialNumber()); + throw new AuthenticationException("리프레시 토큰이 만료되었습니다. 다시 로그인해주세요."); + } + return token; + } + + @Override + public void deleteByMemberSerialNumber(Long memberSerialNumber) { + refreshTokenRepository.deleteByMemberSerialNumber(memberSerialNumber); + } + + @Override + public void deleteExpiredTokens() { + refreshTokenRepository.deleteExpiredTokens(LocalDateTime.now()); + } +} diff --git a/user-service/src/main/java/com/healthsync/user/service/UserProfile/UserService.java b/user-service/src/main/java/com/healthsync/user/service/UserProfile/UserService.java new file mode 100644 index 0000000..68c0653 --- /dev/null +++ b/user-service/src/main/java/com/healthsync/user/service/UserProfile/UserService.java @@ -0,0 +1,38 @@ +package com.healthsync.user.service.UserProfile; + +import com.healthsync.user.domain.Oauth.User; +import com.healthsync.user.dto.UserProfile.OccupationDto; +import java.util.Optional; +import java.util.List; + +public interface UserService { + + // 기존 메서드들 + User saveUser(User user); + Optional findByGoogleId(String googleId); + Optional findById(Long memberSerialNumber); + User updateUser(User user); + void updateLastLoginAt(Long memberSerialNumber); + boolean existsByGoogleId(String googleId); + + // 직업 코드 변환 메서드 추가 + /** + * 직업 코드를 직업명으로 변환 (조회 시 사용) + * @param occupationCode 직업 코드 + * @return 직업명 + */ + String convertOccupationCodeToName(String occupationCode); + + /** + * 직업명을 직업 코드로 변환 (저장 시 사용) + * @param occupationName 직업명 + * @return 직업 코드 + */ + String convertOccupationNameToCode(String occupationName); + + /** + * 모든 직업 목록 조회 (선택사항) + * @return 직업 목록 + */ + List getAllOccupations(); +} \ No newline at end of file diff --git a/user-service/src/main/java/com/healthsync/user/service/UserProfile/UserServiceImpl.java b/user-service/src/main/java/com/healthsync/user/service/UserProfile/UserServiceImpl.java new file mode 100644 index 0000000..bdeea95 --- /dev/null +++ b/user-service/src/main/java/com/healthsync/user/service/UserProfile/UserServiceImpl.java @@ -0,0 +1,183 @@ +package com.healthsync.user.service.UserProfile; + +import com.healthsync.user.domain.Oauth.User; +import com.healthsync.user.repository.jpa.UserRepository; +import com.healthsync.user.repository.jpa.OccupationTypeRepository; +import com.healthsync.user.repository.entity.UserEntity; +import com.healthsync.user.repository.entity.OccupationTypeEntity; +import com.healthsync.user.dto.UserProfile.OccupationDto; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.Optional; +import java.util.List; +import java.util.stream.Collectors; + +@Service +@Transactional +public class UserServiceImpl implements UserService { + + private static final Logger logger = LoggerFactory.getLogger(UserServiceImpl.class); + + private final UserRepository userRepository; + private final OccupationTypeRepository occupationTypeRepository; + + public UserServiceImpl(UserRepository userRepository, + OccupationTypeRepository occupationTypeRepository) { + this.userRepository = userRepository; + this.occupationTypeRepository = occupationTypeRepository; + } + + @Override + public String convertOccupationCodeToName(String occupationCode) { + if (occupationCode == null || occupationCode.trim().isEmpty()) { + return "정보 없음"; + } + + logger.debug("직업 코드를 이름으로 변환: {}", occupationCode); + + return occupationTypeRepository.findByOccupationCode(occupationCode) + .map(entity -> { + logger.debug("직업 코드 변환 성공: {} -> {}", occupationCode, entity.getOccupationName()); + return entity.getOccupationName(); + }) + .orElseGet(() -> { + logger.warn("직업 코드를 찾을 수 없음: {}", occupationCode); + return occupationCode; // 코드가 없으면 원래 값 반환 + }); + } + + @Override + public String convertOccupationNameToCode(String occupationName) { + if (occupationName == null || occupationName.trim().isEmpty()) { + return null; + } + + logger.debug("직업명을 코드로 변환: {}", occupationName); + + return occupationTypeRepository.findByOccupationName(occupationName) + .map(entity -> { + logger.debug("직업명 변환 성공: {} -> {}", occupationName, entity.getOccupationCode()); + return entity.getOccupationCode(); + }) + .orElseGet(() -> { + logger.warn("직업명을 찾을 수 없음: {}", occupationName); + return occupationName; // 매칭되는 코드가 없으면 원래 값 반환 + }); + } + + @Override + @Transactional(readOnly = true) + public Optional findById(Long memberSerialNumber) { + return userRepository.findById(memberSerialNumber) + .map(entity -> { + User user = entity.toDomain(); + // 조회할 때 occupation 코드를 이름으로 변환 + if (user.getOccupation() != null) { + String occupationName = convertOccupationCodeToName(user.getOccupation()); + user.setOccupation(occupationName); + } + return user; + }); + } + + @Override + @Transactional(readOnly = true) + public Optional findByGoogleId(String googleId) { + return userRepository.findByGoogleId(googleId) + .map(entity -> { + User user = entity.toDomain(); + // 조회할 때 occupation 코드를 이름으로 변환 + if (user.getOccupation() != null) { + String occupationName = convertOccupationCodeToName(user.getOccupation()); + user.setOccupation(occupationName); + } + return user; + }); + } + + @Override + public User saveUser(User user) { + logger.info("새 사용자 저장"); + + // 저장하기 전에 occupation 이름을 코드로 변환 + String originalOccupation = user.getOccupation(); + if (originalOccupation != null) { + String occupationCode = convertOccupationNameToCode(originalOccupation); + user.setOccupation(occupationCode); + logger.debug("직업 정보 변환하여 저장: {} -> {}", originalOccupation, occupationCode); + } + + UserEntity entity = UserEntity.fromDomain(user); + UserEntity savedEntity = userRepository.save(entity); + + // 저장 후 다시 조회해서 코드를 이름으로 변환하여 반환 + User savedUser = savedEntity.toDomain(); + if (savedUser.getOccupation() != null) { + String occupationName = convertOccupationCodeToName(savedUser.getOccupation()); + savedUser.setOccupation(occupationName); + } + + logger.info("새 사용자 저장 완료: {}", savedUser.getMemberSerialNumber()); + return savedUser; + } + + @Override + public User updateUser(User user) { + logger.info("사용자 정보 업데이트: {}", user.getMemberSerialNumber()); + + // 저장하기 전에 occupation 이름을 코드로 변환 + String originalOccupation = user.getOccupation(); + if (originalOccupation != null) { + String occupationCode = convertOccupationNameToCode(originalOccupation); + user.setOccupation(occupationCode); + logger.debug("직업 정보 변환하여 저장: {} -> {}", originalOccupation, occupationCode); + } + + // 기존 구현 유지 + user.setUpdatedAt(LocalDateTime.now()); + UserEntity entity = UserEntity.fromDomain(user); + UserEntity savedEntity = userRepository.save(entity); + + // 저장 후 다시 조회해서 코드를 이름으로 변환하여 반환 + User savedUser = savedEntity.toDomain(); + if (savedUser.getOccupation() != null) { + String occupationName = convertOccupationCodeToName(savedUser.getOccupation()); + savedUser.setOccupation(occupationName); + } + + logger.info("사용자 정보 업데이트 완료: {}", savedUser.getMemberSerialNumber()); + return savedUser; + } + + @Override + public void updateLastLoginAt(Long memberSerialNumber) { + LocalDateTime now = LocalDateTime.now(); + userRepository.updateLastLoginAt(memberSerialNumber, now, now); + } + + @Override + @Transactional(readOnly = true) + public boolean existsByGoogleId(String googleId) { + return userRepository.existsByGoogleId(googleId); + } + + @Override + @Transactional(readOnly = true) + public List getAllOccupations() { + logger.info("모든 직업 목록 조회"); + + return occupationTypeRepository.findAll() + .stream() + .map(entity -> new OccupationDto( + entity.getOccupationCode(), + entity.getOccupationName(), + entity.getCategory() + )) + .collect(Collectors.toList()); + } +} \ No newline at end of file diff --git a/user-service/src/main/resources/application.yml b/user-service/src/main/resources/application.yml new file mode 100644 index 0000000..50e9ed1 --- /dev/null +++ b/user-service/src/main/resources/application.yml @@ -0,0 +1,110 @@ +spring: + application: + name: user-service + + datasource: + url: ${DB_URL:jdbc:postgresql://psql-digitalgarage-01.postgres.database.azure.com:5432/healthsync_db} + username: ${DB_USERNAME:team1tier} + password: ${DB_PASSWORD:Hi5Jessica!} + driver-class-name: org.postgresql.Driver + hikari: + connection-timeout: 20000 + maximum-pool-size: 10 + minimum-idle: 5 + idle-timeout: 300000 + max-lifetime: 1200000 + + jpa: + hibernate: + ddl-auto: none + show-sql: ${SHOW_SQL:false} + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + format_sql: true + jdbc: + time_zone: UTC + + # JWT 설정 + security: + oauth2: + client: + registration: + google: + client-id: ${GOOGLE_CLIENT_ID:67517118327-m9mqpr78k24f5j2iillj2ovjdt3f2vt4.apps.googleusercontent.com} + client-secret: ${GOOGLE_CLIENT_SECRET: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} + scope: + - openid + - profile + - email + redirect-uri: ${GOOGLE_REDIRECT_ID:http://localhost:8081/login/oauth2/code/google} + # redirect-uri: "http://team1tier.20.214.196.128.nip.io/login/oauth2/code/google" + provider: + google: + authorization-uri: https://accounts.google.com/o/oauth2/v2/auth + token-uri: https://www.googleapis.com/oauth2/v4/token + user-info-uri: https://www.googleapis.com/oauth2/v3/userinfo + user-name-attribute: sub + resource server: + jwt: + issuer-uri: "http://team1tier.20.214.196.128.nip.io" + mvc: + log-request-details: true # 요청 파라미터와 헤더 로깅 활성화 + + +server: + port: ${SERVER_PORT:8081} + + + +jwt: + private-key: "-----BEGIN PRIVATE KEY-----MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCnHuDmeTbJcxNtY/VJbCktLaqEwCEJStwY32A24xh6jMjz0km9JNpWXpVUtAuAXy+g9omD0u+38E5CdFu6INgykmLeSsm2h8GftiDPX8Nf2NF8HcikFD7N1G2X8loswSTvg40hzPnHllOJr0ZmXAoN4Z55ndkPdPUj8qB364nqx9Yv0natlm9fu0a7WOPB7PtWY5qZa9uCDZ4rn1xaVgHTT89lEqvPVvbnrpbgtyRKK7DEj3AaAsXMHoxYgqyM+h3U3HZjWAdsC50Stl/4Snz+kYbEcsp3HJUbo627Tn2rxssvxktTiYuW0c1RHqZXIXumi2AmLqeRqt0VCBMR+0HDAgMBAAECggEAFPpMy9FqXaYqyZ/zAcjocEnbrjc5zmNNtnePqcQe5f83GFgMtofiOlY8E3pYOUB5h5B62YfIXIP3JuNZQkduLAbxDys/H8Dxvp0LiExih2z9esF4VpRN/+NK8HhU9mo2OzR9qkEDF5kYml9cjGvAPVbVYDm+rfCF9wG1P+hakxRXY5dua3J+Z7KBGYWfTGRLdb8/xl07s7eb4CDDmEWAae+LwLIUXWY1Gh82lp+AhgkWv6Q2ohxqFA6cOR5lScRqLAN7be6pbvIvKlNYFdectaTaRuD7m4rCfQnNpX+TyX7Q53qSzTPddvcymkKaUwaXw2jXSPhgdrUJ9PKbhJ0z/QKBgQDmonJTGZ+gq1RvIWC9IuVUyIwXk3umN4EP6qI/4BxargO4sVAOOS6m61HugpSLehTp9XL09GFHKFsQd9yT0B+hqIIF+BTWXlvbQ2eEzFsIhCc2yAeV414sLWRAIfllMPKk3rc+m5bD81KgPW5yrwJtMgxgoGtj6ETH3BPg0/oIpwKBgQC5gC/Lm8cTWN5AfK30DW/fYD7/RxHsaYSuAN0hLOBGBoGbfqk0f6AG6IOIxYJsU7/uKtXlDNvSPdvXYCupe39S8WDKB1YxpDCODwYWVqfRTEuJzD0SbF3stcn2wwpWyxDRtep947o/P+VCePVgZtoWf8340n89h1bIZ+EXHXqFhQKBgHcwqp6RlnJFOMx51nHIb/ZB8kxY1sUO2C8ulg0mt+CRH7E6SWIgYSC4ak41w6jVPauvQmqfRQquK2m2WBM3srEr0Y5eJ/6lIxmMmxoBNmaPTWi9NVZb+5YfGzkdlbKa+jsEMnUzmVXJEQFo3gR8t2dRPx5MqVMnfSxAazF8uzHvAoGAGQKTbxw9pvogXQlyWqlFIBTV6Y0neXxwixVKuyJVypst9k0Jey6J4OSQd2xJvVk9U1srI4qsSJhWf59Tw7IG5KPurM54bJD6iuyzoWdlkO58cMO8qDM8JqIL7N03E6SlS+D/EKIXhleTDXdJfgnf9ZCdsKKQzTbmGHcI/hjXYBECgYEAiTW83qOqgioI0kJen5cM9/bTkMgYU22I2p2fFdnYEtOn2tIrTJ198fwr1hpWqm/rwK3mig4DyM2/G7cNyJTqexM0gxKqluRPM+ebzyDY9crF4A34lhKsf267tQbasr9uaVrPM23a8CflTrZGAtTMVtJKtHKNFBFxV+p5o6LRDb8=-----END PRIVATE KEY-----" + public-key: "-----BEGIN PUBLIC KEY-----MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApx7g5nk2yXMTbWP1SWwpLS2qhMAhCUrcGN9gNuMYeozI89JJvSTaVl6VVLQLgF8voPaJg9Lvt/BOQnRbuiDYMpJi3krJtofBn7Ygz1/DX9jRfB3IpBQ+zdRtl/JaLMEk74ONIcz5x5ZTia9GZlwKDeGeeZ3ZD3T1I/Kgd+uJ6sfWL9J2rZZvX7tGu1jjwez7VmOamWvbgg2eK59cWlYB00/PZRKrz1b2566W4LckSiuwxI9wGgLFzB6MWIKsjPod1Nx2Y1gHbAudErZf+Ep8/pGGxHLKdxyVG6Otu059q8bLL8ZLU4mLltHNUR6mVyF7potgJi6nkardFQgTEftBwwIDAQAB-----END PUBLIC KEY-----" + access-token-expiration: 1800000 # 30 minutes in milliseconds + refresh-token-expiration: 604800000 # 7 days in milliseconds + +# 외부 서비스 URL +services: + health-service: + url: ${HEALTH_SERVICE_URL:http://localhost:8082} + goal-service: + url: ${GOAL_SERVICE_URL:http://localhost:8084} + + +# 로깅 설정 +logging: + level: + com.healthsync.user: ${LOG_LEVEL:TRACE} + org.springframework.web: ${WEB_LOG_LEVEL:TRACE} + org.springframework.security: ${SECURITY_LOG_LEVEL:TRACE} + org.springframework.data.jpa: ${JPA_LOG_LEVEL:TRACE} + # OAuth2 관련 로깅 추가 + org.springframework.security.oauth2: TRACE + org.springframework.security.oauth2.client: TRACE + org.springframework.security.oauth2.server.resource: TRACE + org.springframework.security.jwt: TRACE + org.springframework.security.web.authentication: TRACE + org.springframework.security.web.FilterChainProxy: TRACE + org.apache.http: TRACE + org.apache.http.wire: TRACE # HTTP 와이어 레벨 로깅 (실제 HTTP 메시지) + pattern: + console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n" + + +# Management endpoints +management: + endpoints: + web: + exposure: + include: health,info,metrics,prometheus + endpoint: + health: + show-details: always + prometheus: + metrics: + export: + enabled: true + +app: + oauth2: + redirect-url: ${OAUTH2_REDIRECT_URL:http://localhost:3000/login} \ No newline at end of file