feat : initial commit

This commit is contained in:
hehe 2025-06-20 05:42:24 +00:00
commit 409d7abdc6
245 changed files with 17069 additions and 0 deletions

65
.gitignore vendored Normal file
View File

@ -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

26
Dockerfile.backup Normal file
View File

@ -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"]

148
README.md Normal file
View File

@ -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 라이선스를 따릅니다.

43
api-gateway/build.gradle Normal file
View File

@ -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()
}

View File

@ -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);
}
}

View File

@ -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);
})
);
};
}
}

View File

@ -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>.*), /${segment}
# Health Service 라우팅
- id: health-service
uri: ${HEALTH_SERVICE_URL:http://localhost:8082}
predicates:
- Path=/api/health/**
filters:
- RewritePath=/api/(?<segment>.*), /$\{segment}
# Intelligence Service 라우팅
- id: intelligence-service
uri: ${INTELLIGENCE_SERVICE_URL:http://localhost:8083}
predicates:
- Path=/api/intelligence/**
filters:
- RewritePath=/api/(?<segment>.*), /$\{segment}
# Goal Service 라우팅
- id: goal-service
uri: ${GOAL_SERVICE_URL:http://localhost:8084}
predicates:
- Path=/api/goals/**
filters:
- RewritePath=/api/(?<segment>.*), /$\{segment}
# Motivator Service 라우팅
- id: motivator-service
uri: ${MOTIVATOR_SERVICE_URL:http://localhost:8085}
predicates:
- Path=/api/motivator/**
filters:
- RewritePath=/api/(?<segment>.*), /$\{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"

60
build.gradle Normal file
View File

@ -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'
}
}

8
common/build.gradle Normal file
View File

@ -0,0 +1,8 @@
bootJar {
enabled = false
}
jar {
enabled = true
archiveClassifier = ''
}

View File

@ -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의 생성일시, 수정일시가 자동으로 설정됩니다.
}

View File

@ -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<String, Object>
*/
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, Object> 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;
}
}

View File

@ -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")));
}
}

View File

@ -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";
}

View File

@ -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 = "목표 설정이 완료되었습니다.";
}

View File

@ -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<T> {
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 <T> ApiResponse<T> success(T data) {
return ApiResponse.<T>builder()
.status(200)
.message("SUCCESS")
.data(data)
.timestamp(LocalDateTime.now().toString())
.build();
}
/**
* 성공 응답을 생성합니다.
*
* @param message 성공 메시지
* @param data 응답 데이터
* @return ApiResponse 인스턴스
*/
public static <T> ApiResponse<T> success(String message, T data) {
return ApiResponse.<T>builder()
.status(200)
.message(message)
.data(data)
.timestamp(LocalDateTime.now().toString())
.build();
}
/**
* 에러 응답을 생성합니다.
*
* @param message 에러 메시지
* @return ApiResponse 인스턴스
*/
public static <T> ApiResponse<T> error(String message) {
return ApiResponse.<T>builder()
.status(500)
.message(message)
.data(null)
.timestamp(LocalDateTime.now().toString())
.build();
}
/**
* 성공 여부를 반환합니다.
*
* @return 성공 여부
*/
public boolean isSuccess() {
return status >= 200 && status < 300;
}
}

View File

@ -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();
}

View File

@ -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();
}
}

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -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<ErrorResponse> 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<ErrorResponse> 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<ErrorResponse> 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);
}
}

View File

@ -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<ErrorResponse> 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<ErrorResponse> 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<ErrorResponse> 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);
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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();
}
}

View File

@ -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"]

View File

@ -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"]

150
docker-compose.yml Normal file
View File

@ -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

41
goal-service/build.gradle Normal file
View File

@ -0,0 +1,41 @@
plugins {
id 'java'
id 'org.springframework.boot' version '3.4.0'
id 'io.spring.dependency-management' version '1.1.4'
}
group = 'com.healthsync'
version = '1.0.0'
java {
sourceCompatibility = '21'
targetCompatibility = '21'
}
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
dependencies {
implementation project(':common')
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.springframework.boot:spring-boot-starter-validation'
// WebClient는 Mock에서
implementation 'org.springframework.boot:spring-boot-starter-webflux'
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0'
runtimeOnly 'org.postgresql:postgresql'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
tasks.named('test') {
useJUnitPlatform()
}

View File

@ -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();
}
}

View File

@ -0,0 +1,612 @@
package com.healthsync.goal.application_services;
import com.healthsync.goal.domain.services.GoalDomainService;
import com.healthsync.goal.domain.repositories.GoalRepository;
import com.healthsync.goal.dto.*;
import com.healthsync.goal.infrastructure.entities.MissionCompletionHistoryEntity;
import com.healthsync.goal.infrastructure.entities.UserMissionGoalEntity;
import com.healthsync.goal.infrastructure.ports.*;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* 목표 관리 유스케이스입니다.
* Clean Architecture의 Application Service 계층에 해당합니다.
*
* @author healthsync-team
* @version 1.0
*/
@Slf4j
@Service
@RequiredArgsConstructor
@Transactional
public class GoalUseCase {
private final GoalDomainService goalDomainService;
private final GoalRepository goalRepository;
private final UserServicePort userServicePort;
private final com.healthsync.goal.domain.ports.IntelligenceServicePort intelligenceServicePort;
private final CachePort cachePort;
private final EventPublisherPort eventPublisherPort;
/**
* 미션을 선택하고 목표를 설정합니다.
*
* @param request 미션 선택 요청
* @return 목표 설정 결과
*/
public GoalSetupResponse selectMissions(MissionSelectionRequest request) {
log.info("미션 선택 처리 시작: memberSerialNumber={}", request.getMemberSerialNumber());
// 사용자 정보 검증 (간단히 처리)
// userServicePort.validateUserExists(request.getMemberSerialNumber());
// 미션 선택 검증
goalDomainService.validateMissionSelection(request);
// 기존 활성 미션 비활성화
goalRepository.deactivateCurrentMissions(request.getMemberSerialNumber());
// 미션 설정 저장
String goalId = goalRepository.saveGoalSettings(request);
// 선택된 미션 정보 구성 - SelectedMissionDetail에서 정보 추출
List<SelectedMission> selectedMissions = request.getSelectedMissionIds().stream()
.map(missionDetail -> SelectedMission.builder()
.missionId(generateMissionId(missionDetail)) // 미션 ID 생성
.title(missionDetail.getTitle()) // 제목은 SelectedMissionDetail에서
.description(generateMissionDescription(missionDetail)) // 설명 생성
.startDate(LocalDate.now().toString())
.build())
.toList();
// 이벤트 발행 - 미션 ID 목록 추출
List<String> missionIdList = request.getSelectedMissionIds().stream()
.map(this::generateMissionId)
.toList();
eventPublisherPort.publishGoalSetEvent(request.getMemberSerialNumber(), missionIdList);
// 캐시 무효화
cachePort.invalidateUserMissionCache(request.getMemberSerialNumber());
GoalSetupResponse response = GoalSetupResponse.builder()
.goalId(goalId)
.selectedMissions(selectedMissions)
.message("선택하신 미션으로 건강 목표가 설정되었습니다.")
.setupCompletedAt(java.time.LocalDateTime.now().toString())
.build();
log.info("미션 선택 처리 완료: memberSerialNumber={}, goalId={}", request.getMemberSerialNumber(), goalId);
return response;
}
/**
* 설정된 활성 미션을 조회합니다.
*
* @param memberSerialNumber 회원 시리얼 번호
* @return 활성 미션 목록
*/
@Transactional(readOnly = true)
public ActiveMissionsResponse getActiveMissions(String memberSerialNumber) {
log.info("활성 미션 조회: memberSerialNumber={}", memberSerialNumber);
// 캐시 확인
ActiveMissionsResponse cachedResponse = cachePort.getActiveMissions(memberSerialNumber);
if (cachedResponse != null) {
log.info("캐시에서 활성 미션 조회: memberSerialNumber={}", memberSerialNumber);
return cachedResponse;
}
// 활성 미션 조회
List<DailyMission> dailyMissions = goalRepository.findActiveMissionsByUserId(memberSerialNumber);
// 완료 통계 계산
int totalMissions = dailyMissions.size();
int todayCompletedCount = (int) dailyMissions.stream()
.mapToInt(mission -> mission.isCompletedToday() ? 1 : 0)
.sum();
double completionRate = totalMissions > 0 ?
((double) todayCompletedCount / totalMissions) * 100.0 : 0.0;
// 연속 달성 일수 계산
int currentStreak = dailyMissions.stream()
.mapToInt(DailyMission::getStreakDays)
.max()
.orElse(0);
ActiveMissionsResponse response = ActiveMissionsResponse.builder()
.dailyMissions(dailyMissions)
.totalMissions(totalMissions)
.todayCompletedCount(todayCompletedCount)
.completionRate(completionRate)
.currentStreak(currentStreak)
.bestStreak(calculateBestStreak(memberSerialNumber))
.motivationalMessage(generateMotivationalMessage(completionRate))
.build();
// 캐시 저장
cachePort.cacheActiveMissions(memberSerialNumber, response);
log.info("활성 미션 조회 완료: memberSerialNumber={}, totalMissions={}", memberSerialNumber, totalMissions);
return response;
}
/**
* 미션 완료를 처리합니다.
*
* 🎯 핵심 기능:
* 1. 미션 완료 기록 (mission_completion_history 테이블)
* 2. 목표 달성 여부 확인 (daily_completed_count >= daily_target_count)
* 3. 목표 달성 HealthSync_Intelligence Python API 호출
*
* 🐍 Python API 연동:
* - 조건: daily_completed_count == daily_target_count
* - 호출: POST /api/intelligence/missions/celebrate
* - 요청: { userId: long, missionId: long }
* - 응답: { congratsMessage: str }
*
* @param missionId 미션 ID
* @param request 미션 완료 요청
* @return 미션 완료 결과 (Python 축하 메시지 포함)
*/
public MissionCompleteResponse completeMission(String missionId, MissionCompleteRequest request) {
log.info("미션 완료 처리: memberSerialNumber={}, missionId={}", request.getMemberSerialNumber(), missionId);
// 미션 완료 기록
goalRepository.recordMissionCompletion(missionId, request);
// 🎯 목표 달성 여부 확인 축하 API 호출
CelebrationResponse celebrationResponse = checkAndCelebrateMissionAchievement(missionId, request.getMemberSerialNumber());
// 연속 달성 일수 계산
int newStreakDays = calculateNewStreakDays(request.getMemberSerialNumber(), missionId);
// 완료 횟수 조회
int totalCompletedCount = goalRepository.getTotalCompletedCount(request.getMemberSerialNumber(), missionId);
// 성취 메시지 생성 (축하 API 응답 우선 사용)
String achievementMessage = celebrationResponse != null && celebrationResponse.getCongratsMessage() != null
? celebrationResponse.getCongratsMessage()
: generateAchievementMessage(newStreakDays, totalCompletedCount);
// 캐시 무효화
cachePort.invalidateUserMissionCache(request.getMemberSerialNumber());
// 이벤트 발행
eventPublisherPort.publishMissionCompleteEvent(request.getMemberSerialNumber(), missionId, newStreakDays);
MissionCompleteResponse response = MissionCompleteResponse.builder()
.message("미션이 완료되었습니다!")
.status("SUCCESS")
.achievementMessage(achievementMessage)
.newStreakDays(newStreakDays)
.totalCompletedCount(totalCompletedCount)
.earnedPoints(calculateEarnedPoints(newStreakDays))
.build();
log.info("미션 완료 처리 완료: memberSerialNumber={}, missionId={}, streakDays={}",
request.getMemberSerialNumber(), missionId, newStreakDays);
return response;
}
/**
* 🎯 목표 달성 여부를 확인하고 달성시 축하 API를 호출합니다.
*
* @param missionId 미션 ID
* @param memberSerialNumber 회원 시리얼 번호
* @return 축하 응답 (달성하지 않은 경우 null)
*/
private CelebrationResponse checkAndCelebrateMissionAchievement(String missionId, String memberSerialNumber) {
log.info("🎯 [MISSION_ACHIEVEMENT] 목표 달성 여부 확인: memberSerialNumber={}, missionId={}",
memberSerialNumber, missionId);
try {
// 오늘의 미션 완료 이력 조회
boolean isTargetAchieved = goalRepository.isTodayTargetAchieved(memberSerialNumber, missionId);
if (isTargetAchieved) {
log.info("🎉 [MISSION_ACHIEVEMENT] 목표 달성 확인! Python 축하 API 호출: memberSerialNumber={}, missionId={}",
memberSerialNumber, missionId);
try {
// 🔧 Python API 스펙에 맞춘 축하 요청 생성 (userId: long, missionId: long)
CelebrationRequest celebrationRequest = CelebrationRequest.builder()
.userId(Long.parseLong(memberSerialNumber)) // 🔧 String Long 변환
.missionId(Long.parseLong(missionId)) // 🔧 String Long 변환 ( ID 지원)
.build();
// HealthSync_Intelligence Python Service의 축하 API 호출
return intelligenceServicePort.celebrateMissionAchievement(celebrationRequest);
} catch (NumberFormatException e) {
log.error("❌ [MISSION_ACHIEVEMENT] 숫자 변환 실패: memberSerialNumber={}, missionId={}, error={}",
memberSerialNumber, missionId, e.getMessage());
// 🔧 숫자 변환 실패시 Fallback 축하 메시지 반환
return CelebrationResponse.builder()
.congratsMessage("🎉 목표를 달성하셨습니다! 훌륭해요! 💪✨")
.build();
}
} else {
log.info("📝 [MISSION_ACHIEVEMENT] 목표 미달성: memberSerialNumber={}, missionId={}",
memberSerialNumber, missionId);
return null;
}
} catch (Exception e) {
log.error("❌ [MISSION_ACHIEVEMENT] 목표 달성 확인 중 오류: memberSerialNumber={}, missionId={}, error={}",
memberSerialNumber, missionId, e.getMessage(), e);
return null;
}
}
// 점진적 완료 처리 메서드 추가
private MissionCompleteResponse processIncrementalCompletion(String missionId, MissionCompleteRequest request, UserMissionGoalEntity mission) {
// 오늘 완료 기록 조회/생성
MissionCompletionHistoryEntity todayCompletion = goalRepository.findOrCreateTodayCompletion(
missionId, request.getMemberSerialNumber(), mission.getDailyTargetCount());
// 목표 초과 방지
int currentCount = todayCompletion.getDailyCompletedCount();
int targetCount = todayCompletion.getDailyTargetCount();
if (currentCount >= targetCount) {
throw new IllegalStateException("이미 오늘 목표를 달성했습니다.");
}
// 진행도 증가
int newCount = Math.min(currentCount + request.getIncrementCount(), targetCount);
todayCompletion.setDailyCompletedCount(newCount);
goalRepository.saveMissionCompletion(todayCompletion);
// 결과 계산
boolean isTargetAchieved = newCount >= targetCount;
double achievementRate = (double) newCount / targetCount * 100.0;
// 목표 달성 추가 처리
String celebrationMessage = null;
String achievementMessage = null;
int streakDays = 0;
int earnedPoints = 5; // 기본 포인트
if (isTargetAchieved) {
streakDays = goalRepository.calculateStreakDays(request.getMemberSerialNumber(), missionId);
celebrationMessage = "🎉 오늘 목표를 달성했어요!";
achievementMessage = generateAchievementMessage(streakDays, newCount);
earnedPoints += 10; // 목표 달성 보너스
// 이벤트 발행
eventPublisherPort.publishMissionCompleteEvent(request.getMemberSerialNumber(), missionId, streakDays);
}
// 캐시 무효화
cachePort.invalidateUserMissionCache(request.getMemberSerialNumber());
// 응답 생성
return MissionCompleteResponse.builder()
.message(isTargetAchieved ? "🎉 오늘 목표를 달성했어요!" : "좋아요! 계속 진행해보세요!")
.status("SUCCESS")
.achievementMessage(achievementMessage)
.newStreakDays(streakDays)
.totalCompletedCount(goalRepository.getTotalCompletedCount(request.getMemberSerialNumber(), missionId))
.earnedPoints(earnedPoints)
.currentCount(newCount)
.targetCount(targetCount)
.isTargetAchieved(isTargetAchieved)
.achievementRate(achievementRate)
.completionType("INCREMENT")
.celebrationMessage(celebrationMessage)
.build();
}
// 전체 완료 처리 메서드 추가 (기존 로직 유지)
private MissionCompleteResponse processFullCompletion(String missionId, MissionCompleteRequest request, UserMissionGoalEntity mission) {
// 기존 완료 로직 수행
goalRepository.recordMissionCompletion(missionId, request);
// 연속 달성 일수 계산
int newStreakDays = calculateNewStreakDays(request.getMemberSerialNumber(), missionId);
// 완료 횟수 조회
int totalCompletedCount = goalRepository.getTotalCompletedCount(request.getMemberSerialNumber(), missionId);
// 성취 메시지 생성
String achievementMessage = generateAchievementMessage(newStreakDays, totalCompletedCount);
// 캐시 무효화 이벤트 발행
cachePort.invalidateUserMissionCache(request.getMemberSerialNumber());
eventPublisherPort.publishMissionCompleteEvent(request.getMemberSerialNumber(), missionId, newStreakDays);
// 오늘 완료 상태 조회
MissionCompletionHistoryEntity todayCompletion = goalRepository.findOrCreateTodayCompletion(
missionId, request.getMemberSerialNumber(), mission.getDailyTargetCount());
return MissionCompleteResponse.builder()
.message("미션이 완료되었습니다!")
.status("SUCCESS")
.achievementMessage(achievementMessage)
.newStreakDays(newStreakDays)
.totalCompletedCount(totalCompletedCount)
.earnedPoints(calculateEarnedPoints(newStreakDays))
.currentCount(todayCompletion.getDailyCompletedCount())
.targetCount(todayCompletion.getDailyTargetCount())
.isTargetAchieved(true)
.achievementRate(100.0)
.completionType("FULL")
.celebrationMessage(achievementMessage)
.build();
}
/**
* 미션 달성 이력을 조회합니다.
*
* @param memberSerialNumber 회원 시리얼 번호
* @param startDate 시작일
* @param endDate 종료일
* @param missionIds 미션 ID 목록
* @return 미션 달성 이력
*/
@Transactional(readOnly = true)
public MissionHistoryResponse getMissionHistory(String memberSerialNumber, String startDate, String endDate, String missionIds) {
log.info("미션 이력 조회: memberSerialNumber={}, period={} to {}", memberSerialNumber, startDate, endDate);
// 캐시 생성
String cacheKey = String.format("mission_history:%s:%s:%s:%s", memberSerialNumber, startDate, endDate, missionIds);
// 캐시 확인
MissionHistoryResponse cachedResponse = cachePort.getMissionHistory(cacheKey);
if (cachedResponse != null) {
log.info("캐시에서 미션 이력 조회: memberSerialNumber={}", memberSerialNumber);
return cachedResponse;
}
// 기본값 설정
if (startDate == null) startDate = LocalDate.now().minusMonths(1).toString();
if (endDate == null) endDate = LocalDate.now().toString();
// 미션 이력 조회
List<MissionStats> missionStats = goalRepository.findMissionHistoryByPeriod(memberSerialNumber, startDate, endDate, missionIds);
// 실제 데이터를 기반으로 통계 계산
AchievementStats achievementStats = calculateStatsFromMissionData(missionStats);
// 차트 데이터 생성
Map<String, Object> chartData = goalDomainService.generateChartData(missionStats);
// 인사이트 생성
List<String> insights = goalDomainService.analyzeProgressPatterns(missionStats);
MissionHistoryResponse response = MissionHistoryResponse.builder()
.totalAchievementRate(achievementStats.getTotalAchievementRate())
.periodAchievementRate(achievementStats.getPeriodAchievementRate())
.bestStreak(achievementStats.getBestStreak())
.missionStats(missionStats)
.chartData(chartData)
.period(Period.builder()
.startDate(startDate)
.endDate(endDate)
.build())
.insights(insights)
.build();
// 캐시 저장
cachePort.cacheMissionHistory(cacheKey, response);
log.info("미션 이력 조회 완료: memberSerialNumber={}, achievementRate={}", memberSerialNumber, response.getTotalAchievementRate());
return response;
}
/**
* 미션 데이터를 기반으로 달성 통계를 계산합니다.
*
* @param missionStats 미션 통계 목록
* @return 달성 통계
*/
private AchievementStats calculateStatsFromMissionData(List<MissionStats> missionStats) {
if (missionStats.isEmpty()) {
log.info("미션 데이터가 없어 기본값으로 통계 반환");
return AchievementStats.builder()
.totalAchievementRate(0.0)
.periodAchievementRate(0.0)
.bestStreak(0)
.completedDays(0)
.totalDays(0)
.build();
}
// 평균 달성률 계산
double avgAchievementRate = missionStats.stream()
.mapToDouble(MissionStats::getAchievementRate)
.average()
.orElse(0.0);
// 완료 일수와 전체 일수 합계
int totalCompletedDays = missionStats.stream()
.mapToInt(MissionStats::getCompletedDays)
.sum();
int totalDays = missionStats.stream()
.mapToInt(MissionStats::getTotalDays)
.sum();
// 최고 연속 달성 계산 (간단한 로직)
int bestStreak = missionStats.stream()
.mapToInt(stat -> (int) (stat.getAchievementRate() / 10)) // 임시 계산
.max()
.orElse(0);
log.info("미션 통계 계산 완료: 평균달성률={}, 총완료일수={}, 전체일수={}", avgAchievementRate, totalCompletedDays, totalDays);
return AchievementStats.builder()
.totalAchievementRate(avgAchievementRate)
.periodAchievementRate(avgAchievementRate)
.bestStreak(bestStreak)
.completedDays(totalCompletedDays)
.totalDays(totalDays)
.build();
}
/**
* 미션을 재설정합니다.
*
* @param request 미션 재설정 요청
* @return 미션 재설정 결과
*/
public MissionResetResponse resetMissions(MissionResetRequest request) {
log.info("미션 재설정: memberSerialNumber={}, reason={}", request.getMemberSerialNumber(), request.getReason());
// 현재 활성 미션 비활성화 (기존 구현 사용)
goalRepository.deactivateCurrentMissions(request.getMemberSerialNumber());
// 새로운 미션 추천 요청 (기존 구현 사용)
List<RecommendedMission> newRecommendations = intelligenceServicePort
.getNewMissionRecommendations(request.getMemberSerialNumber(), request.getReason());
// 캐시 무효화
cachePort.invalidateUserMissionCache(request.getMemberSerialNumber());
// 이벤트 발행 (기존 구현 사용)
eventPublisherPort.publishMissionResetEvent(request.getMemberSerialNumber(), request.getReason());
MissionResetResponse response = MissionResetResponse.builder()
.message("미션이 재설정되었습니다.")
.newRecommendations(newRecommendations)
.resetCompletedAt(java.time.LocalDateTime.now().toString())
.build();
log.info("미션 재설정 완료: memberSerialNumber={}", request.getMemberSerialNumber());
return response;
}
// === Private Helper Methods ===
/**
* SelectedMissionDetail로부터 고유한 미션 ID를 생성합니다.
*/
private String generateMissionId(SelectedMissionDetail missionDetail) {
if (missionDetail == null || missionDetail.getTitle() == null) {
return UUID.randomUUID().toString();
}
// 제목을 기반으로 ID 생성 + UUID 일부 추가 (중복 방지)
String baseId = missionDetail.getTitle()
.replaceAll("[^가-힣a-zA-Z0-9]", "_")
.toLowerCase()
.replaceAll("_+", "_")
.replaceAll("^_|_$", "");
String shortUuid = UUID.randomUUID().toString().substring(0, 8);
return String.format("mission_%s_%s", baseId, shortUuid);
}
/**
* SelectedMissionDetail로부터 미션 설명을 생성합니다.
*/
private String generateMissionDescription(SelectedMissionDetail missionDetail) {
return String.format("%s (일일 %d회) - %s",
missionDetail.getTitle(),
missionDetail.getDaily_target_count(),
missionDetail.getReason());
}
/**
* 기존 메서드들 - 호환성을 위해 유지
*/
private String getMissionTitle(String missionId) {
// 실제 구현에서는 미션 ID로 제목을 조회
return "미션 제목"; // placeholder
}
private String getMissionDescription(String missionId) {
// 실제 구현에서는 미션 ID로 설명을 조회
return "미션 설명"; // placeholder
}
/**
* 기존 GoalUseCase에 있던 메서드들 (기존 구현 유지)
*/
private int calculateNewStreakDays(String memberSerialNumber, String missionId) {
// 기존 구현 로직 유지
return 1; // placeholder - 실제로는 연속 일수 계산
}
private String generateAchievementMessage(int streakDays, int totalCount) {
if (streakDays >= 7) {
return String.format("🔥 대단해요! %d일 연속 달성!", streakDays);
} else if (streakDays >= 3) {
return String.format("💪 좋아요! %d일 연속 달성 중!", streakDays);
} else {
return "🌟 오늘도 목표 달성! 계속 화이팅!";
}
}
private int calculateEarnedPoints(int streakDays) {
// 기본 포인트 + 연속 달성 보너스 (기존 구현)
int basePoints = 10;
int streakBonus = Math.min(streakDays * 2, 50); // 최대 50점
return basePoints + streakBonus;
}
private int calculateBestStreak(String memberSerialNumber) {
// 기존 구현 - Repository에서 조회하지 않고 간단히 처리
return 7; // placeholder
}
private String generateMotivationalMessage(double completionRate) {
if (completionRate >= 80) {
return "🔥 오늘도 대단해요! 이런 페이스로 계속 가세요!";
} else if (completionRate >= 50) {
return "💪 잘하고 있어요! 조금만 더 힘내세요!";
} else {
return "🌱 시작이 반이에요! 작은 걸음부터 차근차근 해봐요!";
}
}
private int calculateEarnedPoints(String missionId, int streakDays) {
// 오버로드된 메서드 - 위의 calculateEarnedPoints(int) 호출
return calculateEarnedPoints(streakDays);
}
private double calculateTotalAchievementRate(List<MissionStats> missionStats) {
if (missionStats.isEmpty()) return 0.0;
return missionStats.stream()
.mapToDouble(MissionStats::getAchievementRate)
.average()
.orElse(0.0);
}
private double calculatePeriodAchievementRate(List<MissionStats> missionStats) {
// 기간별 달성률 계산 로직
return calculateTotalAchievementRate(missionStats);
}
private Object generateChartData(List<MissionStats> missionStats, String startDate, String endDate) {
// 차트 데이터 생성 로직
return new Object(); // placeholder
}
private List<String> generateInsights(List<MissionStats> missionStats, double achievementRate) {
List<String> insights = new java.util.ArrayList<>();
if (achievementRate >= 80) {
insights.add("훌륭한 성과를 보이고 있습니다!");
} else if (achievementRate >= 60) {
insights.add("꾸준히 목표를 향해 나아가고 있어요.");
} else {
insights.add("조금 더 꾸준함이 필요해 보여요.");
}
return insights;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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();
}
}

View File

@ -0,0 +1,99 @@
package com.healthsync.goal.domain.repositories;
import com.healthsync.goal.dto.*;
import com.healthsync.goal.infrastructure.entities.MissionCompletionHistoryEntity;
import com.healthsync.goal.infrastructure.entities.UserMissionGoalEntity;
import java.util.List;
/**
* 목표 데이터 저장소 인터페이스입니다.
* Clean Architecture의 Domain 계층에서 정의합니다.
*
* @author healthsync-team
* @version 1.0
*/
public interface GoalRepository {
/**
* 목표 설정을 저장합니다.
*
* @param request 미션 선택 요청
* @return 목표 ID
*/
String saveGoalSettings(MissionSelectionRequest request);
/**
* 사용자의 활성 미션을 조회합니다.
*
* @param memberSerialNumber 회원 시리얼 번호
* @return 활성 미션 목록
*/
List<DailyMission> findActiveMissionsByUserId(String memberSerialNumber);
/**
* 현재 미션을 비활성화합니다.
*
* @param memberSerialNumber 회원 시리얼 번호
*/
void deactivateCurrentMissions(String memberSerialNumber);
/**
* 미션 완료를 기록합니다.
*
* @param missionId 미션 ID
* @param request 미션 완료 요청
*/
void recordMissionCompletion(String missionId, MissionCompleteRequest request);
/**
* 완료 횟수를 조회합니다.
*
* @param memberSerialNumber 회원 시리얼 번호
* @param missionId 미션 ID
* @return 완료 횟수
*/
int getTotalCompletedCount(String memberSerialNumber, String missionId);
/**
* 기간별 미션 이력을 조회합니다.
*
* @param memberSerialNumber 회원 시리얼 번호
* @param startDate 시작일
* @param endDate 종료일
* @param missionIds 미션 ID 목록
* @return 미션 통계 목록
*/
List<MissionStats> findMissionHistoryByPeriod(String memberSerialNumber, String startDate, String endDate, String missionIds);
/**
* 미션 ID와 사용자로 미션을 조회합니다.
*/
UserMissionGoalEntity findMissionByIdAndUser(String missionId, String memberSerialNumber);
/**
* 오늘의 완료 기록을 조회하거나 생성합니다.
*/
MissionCompletionHistoryEntity findOrCreateTodayCompletion(String missionId, String memberSerialNumber, Integer dailyTargetCount);
/**
* 미션 완료 기록을 저장합니다.
*/
void saveMissionCompletion(MissionCompletionHistoryEntity completion);
/**
* 연속 달성일수를 계산합니다.
*/
int calculateStreakDays(String memberSerialNumber, String missionId);
/**
* 오늘 해당 미션의 목표를 달성했는지 확인합니다.
*
* @param memberSerialNumber 회원 시리얼 번호
* @param missionId 미션 ID
* @return 목표 달성 여부
*/
boolean isTodayTargetAchieved(String memberSerialNumber, String missionId);
}

View File

@ -0,0 +1,120 @@
package com.healthsync.goal.domain.services;
import com.healthsync.common.exception.ValidationException;
import com.healthsync.goal.dto.*;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 목표 관련 비즈니스 로직을 처리하는 도메인 서비스입니다.
*
* @author healthsync-team
* @version 1.0
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class GoalDomainService {
/**
* 미션 선택을 검증합니다.
*
* @param request 미션 선택 요청
*/
public void validateMissionSelection(MissionSelectionRequest request) {
if (request.getSelectedMissionIds() == null || request.getSelectedMissionIds().isEmpty()) {
throw new ValidationException("최소 1개 이상의 미션을 선택해야 합니다.");
}
if (request.getSelectedMissionIds().size() > 5) {
throw new ValidationException("최대 5개까지 미션을 선택할 수 있습니다.");
}
// 중복 미션 검사
if (request.getSelectedMissionIds().size() != request.getSelectedMissionIds().stream().distinct().count()) {
throw new ValidationException("중복된 미션이 선택되었습니다.");
}
log.info("미션 선택 검증 완료: memberSerialNumber={}, missionCount={}", request.getMemberSerialNumber(), request.getSelectedMissionIds().size());
}
/**
* 미션 완료를 검증합니다.
*
* @param missionId 미션 ID
* @param userId 사용자 ID
*/
public void validateMissionCompletion(String missionId, String userId) {
if (missionId == null || missionId.trim().isEmpty()) {
throw new ValidationException("미션 ID가 필요합니다.");
}
if (userId == null || userId.trim().isEmpty()) {
throw new ValidationException("사용자 ID가 필요합니다.");
}
log.info("미션 완료 검증 완료: userId={}, missionId={}", userId, missionId);
}
/**
* 연속 달성 일수를 계산합니다.
*
* @param userId 사용자 ID
* @param missionId 미션 ID
* @return 연속 달성 일수
*/
public int calculateStreakDays(String userId, String missionId) {
// 실제 구현에서는 DB에서 연속 달성 일수 계산
// Mock 데이터로 반환
return (int) (Math.random() * 10) + 1;
}
/**
* 차트 데이터를 생성합니다.
*
* @param missionStats 미션 통계 목록
* @return 차트 데이터
*/
public Map<String, Object> generateChartData(List<MissionStats> missionStats) { // Object -> Map<String, Object> 변경
Map<String, Object> chartData = new HashMap<>();
// 미션별 달성률 데이터
List<Map<String, Object>> achievementData = missionStats.stream()
.map(stat -> {
Map<String, Object> data = new HashMap<>();
data.put("missionTitle", stat.getTitle());
data.put("achievementRate", stat.getAchievementRate());
data.put("completedDays", stat.getCompletedDays());
return data;
})
.toList();
chartData.put("achievementByMission", achievementData);
chartData.put("chartType", "bar");
chartData.put("title", "미션별 달성률");
return chartData; // 이미 Map<String, Object> 반환하고 있었음
}
/**
* 진행 패턴을 분석하여 인사이트를 생성합니다.
*
* @param missionStats 미션 통계 목록
* @return 인사이트 목록
*/
public List<String> analyzeProgressPatterns(List<MissionStats> missionStats) {
// 실제 구현에서는 통계 데이터를 분석하여 패턴 발견
return List.of(
"💪 운동 미션의 달성률이 생활습관 미션보다 15% 높습니다.",
"📈 최근 1주일간 미션 달성률이 20% 향상되었습니다.",
"🎯 '어깨 스트레칭' 미션이 가장 높은 달성률(95%)을 보입니다.",
"⏰ 오전에 시작하는 미션들의 달성률이 더 높은 경향을 보입니다."
);
}
}

View File

@ -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;
}

View File

@ -0,0 +1,44 @@
package com.healthsync.goal.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* 활성 미션 응답 DTO 클래스입니다.
*
* @author healthsync-team
* @version 1.0
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "활성 미션 응답")
public class ActiveMissionsResponse {
@Schema(description = "일일 미션 목록")
private List<DailyMission> dailyMissions;
@Schema(description = "총 미션 수")
private int totalMissions;
@Schema(description = "오늘 완료된 미션 수")
private int todayCompletedCount;
@Schema(description = "완료율 (%)")
private double completionRate;
@Schema(description = "현재 연속 달성 일수")
private int currentStreak;
@Schema(description = "최고 연속 달성 일수")
private int bestStreak;
@Schema(description = "동기부여 메시지")
private String motivationalMessage;
}

View File

@ -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 지원)
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -0,0 +1,35 @@
package com.healthsync.goal.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* 목표 설정 응답 DTO 클래스입니다.
*
* @author healthsync-team
* @version 1.0
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "목표 설정 응답")
public class GoalSetupResponse {
@Schema(description = "목표 ID")
private String goalId;
@Schema(description = "선택된 미션 목록")
private List<SelectedMission> selectedMissions;
@Schema(description = "응답 메시지")
private String message;
@Schema(description = "설정 완료 시간")
private String setupCompletedAt;
}

View File

@ -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;
}

View File

@ -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);
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -0,0 +1,31 @@
package com.healthsync.goal.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* AI API에 미션 상세 정보 요청을 위한 DTO입니다.
*
* @author healthsync-team
* @version 1.0
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class MissionDetailsRequest {
/**
* 사용자 ID
*/
private String userId;
/**
* 조회할 미션 ID 목록
*/
private List<String> missionIds;
}

View File

@ -0,0 +1,49 @@
package com.healthsync.goal.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
import java.util.Map;
/**
* 미션 이력 응답 DTO 클래스입니다.
*
* @author healthsync-team
* @version 1.0
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "미션 이력 응답")
public class MissionHistoryResponse {
@Schema(description = "전체 달성률 (%)")
private double totalAchievementRate;
@Schema(description = "기간 내 달성률 (%)")
private double periodAchievementRate;
@Schema(description = "최고 연속 달성 일수")
private int bestStreak;
@Schema(description = "미션별 통계")
private List<MissionStats> missionStats;
// @Schema(description = "차트 데이터")
// private Object chartData;
@Schema(description = "차트 데이터")
private Map<String, Object> chartData;
@Schema(description = "조회 기간")
private Period period;
@Schema(description = "인사이트")
private List<String> insights;
}

View File

@ -0,0 +1,33 @@
package com.healthsync.goal.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* 미션 추천 응답 DTO 클래스입니다.
* 원래는 Intelligence Service에 있었지만 Mock 처리를 위해 추가.
*
* @author healthsync-team
* @version 1.0
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "미션 추천 응답")
public class MissionRecommendationResponse {
@Schema(description = "추천 미션 목록")
private List<Mission> missions;
@Schema(description = "추천 이유")
private String recommendationReason;
@Schema(description = "총 추천 미션 수")
private int totalRecommended;
}

View File

@ -0,0 +1,33 @@
package com.healthsync.goal.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import jakarta.validation.constraints.NotBlank;
import java.util.List;
/**
* 미션 재설정 요청 DTO 클래스입니다.
*
* @author healthsync-team
* @version 1.0
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "미션 재설정 요청")
public class MissionResetRequest {
@NotBlank(message = "회원 시리얼 번호는 필수입니다.")
@Schema(description = "회원 시리얼 번호")
private String memberSerialNumber;
@NotBlank(message = "재설정 이유는 필수입니다.")
@Schema(description = "재설정 이유")
private String reason;
@Schema(description = "현재 미션 ID 목록")
private List<String> currentMissionIds;
}

View File

@ -0,0 +1,32 @@
package com.healthsync.goal.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* 미션 재설정 응답 DTO 클래스입니다.
*
* @author healthsync-team
* @version 1.0
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "미션 재설정 응답")
public class MissionResetResponse {
@Schema(description = "응답 메시지")
private String message;
@Schema(description = "새로운 추천 미션 목록")
private List<RecommendedMission> newRecommendations;
@Schema(description = "재설정 완료 시간")
private String resetCompletedAt;
}

View File

@ -0,0 +1,37 @@
// goal-service/src/main/java/com/healthsync/goal/dto/MissionSelectionRequest.java
package com.healthsync.goal.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Size;
import jakarta.validation.Valid;
import java.util.List;
/**
* 미션 선택 요청 DTO 클래스입니다.
* 선택된 미션의 상세 정보를 포함합니다.
*
* @author healthsync-team
* @version 1.0
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "미션 선택 요청")
public class MissionSelectionRequest {
@NotBlank(message = "회원 시리얼 번호는 필수입니다.")
@Schema(description = "회원 시리얼 번호")
private String memberSerialNumber;
@NotEmpty(message = "선택된 미션 목록은 필수입니다.")
@Size(min = 1, max = 5, message = "1개에서 5개까지 미션을 선택할 수 있습니다.")
@Valid
@Schema(description = "선택된 미션 상세 정보 목록")
private List<SelectedMissionDetail> selectedMissionIds;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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("^_|_$", "");
}
}

View File

@ -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;
}

View File

@ -0,0 +1,88 @@
package com.healthsync.goal.infrastructure.adapters;
import com.healthsync.goal.dto.ActiveMissionsResponse;
import com.healthsync.goal.dto.MissionHistoryResponse;
import com.healthsync.goal.infrastructure.ports.CachePort;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import java.time.Duration;
/**
* Redis 캐시와의 통신을 담당하는 어댑터 클래스입니다.
* Clean Architecture의 Infrastructure 계층에 해당합니다.
*
* @author healthsync-team
* @version 1.0
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class CacheAdapter implements CachePort {
private final RedisTemplate<String, Object> redisTemplate;
@Override
public ActiveMissionsResponse getActiveMissions(String userId) {
try {
String cacheKey = "active_missions:" + userId;
//return (ActiveMissionsResponse) redisTemplate.opsForValue().get(cacheKey);
return null;
} catch (Exception e) {
log.warn("활성 미션 캐시 조회 실패: userId={}, error={}", userId, e.getMessage());
return null;
}
}
@Override
public void cacheActiveMissions(String userId, ActiveMissionsResponse response) {
try {
String cacheKey = "active_missions:" + userId;
//redisTemplate.opsForValue().set(cacheKey, response, Duration.ofMinutes(30));
log.info("활성 미션 캐시 저장: userId={}", userId);
} catch (Exception e) {
log.warn("활성 미션 캐시 저장 실패: userId={}, error={}", userId, e.getMessage());
}
}
@Override
public MissionHistoryResponse getMissionHistory(String cacheKey) {
try {
//return (MissionHistoryResponse) redisTemplate.opsForValue().get(cacheKey);
return null;
} catch (Exception e) {
log.warn("미션 이력 캐시 조회 실패: key={}, error={}", cacheKey, e.getMessage());
return null;
}
}
@Override
public void cacheMissionHistory(String cacheKey, MissionHistoryResponse response) {
try {
//redisTemplate.opsForValue().set(cacheKey, response, Duration.ofHours(1));
log.info("미션 이력 캐시 저장: key={}", cacheKey);
} catch (Exception e) {
log.warn("미션 이력 캐시 저장 실패: key={}, error={}", cacheKey, e.getMessage());
}
}
@Override
public void invalidateUserMissionCache(String userId) {
try {
String activeMissionKey = "active_missions:" + userId;
String historyKeyPattern = "mission_history:" + userId + ":*";
// 활성 미션 캐시 삭제
//redisTemplate.delete(activeMissionKey);
// 미션 이력 캐시 삭제 (패턴 매칭)
//redisTemplate.delete(redisTemplate.keys(historyKeyPattern));
log.info("사용자 미션 캐시 무효화 완료: userId={}", userId);
} catch (Exception e) {
log.warn("사용자 미션 캐시 무효화 실패: userId={}, error={}", userId, e.getMessage());
}
}
}

View File

@ -0,0 +1,82 @@
package com.healthsync.goal.infrastructure.adapters;
import com.healthsync.goal.infrastructure.ports.EventPublisherPort;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* 이벤트 발행을 담당하는 어댑터 클래스입니다.
* Clean Architecture의 Infrastructure 계층에 해당합니다.
*
* @author healthsync-team
* @version 1.0
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class EventPublisherAdapter implements EventPublisherPort {
// 실제 구현에서는 Spring Cloud Stream 또는 Azure Service Bus 사용
// private final ServiceBusTemplate serviceBusTemplate;
@Override
public void publishGoalSetEvent(String memberSerialNumber, List<String> missionIds) {
try {
log.info("목표 설정 이벤트 발행: memberSerialNumber={}, missionCount={}", memberSerialNumber, missionIds.size());
// 실제 구현에서는 이벤트 브로커에 발행
// GoalSetEvent event = GoalSetEvent.builder()
// .memberSerialNumber(memberSerialNumber)
// .missionIds(missionIds)
// .timestamp(LocalDateTime.now())
// .build();
// serviceBusTemplate.send("goal-set-topic", event);
log.info("목표 설정 이벤트 발행 완료: memberSerialNumber={}", memberSerialNumber);
} catch (Exception e) {
log.error("목표 설정 이벤트 발행 실패: memberSerialNumber={}, error={}", memberSerialNumber, e.getMessage(), e);
}
}
@Override
public void publishMissionCompleteEvent(String memberSerialNumber, String missionId, int streakDays) {
try {
log.info("미션 완료 이벤트 발행: memberSerialNumber={}, missionId={}, streakDays={}", memberSerialNumber, missionId, streakDays);
// 실제 구현에서는 이벤트 브로커에 발행
// MissionCompleteEvent event = MissionCompleteEvent.builder()
// .memberSerialNumber(memberSerialNumber)
// .missionId(missionId)
// .streakDays(streakDays)
// .timestamp(LocalDateTime.now())
// .build();
// serviceBusTemplate.send("mission-complete-topic", event);
log.info("미션 완료 이벤트 발행 완료: memberSerialNumber={}, missionId={}", memberSerialNumber, missionId);
} catch (Exception e) {
log.error("미션 완료 이벤트 발행 실패: memberSerialNumber={}, missionId={}, error={}", memberSerialNumber, missionId, e.getMessage(), e);
}
}
@Override
public void publishMissionResetEvent(String memberSerialNumber, String resetReason) {
try {
log.info("미션 재설정 이벤트 발행: memberSerialNumber={}, reason={}", memberSerialNumber, resetReason);
// 실제 구현에서는 이벤트 브로커에 발행
// MissionResetEvent event = MissionResetEvent.builder()
// .memberSerialNumber(memberSerialNumber)
// .resetReason(resetReason)
// .timestamp(LocalDateTime.now())
// .build();
// serviceBusTemplate.send("mission-reset-topic", event);
log.info("미션 재설정 이벤트 발행 완료: memberSerialNumber={}", memberSerialNumber);
} catch (Exception e) {
log.error("미션 재설정 이벤트 발행 실패: memberSerialNumber={}, error={}", memberSerialNumber, e.getMessage(), e);
}
}
}

View File

@ -0,0 +1,86 @@
package com.healthsync.goal.infrastructure.adapters;
import com.healthsync.goal.domain.ports.IntelligenceServicePort;
import com.healthsync.goal.dto.CelebrationRequest;
import com.healthsync.goal.dto.CelebrationResponse;
import com.healthsync.goal.dto.RecommendedMission;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
import java.util.List;
/**
* HealthSync_Intelligence Python Service와의 연동을 담당하는 어댑터
*
* 🐍 Python API 스펙:
* - 엔드포인트: POST /api/intelligence/missions/celebrate
* - 요청: CelebrationRequest { userId: long, missionId: long }
* - 응답: CelebrationResponse { congratsMessage: str }
*
* @author healthsync-team
* @version 1.0
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class IntelligenceServiceAdapter implements IntelligenceServicePort {
private final RestTemplate restTemplate;
/*@Value("${healthsync.intelligence.base-url:http://healthsync-intelligence:8080}") */
@Value("${services.intelligence-service.url:http://localhost:8083}")
private String intelligenceServiceBaseUrl;
@Override
public CelebrationResponse celebrateMissionAchievement(CelebrationRequest request) {
log.info("🎉 [CELEBRATION_API] Python 미션 달성 축하 요청: userId={}, missionId={}",
request.getUserId(), request.getMissionId());
try {
// 🔧 정확한 Python API 엔드포인트 경로
String url = intelligenceServiceBaseUrl + "/api/intelligence/missions/celebrate";
log.info("🔗 [CELEBRATION_API] 호출 URL: {}", url); // 추가
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<CelebrationRequest> requestEntity = new HttpEntity<>(request, headers);
ResponseEntity<CelebrationResponse> response = restTemplate.exchange(
url,
HttpMethod.POST,
requestEntity,
CelebrationResponse.class
);
CelebrationResponse celebrationResponse = response.getBody();
log.info("✅ [CELEBRATION_API] Python 축하 메시지 수신 완료: userId={}, message={}",
request.getUserId(), celebrationResponse != null ? celebrationResponse.getCongratsMessage() : "null");
return celebrationResponse;
} catch (Exception e) {
log.error("❌ [CELEBRATION_API] Python 축하 API 호출 실패: userId={}, error={}", request.getUserId(), e.getMessage(), e);
// 🔧 Fallback: Python API 호출 실패시 기본 축하 메시지 반환
return CelebrationResponse.builder()
.congratsMessage("🎉 목표를 달성하셨습니다! 훌륭해요! 건강한 습관을 만들어가고 계시네요! 💪✨")
.build();
}
}
@Override
public List<RecommendedMission> getNewMissionRecommendations(String memberSerialNumber, String resetReason) {
// 기존 메서드 구현 유지
return List.of();
}
}

View File

@ -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";
}
}

View File

@ -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();
}
}
}

View File

@ -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;
}
}
}

View File

@ -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);
}

View File

@ -0,0 +1,38 @@
package com.healthsync.goal.infrastructure.ports;
import java.util.List;
/**
* 이벤트 발행을 위한 포트 인터페이스입니다.
* Clean Architecture의 Domain 계층에서 정의합니다.
*
* @author healthsync-team
* @version 1.0
*/
public interface EventPublisherPort {
/**
* 목표 설정 이벤트를 발행합니다.
*
* @param memberSerialNumber 회원 시리얼 번호
* @param selectedMissionIds 선택된 미션 ID 목록
*/
void publishGoalSetEvent(String memberSerialNumber, List<String> selectedMissionIds);
/**
* 미션 완료 이벤트를 발행합니다.
*
* @param memberSerialNumber 회원 시리얼 번호
* @param missionId 미션 ID
* @param streakDays 연속 달성 일수
*/
void publishMissionCompleteEvent(String memberSerialNumber, String missionId, int streakDays);
/**
* 미션 재설정 이벤트를 발행합니다.
*
* @param memberSerialNumber 회원 시리얼 번호
* @param resetReason 재설정 이유
*/
void publishMissionResetEvent(String memberSerialNumber, String resetReason);
}

View File

@ -0,0 +1,33 @@
package com.healthsync.goal.domain.ports;
import com.healthsync.goal.dto.CelebrationRequest;
import com.healthsync.goal.dto.CelebrationResponse;
import com.healthsync.goal.dto.RecommendedMission;
import java.util.List;
/**
* Intelligence Service 연동을 위한 Domain Port
*
* @author healthsync-team
* @version 1.0
*/
public interface IntelligenceServicePort {
/**
* 🎯 미션 달성 축하 API를 호출합니다. (Python API)
*
* @param request 축하 요청 (userId: long, missionId: long)
* @return 축하 응답 (congratsMessage: str)
*/
CelebrationResponse celebrateMissionAchievement(CelebrationRequest request);
/**
* 새로운 미션 추천을 요청합니다.
*
* @param memberSerialNumber 회원 시리얼 번호
* @param resetReason 재설정 사유
* @return 추천 미션 목록
*/
List<RecommendedMission> getNewMissionRecommendations(String memberSerialNumber, String resetReason);
}

View File

@ -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);
}

View File

@ -0,0 +1,573 @@
// goal-service/src/main/java/com/healthsync/goal/infrastructure/repositories/GoalRepositoryImpl.java
package com.healthsync.goal.infrastructure.repositories;
import com.healthsync.goal.domain.repositories.GoalRepository;
import com.healthsync.goal.dto.*;
import com.healthsync.goal.infrastructure.entities.UserMissionGoalEntity;
import com.healthsync.goal.infrastructure.entities.MissionCompletionHistoryEntity;
import com.healthsync.goal.infrastructure.utils.UserIdValidator;
import com.healthsync.goal.infrastructure.services.IdGeneratorService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
/**
* 목표 데이터 저장소 구현체입니다.
* DDL 구조에 완전히 맞춰 수정됨 + SelectedMissionDetail 처리 추가.
*
* @author healthsync-team
* @version 1.0
*/
@Slf4j
@Repository
@RequiredArgsConstructor
@Transactional
public class GoalRepositoryImpl implements GoalRepository {
private final UserMissionGoalJpaRepository userMissionGoalJpaRepository;
private final MissionCompletionJpaRepository missionCompletionJpaRepository;
private final IdGeneratorService idGeneratorService;
@Override
public String saveGoalSettings(MissionSelectionRequest request) {
log.info("🎯 [GOAL_SETTINGS] 미션 목표 설정 저장 시작: memberSerialNumber={}, selectedMissionCount={}",
request.getMemberSerialNumber(), request.getSelectedMissionIds().size());
try {
Long memberSerialNumber = UserIdValidator.parseMemberSerialNumber(request.getMemberSerialNumber(), "saveGoalSettings");
// SelectedMissionDetail에서 미션 ID 목록 추출
List<String> missionIdList = request.getSelectedMissionIds().stream()
.map(this::extractMissionIdFromDetail)
.collect(Collectors.toList());
// Mock Intelligence Service에서 선택된 미션들의 상세 정보 조회 (필요시)
// List<MissionDetailResponse> missionDetails = intelligenceServicePort
// .getMissionDetails(request.getMemberSerialNumber(), missionIdList);
// 선택된 미션들을 DDL 구조에 맞춰 저장
List<UserMissionGoalEntity> savedMissions = request.getSelectedMissionIds().stream()
.map(selectedMissionDetail -> {
// DDL에 맞춰 ID를 수동 생성
Long generatedMissionId = idGeneratorService.generateMissionId();
// SelectedMissionDetail에서 직접 정보 사용
String missionTitle = selectedMissionDetail.getTitle();
String missionDescription = generateMissionDescription(selectedMissionDetail);
int dailyTargetCount = selectedMissionDetail.getDaily_target_count();
UserMissionGoalEntity userMissionGoal = UserMissionGoalEntity.builder()
.missionId(generatedMissionId)
.memberSerialNumber(memberSerialNumber)
.performanceDate(LocalDate.now())
.missionName(missionTitle)
.missionDescription(missionDescription)
.dailyTargetCount(dailyTargetCount)
.isActive(true)
.createdAt(LocalDateTime.now())
.build();
log.debug("✅ Creating mission goal: missionId={}, title={}, description={}, dailyTarget={}",
generatedMissionId, missionTitle, missionDescription, dailyTargetCount);
return userMissionGoalJpaRepository.save(userMissionGoal);
})
.toList();
String goalId = "GOAL_SAVED_" + System.currentTimeMillis();
log.info("✅ [GOAL_SETTINGS] 미션 목표 설정 저장 완료: memberSerialNumber={}, goalId={}, savedCount={}",
request.getMemberSerialNumber(), goalId, savedMissions.size());
return goalId;
} catch (IllegalArgumentException e) {
log.error("❌ [GOAL_SETTINGS] 미션 목표 설정 저장 실패: memberSerialNumber={}, 오류={}", request.getMemberSerialNumber(), e.getMessage());
throw e;
} catch (Exception e) {
log.error("❌ [GOAL_SETTINGS] 미션 목표 설정 저장 중 예상치 못한 오류: memberSerialNumber={}, 오류={}", request.getMemberSerialNumber(), e.getMessage(), e);
throw new RuntimeException("미션 목표 설정 중 오류가 발생했습니다.", e);
}
}
@Override
public List<DailyMission> findActiveMissionsByUserId(String memberSerialNumber) {
log.info("🔍 [ACTIVE_MISSIONS] 활성 미션 조회 시작: memberSerialNumber={}", memberSerialNumber);
try {
Long memberSerialNumberLong = UserIdValidator.parseMemberSerialNumber(memberSerialNumber, "findActiveMissionsByUserId");
// 실제 메서드명 사용
List<UserMissionGoalEntity> activeMissions = userMissionGoalJpaRepository
.findActiveByMemberSerialNumber(memberSerialNumberLong);
List<DailyMission> dailyMissions = activeMissions.stream()
.map(this::convertToDailyMission)
.toList();
log.info("✅ [ACTIVE_MISSIONS] 활성 미션 조회 완료: memberSerialNumber={}, missionCount={}", memberSerialNumber, dailyMissions.size());
return dailyMissions;
} catch (Exception e) {
log.error("❌ [ACTIVE_MISSIONS] 활성 미션 조회 중 오류: memberSerialNumber={}, 오류={}", memberSerialNumber, e.getMessage(), e);
throw new RuntimeException("활성 미션 조회 중 오류가 발생했습니다.", e);
}
}
@Override
public void deactivateCurrentMissions(String memberSerialNumber) {
log.info("⏹️ [DEACTIVATE_MISSIONS] 미션 비활성화 시작: memberSerialNumber={}", memberSerialNumber);
try {
Long memberSerialNumberLong = UserIdValidator.parseMemberSerialNumber(memberSerialNumber, "deactivateCurrentMissions");
// 실제 구현 방식: 엔티티 조회 deactivate() 호출 saveAll()
List<UserMissionGoalEntity> activeMissions = userMissionGoalJpaRepository
.findActiveByMemberSerialNumber(memberSerialNumberLong);
activeMissions.forEach(UserMissionGoalEntity::deactivate);
userMissionGoalJpaRepository.saveAll(activeMissions);
log.info("✅ [DEACTIVATE_MISSIONS] 미션 비활성화 완료: memberSerialNumber={}, updatedCount={}", memberSerialNumber, activeMissions.size());
} catch (Exception e) {
log.error("❌ [DEACTIVATE_MISSIONS] 미션 비활성화 중 오류: memberSerialNumber={}, 오류={}", memberSerialNumber, e.getMessage(), e);
throw new RuntimeException("미션 비활성화 중 오류가 발생했습니다.", e);
}
}
@Override
public void recordMissionCompletion(String missionId, MissionCompleteRequest request) {
log.info("📝 [MISSION_COMPLETION] 미션 점진적 완료 기록 시작: memberSerialNumber={}, missionId={}",
request.getMemberSerialNumber(), missionId);
try {
Long memberSerialNumber = UserIdValidator.parseMemberSerialNumber(request.getMemberSerialNumber(), "recordMissionCompletion");
Long missionIdLong = Long.parseLong(missionId);
// 1. 미션 정보 조회
Optional<UserMissionGoalEntity> missionOpt = userMissionGoalJpaRepository
.findByMissionIdAndMemberSerialNumber(missionIdLong, memberSerialNumber);
if (missionOpt.isEmpty()) {
throw new IllegalArgumentException("미션을 찾을 수 없습니다: " + missionId);
}
UserMissionGoalEntity mission = missionOpt.get();
LocalDate today = LocalDate.now();
// 2. 오늘 완료 기록이 있는지 확인
Optional<MissionCompletionHistoryEntity> existingOpt = missionCompletionJpaRepository
.findByMissionIdAndMemberSerialNumberAndCompletionDate(missionIdLong, memberSerialNumber, today);
if (existingOpt.isPresent()) {
// 3-A. 기존 기록이 있으면 daily_completed_count +1 증가
MissionCompletionHistoryEntity existing = existingOpt.get();
// 목표 초과 방지
if (existing.getDailyCompletedCount() >= existing.getDailyTargetCount()) {
log.warn("⚠️ [MISSION_COMPLETION] 이미 목표를 달성한 미션: memberSerialNumber={}, missionId={}, current={}/{}",
request.getMemberSerialNumber(), missionId,
existing.getDailyCompletedCount(), existing.getDailyTargetCount());
return;
}
// +1 증가
int newCompletedCount = existing.getDailyCompletedCount() + 1;
existing.setDailyCompletedCount(newCompletedCount);
missionCompletionJpaRepository.save(existing);
log.info("✅ [MISSION_COMPLETION] 기존 기록 업데이트: memberSerialNumber={}, missionId={}, count={}/{}",
request.getMemberSerialNumber(), missionId, newCompletedCount, existing.getDailyTargetCount());
} else {
// 3-B. 기존 기록이 없으면 새로 생성 (daily_completed_count = 1로 시작)
Long generatedCompletionId = idGeneratorService.generateCompletionId();
MissionCompletionHistoryEntity newCompletion = MissionCompletionHistoryEntity.builder()
.completionId(generatedCompletionId)
.missionId(missionIdLong)
.memberSerialNumber(memberSerialNumber)
.completionDate(today)
.dailyTargetCount(mission.getDailyTargetCount()) // user_mission_goal에서 가져옴
.dailyCompletedCount(1) // 호출시 1로 시작
.createdAt(LocalDateTime.now())
.build();
missionCompletionJpaRepository.save(newCompletion);
log.info("✅ [MISSION_COMPLETION] 새로운 기록 생성: memberSerialNumber={}, missionId={}, completionId={}, count=1/{}",
request.getMemberSerialNumber(), missionId, generatedCompletionId, mission.getDailyTargetCount());
}
} catch (IllegalArgumentException e) {
log.error("❌ [MISSION_COMPLETION] 미션 완료 기록 실패: memberSerialNumber={}, missionId={}, 오류={}",
request.getMemberSerialNumber(), missionId, e.getMessage());
throw e;
} catch (Exception e) {
log.error("❌ [MISSION_COMPLETION] 미션 완료 기록 중 예상치 못한 오류: memberSerialNumber={}, missionId={}, 오류={}",
request.getMemberSerialNumber(), missionId, e.getMessage(), e);
throw new RuntimeException("미션 완료 기록 중 오류가 발생했습니다.", e);
}
}
@Override
public int getTotalCompletedCount(String memberSerialNumber, String missionId) {
log.info("📊 [TOTAL_COUNT] 총 완료 횟수 조회 시작: memberSerialNumber={}, missionId={}", memberSerialNumber, missionId);
try {
Long memberSerialNumberLong = UserIdValidator.parseMemberSerialNumber(memberSerialNumber, "getTotalCompletedCount");
Long missionIdLong = Long.parseLong(missionId);
int count = missionCompletionJpaRepository
.countByMemberSerialNumberAndMissionId(memberSerialNumberLong, missionIdLong);
log.info("✅ [TOTAL_COUNT] 총 완료 횟수 조회 완료: memberSerialNumber={}, missionId={}, count={}", memberSerialNumber, missionId, count);
return count;
} catch (Exception e) {
log.error("❌ [TOTAL_COUNT] 총 완료 횟수 조회 중 오류: memberSerialNumber={}, missionId={}, 오류={}", memberSerialNumber, missionId, e.getMessage(), e);
throw new RuntimeException("총 완료 횟수 조회 중 오류가 발생했습니다.", e);
}
}
@Override
public List<MissionStats> findMissionHistoryByPeriod(String memberSerialNumber, String startDate, String endDate, String missionIds) {
log.info("📊 [MISSION_HISTORY] 미션 이력 조회 시작: memberSerialNumber={}, period={}-{}", memberSerialNumber, startDate, endDate);
try {
Long memberSerialNumberLong = UserIdValidator.parseMemberSerialNumber(memberSerialNumber, "findMissionHistoryByPeriod");
// 실제 데이터베이스 조회
List<MissionCompletionHistoryEntity> completionHistory =
missionCompletionJpaRepository.findByMemberSerialNumberAndDateRange(
memberSerialNumberLong,
LocalDate.parse(startDate),
LocalDate.parse(endDate)
);
// MissionStats로 변환 - 실제 미션 이름 조회 포함
List<MissionStats> missionStats = completionHistory.stream()
.collect(Collectors.groupingBy(MissionCompletionHistoryEntity::getMissionId))
.entrySet().stream()
.map(entry -> {
Long missionId = entry.getKey();
List<MissionCompletionHistoryEntity> missions = entry.getValue();
// 실제 미션 이름 조회
String missionTitle = getMissionNameById(missionId);
return MissionStats.builder()
.missionId(missionId.toString())
.title(missionTitle) // 실제 DB에서 조회한 미션 이름
.completedDays(missions.size())
.totalDays(calculateTotalDaysInPeriod(startDate, endDate))
.achievementRate(calculateAchievementRate(missions.size(), calculateTotalDaysInPeriod(startDate, endDate)))
.build();
})
.collect(Collectors.toList());
log.info("✅ [MISSION_HISTORY] 미션 이력 조회 완료: memberSerialNumber={}, count={}", memberSerialNumber, missionStats.size());
return missionStats;
} catch (Exception e) {
log.error("❌ [MISSION_HISTORY] 미션 이력 조회 중 오류: memberSerialNumber={}, 오류={}", memberSerialNumber, e.getMessage(), e);
throw new RuntimeException("미션 이력 조회 중 오류가 발생했습니다.", e);
}
}
/**
* 미션 ID로 실제 미션 이름을 조회합니다.
*
* @param missionId 미션 ID
* @return 미션 이름
*/
private String getMissionNameById(Long missionId) {
try {
Optional<UserMissionGoalEntity> missionEntity = userMissionGoalJpaRepository.findByMissionId(missionId);
if (missionEntity.isPresent()) {
String missionName = missionEntity.get().getMissionName();
log.debug("🔍 [MISSION_NAME] 미션 이름 조회 성공: missionId={}, name={}", missionId, missionName);
return missionName;
} else {
log.warn("⚠️ [MISSION_NAME] 미션을 찾을 수 없음: missionId={}", missionId);
return "미션 #" + missionId; // fallback
}
} catch (Exception e) {
log.error("❌ [MISSION_NAME] 미션 이름 조회 중 오류: missionId={}, 오류={}", missionId, e.getMessage());
return "미션 #" + missionId; // fallback
}
}
private int calculateTotalDaysInPeriod(String startDate, String endDate) {
return (int) ChronoUnit.DAYS.between(LocalDate.parse(startDate), LocalDate.parse(endDate)) + 1;
}
private double calculateAchievementRate(int completedDays, int totalDays) {
return totalDays > 0 ? (double) completedDays / totalDays * 100.0 : 0.0;
}
// === Private Helper Methods ===
/**
* SelectedMissionDetail에서 미션 ID를 추출합니다.
* 제목을 기반으로 고유한 ID를 생성합니다.
*/
private String extractMissionIdFromDetail(SelectedMissionDetail detail) {
if (detail == null || detail.getTitle() == null) {
return "mission_unknown_" + System.currentTimeMillis();
}
// 제목을 기반으로 간단한 ID 생성
return detail.getTitle()
.replaceAll("[^가-힣a-zA-Z0-9]", "_")
.toLowerCase()
.replaceAll("_+", "_")
.replaceAll("^_|_$", "");
}
/**
* SelectedMissionDetail로부터 미션 설명을 생성합니다.
*/
private String generateMissionDescription(SelectedMissionDetail detail) {
return String.format("%s (일일 %d회) - %s",
detail.getTitle(),
detail.getDaily_target_count(),
detail.getReason());
}
/**
* UserMissionGoalEntity를 DailyMission으로 변환하는 헬퍼 메서드
*/
private DailyMission convertToDailyMission(UserMissionGoalEntity entity) {
// 오늘 완료 상태 조회
LocalDate today = LocalDate.now();
List<MissionCompletionHistoryEntity> todayCompletions = missionCompletionJpaRepository
.findByMemberSerialNumberAndCompletionDate(entity.getMemberSerialNumber(), today);
// 미션의 오늘 완료 기록만 필터링
List<MissionCompletionHistoryEntity> thisMissionCompletions = todayCompletions.stream()
.filter(completion -> completion.getMissionId().equals(entity.getMissionId()))
.toList();
// 오늘 완료 여부
boolean completedToday = !thisMissionCompletions.isEmpty();
// 오늘 완료한 횟수
int completedCount = thisMissionCompletions.stream()
.mapToInt(MissionCompletionHistoryEntity::getDailyCompletedCount)
.sum();
// 연속 달성 일수 계산
int streakDays = calculateStreakDays(entity.getMemberSerialNumber(), entity.getMissionId());
// 미션 상태 결정
String status;
if (completedToday && completedCount >= entity.getDailyTargetCount()) {
status = "COMPLETED";
} else if (completedToday) {
status = "PARTIAL";
} else {
status = "PENDING";
}
return DailyMission.builder()
.missionId(entity.getMissionId().toString())
.title(entity.getMissionName())
.description(entity.getMissionDescription())
.targetCount(entity.getDailyTargetCount())
.status(status)
.completedToday(completedToday)
.completedCount(completedCount)
.streakDays(streakDays)
.nextReminderTime("09:00")
.build();
}
/**
* 연속 달성 일수를 계산하는 헬퍼 메서드
*/
private int calculateStreakDays(Long memberSerialNumber, Long missionId) {
// 간단한 구현: 실제로는 연속성 확인 로직 필요
List<MissionCompletionHistoryEntity> recentCompletions = missionCompletionJpaRepository
.findByMemberSerialNumberAndMissionId(memberSerialNumber, missionId);
return Math.min(recentCompletions.size(), 7); // 최대 7일로 제한
}
@Override
public UserMissionGoalEntity findMissionByIdAndUser(String missionId, String memberSerialNumber) {
log.info("📋 [FIND_MISSION] 미션 조회: missionId={}, memberSerialNumber={}", missionId, memberSerialNumber);
try {
Long missionIdLong = Long.parseLong(missionId);
Long memberSerialNumberLong = UserIdValidator.parseMemberSerialNumber(memberSerialNumber, "findMissionByIdAndUser");
return userMissionGoalJpaRepository
.findByMissionIdAndMemberSerialNumber(missionIdLong, memberSerialNumberLong)
.orElse(null);
} catch (Exception e) {
log.error("❌ [FIND_MISSION] 미션 조회 오류: missionId={}, memberSerialNumber={}, 오류={}",
missionId, memberSerialNumber, e.getMessage(), e);
throw new RuntimeException("미션 조회 중 오류가 발생했습니다.", e);
}
}
@Override
public MissionCompletionHistoryEntity findOrCreateTodayCompletion(String missionId, String memberSerialNumber, Integer dailyTargetCount) {
log.info("📋 [TODAY_COMPLETION] 오늘 완료 기록 조회/생성: missionId={}, memberSerialNumber={}", missionId, memberSerialNumber);
try {
Long missionIdLong = Long.parseLong(missionId);
Long memberSerialNumberLong = UserIdValidator.parseMemberSerialNumber(memberSerialNumber, "findOrCreateTodayCompletion");
LocalDate today = LocalDate.now();
// 오늘 완료 기록 조회
Optional<MissionCompletionHistoryEntity> existingOpt = missionCompletionJpaRepository
.findByMissionIdAndMemberSerialNumberAndCompletionDate(missionIdLong, memberSerialNumberLong, today);
if (existingOpt.isPresent()) {
return existingOpt.get();
}
// 새로운 완료 기록 생성
Long newCompletionId = idGeneratorService.generateCompletionId();
MissionCompletionHistoryEntity newCompletion = MissionCompletionHistoryEntity.builder()
.completionId(newCompletionId)
.missionId(missionIdLong)
.memberSerialNumber(memberSerialNumberLong)
.completionDate(today)
.dailyTargetCount(dailyTargetCount)
.dailyCompletedCount(0)
.createdAt(LocalDateTime.now())
.build();
return missionCompletionJpaRepository.save(newCompletion);
} catch (Exception e) {
log.error("❌ [TODAY_COMPLETION] 완료 기록 조회/생성 오류: missionId={}, memberSerialNumber={}, 오류={}",
missionId, memberSerialNumber, e.getMessage(), e);
throw new RuntimeException("완료 기록 조회/생성 중 오류가 발생했습니다.", e);
}
}
@Override
public void saveMissionCompletion(MissionCompletionHistoryEntity completion) {
log.info("💾 [SAVE_COMPLETION] 완료 기록 저장: completionId={}, count={}/{}",
completion.getCompletionId(), completion.getDailyCompletedCount(), completion.getDailyTargetCount());
try {
missionCompletionJpaRepository.save(completion);
log.info("✅ [SAVE_COMPLETION] 완료 기록 저장 성공: completionId={}", completion.getCompletionId());
} catch (Exception e) {
log.error("❌ [SAVE_COMPLETION] 완료 기록 저장 오류: completionId={}, 오류={}",
completion.getCompletionId(), e.getMessage(), e);
throw new RuntimeException("완료 기록 저장 중 오류가 발생했습니다.", e);
}
}
@Override
public int calculateStreakDays(String memberSerialNumber, String missionId) {
log.info("📊 [STREAK_DAYS] 연속 달성일수 계산: memberSerialNumber={}, missionId={}", memberSerialNumber, missionId);
try {
Long memberSerialNumberLong = UserIdValidator.parseMemberSerialNumber(memberSerialNumber, "calculateStreakDays");
Long missionIdLong = Long.parseLong(missionId);
LocalDate endDate = LocalDate.now();
LocalDate startDate = endDate.minusDays(30);
List<MissionCompletionHistoryEntity> recentCompletions = missionCompletionJpaRepository
.findByMissionIdAndMemberSerialNumberAndCompletionDateBetweenOrderByCompletionDateDesc(
missionIdLong, memberSerialNumberLong, startDate, endDate);
int streakDays = 0;
LocalDate checkDate = LocalDate.now();
while (true) {
boolean foundCompleted = false;
for (MissionCompletionHistoryEntity completion : recentCompletions) {
if (completion.getCompletionDate().equals(checkDate) && completion.isTargetAchieved()) {
streakDays++;
foundCompleted = true;
break;
}
}
if (!foundCompleted || checkDate.isBefore(startDate)) {
break;
}
checkDate = checkDate.minusDays(1);
}
log.info("✅ [STREAK_DAYS] 연속 달성일수 계산 완료: memberSerialNumber={}, missionId={}, streakDays={}",
memberSerialNumber, missionId, streakDays);
return streakDays;
} catch (Exception e) {
log.error("❌ [STREAK_DAYS] 연속 달성일수 계산 오류: memberSerialNumber={}, missionId={}, 오류={}",
memberSerialNumber, missionId, e.getMessage(), e);
return 0;
}
}
/**
* 🎯 오늘 해당 미션의 목표를 달성했는지 확인합니다.
*
* @param memberSerialNumber 회원 시리얼 번호
* @param missionId 미션 ID
* @return 목표 달성 여부
*/
@Override
public boolean isTodayTargetAchieved(String memberSerialNumber, String missionId) {
log.info("🎯 [TARGET_CHECK] 오늘 목표 달성 여부 확인: memberSerialNumber={}, missionId={}",
memberSerialNumber, missionId);
try {
Long memberSerialNumberLong = UserIdValidator.parseMemberSerialNumber(memberSerialNumber, "isTodayTargetAchieved");
Long missionIdLong = Long.parseLong(missionId);
LocalDate today = LocalDate.now();
// 오늘 해당 미션의 완료 이력 조회
List<MissionCompletionHistoryEntity> todayCompletions = missionCompletionJpaRepository
.findByMemberSerialNumberAndMissionIdAndCompletionDate(memberSerialNumberLong, missionIdLong, today);
if (todayCompletions.isEmpty()) {
log.info("📝 [TARGET_CHECK] 오늘 완료 이력 없음: memberSerialNumber={}, missionId={}",
memberSerialNumber, missionId);
return false;
}
// 가장 최근 완료 이력 확인
MissionCompletionHistoryEntity latestCompletion = todayCompletions.get(0);
boolean isAchieved = latestCompletion.getDailyCompletedCount() >= latestCompletion.getDailyTargetCount();
log.info("✅ [TARGET_CHECK] 목표 달성 여부: memberSerialNumber={}, missionId={}, completed={}/{}, achieved={}",
memberSerialNumber, missionId,
latestCompletion.getDailyCompletedCount(),
latestCompletion.getDailyTargetCount(),
isAchieved);
return isAchieved;
} catch (Exception e) {
log.error("❌ [TARGET_CHECK] 목표 달성 여부 확인 중 오류: memberSerialNumber={}, missionId={}, error={}",
memberSerialNumber, missionId, e.getMessage(), e);
return false;
}
}
}

View File

@ -0,0 +1,90 @@
package com.healthsync.goal.infrastructure.repositories;
import com.healthsync.goal.infrastructure.entities.MissionCompletionHistoryEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
/**
* 미션 완료 기록을 위한 JPA 리포지토리입니다.
* DDL의 mission_completion_history 테이블과 매핑됩니다.
*
* @author healthsync-team
* @version 1.0
*/
@Repository
public interface MissionCompletionJpaRepository extends JpaRepository<MissionCompletionHistoryEntity, Long> {
/**
* 회원 시리얼번호와 미션 ID로 완료 이력 조회 (DDL 맞춤)
*/
@Query("SELECT mch FROM MissionCompletionHistoryEntity mch WHERE mch.memberSerialNumber = :memberSerialNumber AND mch.missionId = :missionId ORDER BY mch.completionDate DESC")
List<MissionCompletionHistoryEntity> findByMemberSerialNumberAndMissionId(@Param("memberSerialNumber") Long memberSerialNumber, @Param("missionId") Long missionId);
/**
* 특정 날짜의 완료 이력 조회 (DDL 맞춤)
*/
@Query("SELECT mch FROM MissionCompletionHistoryEntity mch WHERE mch.memberSerialNumber = :memberSerialNumber AND mch.completionDate = :completionDate")
List<MissionCompletionHistoryEntity> findByMemberSerialNumberAndCompletionDate(@Param("memberSerialNumber") Long memberSerialNumber, @Param("completionDate") LocalDate completionDate);
/**
* 회원 시리얼번호와 미션 ID, 특정 날짜의 완료 여부 확인 (DDL 맞춤)
*/
boolean existsByMemberSerialNumberAndMissionIdAndCompletionDate(Long memberSerialNumber, Long missionId, LocalDate completionDate);
/**
* 회원 시리얼번호와 미션 ID로 완료 횟수 조회 (DDL 맞춤)
*/
int countByMemberSerialNumberAndMissionId(Long memberSerialNumber, Long missionId);
/**
* 미션 ID, 회원 시리얼 번호, 완료 날짜로 완료 기록을 조회합니다.
*/
@Query("SELECT mch FROM MissionCompletionHistoryEntity mch WHERE mch.missionId = :missionId AND mch.memberSerialNumber = :memberSerialNumber AND mch.completionDate = :completionDate")
Optional<MissionCompletionHistoryEntity> findByMissionIdAndMemberSerialNumberAndCompletionDate(
@Param("missionId") Long missionId,
@Param("memberSerialNumber") Long memberSerialNumber,
@Param("completionDate") LocalDate completionDate);
/**
* 미션 ID와 회원 시리얼 번호로 특정 기간의 완료 기록을 날짜 내림차순으로 조회합니다.
*/
@Query("SELECT mch FROM MissionCompletionHistoryEntity mch WHERE mch.missionId = :missionId AND mch.memberSerialNumber = :memberSerialNumber AND mch.completionDate BETWEEN :startDate AND :endDate ORDER BY mch.completionDate DESC")
List<MissionCompletionHistoryEntity> findByMissionIdAndMemberSerialNumberAndCompletionDateBetweenOrderByCompletionDateDesc(
@Param("missionId") Long missionId,
@Param("memberSerialNumber") Long memberSerialNumber,
@Param("startDate") LocalDate startDate,
@Param("endDate") LocalDate endDate);
/**
* 기간별 미션 완료 이력 조회 (findMissionHistoryByPeriod에서 사용)
*/
@Query("SELECT mch FROM MissionCompletionHistoryEntity mch " +
"WHERE mch.memberSerialNumber = :memberSerialNumber " +
"AND mch.completionDate BETWEEN :startDate AND :endDate " +
"ORDER BY mch.completionDate DESC")
List<MissionCompletionHistoryEntity> findByMemberSerialNumberAndDateRange(
@Param("memberSerialNumber") Long memberSerialNumber,
@Param("startDate") LocalDate startDate,
@Param("endDate") LocalDate endDate
);
/**
* 특정 회원의 특정 미션에 대한 특정 날짜의 완료 이력을 조회합니다.
*
* @param memberSerialNumber 회원 시리얼 번호
* @param missionId 미션 ID
* @param completionDate 완료 날짜
* @return 완료 이력 목록
*/
List<MissionCompletionHistoryEntity> findByMemberSerialNumberAndMissionIdAndCompletionDate(
Long memberSerialNumber, Long missionId, LocalDate completionDate);
}

View File

@ -0,0 +1,45 @@
package com.healthsync.goal.infrastructure.repositories;
import com.healthsync.goal.infrastructure.entities.UserMissionGoalEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
/**
* DDL의 user_mission_goal 테이블을 위한 JPA 리포지토리입니다.
*
* @author healthsync-team
* @version 1.0
*/
@Repository
public interface UserMissionGoalJpaRepository extends JpaRepository<UserMissionGoalEntity, Long> {
/**
* 회원 시리얼 번호로 활성 미션 목표 조회
*/
@Query("SELECT umg FROM UserMissionGoalEntity umg WHERE umg.memberSerialNumber = :memberSerialNumber AND umg.isActive = true")
List<UserMissionGoalEntity> findActiveByMemberSerialNumber(@Param("memberSerialNumber") Long memberSerialNumber);
/**
* 특정 날짜의 미션 목표 조회
*/
@Query("SELECT umg FROM UserMissionGoalEntity umg WHERE umg.memberSerialNumber = :memberSerialNumber AND umg.performanceDate = :performanceDate AND umg.isActive = true")
List<UserMissionGoalEntity> findByMemberSerialNumberAndPerformanceDate(@Param("memberSerialNumber") Long memberSerialNumber, @Param("performanceDate") LocalDate performanceDate);
/**
* 미션 ID로 조회 (명시적 쿼리 추가)
*/
@Query("SELECT umg FROM UserMissionGoalEntity umg WHERE umg.missionId = :missionId")
Optional<UserMissionGoalEntity> findByMissionId(@Param("missionId") Long missionId);
/**
* 미션 ID와 회원 시리얼 번호로 미션을 조회합니다.
*/
@Query("SELECT umg FROM UserMissionGoalEntity umg WHERE umg.missionId = :missionId AND umg.memberSerialNumber = :memberSerialNumber")
Optional<UserMissionGoalEntity> findByMissionIdAndMemberSerialNumber(@Param("missionId") Long missionId, @Param("memberSerialNumber") Long memberSerialNumber);
}

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -0,0 +1,138 @@
package com.healthsync.goal.interface_adapters.controllers;
import com.healthsync.common.dto.ApiResponse;
import com.healthsync.goal.application_services.GoalUseCase;
import com.healthsync.goal.dto.*;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import jakarta.validation.Valid;
/**
* 목표 관리 관련 API를 제공하는 컨트롤러입니다.
* Clean Architecture의 Interface Adapter 계층에 해당합니다.
*
* @author healthsync-team
* @version 1.0
*/
@Slf4j
@RestController
@RequestMapping("/api/goals")
@RequiredArgsConstructor
@Tag(name = "목표 관리", description = "건강 목표 설정 및 미션 관리 API")
public class GoalController {
private final GoalUseCase goalUseCase;
/**
* 미션을 선택하고 목표를 설정합니다.
* 이제 미션의 상세 정보(제목, 일일 목표 횟수, 사유) 함께 받습니다.
*
* @param request 미션 선택 요청 (상세 정보 포함)
* @return 목표 설정 결과
*/
@PostMapping("/missions/select")
@Operation(summary = "미션 선택 및 목표 설정",
description = "사용자가 선택한 미션의 상세 정보로 건강 목표를 설정합니다. " +
"미션 제목, 일일 목표 횟수, 선정 사유를 모두 포함해야 합니다.")
public ResponseEntity<ApiResponse<GoalSetupResponse>> selectMissions(@Valid @RequestBody MissionSelectionRequest request) {
log.info("미션 선택 및 목표 설정 요청: memberSerialNumber={}, missionCount={}",
request.getMemberSerialNumber(), request.getSelectedMissionIds().size());
// 미션의 상세 정보 로깅
request.getSelectedMissionIds().forEach(mission ->
log.info("선택된 미션: title={}, daily_target={}, reason={}",
mission.getTitle(), mission.getDaily_target_count(), mission.getReason())
);
GoalSetupResponse response = goalUseCase.selectMissions(request);
log.info("미션 선택 및 목표 설정 완료: memberSerialNumber={}, goalId={}",
request.getMemberSerialNumber(), response.getGoalId());
return ResponseEntity.ok(ApiResponse.success("목표 설정이 완료되었습니다.", response));
}
/**
* 설정된 활성 미션을 조회합니다.
*
* @param memberSerialNumber 회원 시리얼 번호
* @return 활성 미션 목록
*/
@GetMapping("/missions/active")
@Operation(summary = "설정된 목표 조회", description = "사용자의 현재 활성 미션과 진행 상황을 조회합니다")
public ResponseEntity<ApiResponse<ActiveMissionsResponse>> getActiveMissions(@RequestParam String memberSerialNumber) {
log.info("활성 미션 조회 요청: memberSerialNumber={}", memberSerialNumber);
ActiveMissionsResponse response = goalUseCase.getActiveMissions(memberSerialNumber);
log.info("활성 미션 조회 완료: memberSerialNumber={}, totalMissions={}", memberSerialNumber, response.getTotalMissions());
return ResponseEntity.ok(ApiResponse.success("활성 미션 조회가 완료되었습니다.", response));
}
/**
* 미션 완료를 처리합니다.
*
* @param missionId 미션 ID
* @param request 미션 완료 요청
* @return 미션 완료 결과
*/
@PutMapping("/missions/{missionId}/complete")
@Operation(summary = "미션 완료 처리", description = "사용자의 미션 완료를 기록하고 성과를 업데이트합니다")
public ResponseEntity<ApiResponse<MissionCompleteResponse>> completeMission(
@PathVariable String missionId,
@Valid @RequestBody MissionCompleteRequest request) {
log.info("미션 완료 처리 요청: memberSerialNumber={}, missionId={}", request.getMemberSerialNumber(), missionId);
MissionCompleteResponse response = goalUseCase.completeMission(missionId, request);
log.info("미션 완료 처리 완료: memberSerialNumber={}, missionId={}, streakDays={}",
request.getMemberSerialNumber(), missionId, response.getNewStreakDays());
return ResponseEntity.ok(ApiResponse.success("미션 완료가 기록되었습니다.", response));
}
/**
* 미션 달성 이력을 조회합니다.
*
* @param memberSerialNumber 회원 시리얼 번호
* @param startDate 시작일
* @param endDate 종료일
* @param missionIds 미션 ID 목록
* @return 미션 달성 이력
*/
@GetMapping("/missions/history")
@Operation(summary = "미션 달성 이력 조회", description = "지정한 기간의 미션 달성 이력과 통계를 조회합니다")
public ResponseEntity<ApiResponse<MissionHistoryResponse>> getMissionHistory(
@RequestParam String memberSerialNumber,
@RequestParam(required = false) String startDate,
@RequestParam(required = false) String endDate,
@RequestParam(required = false) String missionIds) {
log.info("미션 이력 조회 요청: memberSerialNumber={}, period={} to {}", memberSerialNumber, startDate, endDate);
MissionHistoryResponse response = goalUseCase.getMissionHistory(memberSerialNumber, startDate, endDate, missionIds);
log.info("미션 이력 조회 완료: memberSerialNumber={}, achievementRate={}", memberSerialNumber, response.getTotalAchievementRate());
return ResponseEntity.ok(ApiResponse.success("미션 이력 조회가 완료되었습니다.", response));
}
/**
* 미션을 재설정합니다.
*
* @param request 미션 재설정 요청
* @return 미션 재설정 결과
*/
@PostMapping("/missions/reset")
@Operation(summary = "목표 재설정", description = "현재 미션을 중단하고 새로운 미션으로 재설정합니다")
public ResponseEntity<ApiResponse<MissionResetResponse>> resetMissions(@Valid @RequestBody MissionResetRequest request) {
log.info("미션 재설정 요청: memberSerialNumber={}, reason={}", request.getMemberSerialNumber(), request.getReason());
MissionResetResponse response = goalUseCase.resetMissions(request);
log.info("미션 재설정 완료: memberSerialNumber={}, newRecommendationCount={}",
request.getMemberSerialNumber(), response.getNewRecommendations().size());
return ResponseEntity.ok(ApiResponse.success("미션 재설정이 완료되었습니다.", response));
}
}

View File

@ -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

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View File

@ -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

251
gradlew vendored Executable file
View File

@ -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" "$@"

94
gradlew.bat vendored Normal file
View File

@ -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

View File

@ -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'
}

View File

@ -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);
}
}

View File

@ -0,0 +1,42 @@
package com.healthsync.common.dto;
public class CusApiResponse<T> {
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 <T> CusApiResponse<T> success(T data, String message) {
return new CusApiResponse<>(true, message, data);
}
public static <T> CusApiResponse<T> 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; }
}

View File

@ -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);
}
}

View File

@ -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<CusApiResponse<Void>> handleUserNotFoundException(UserNotFoundException e) {
logger.error("User not found: {}", e.getMessage());
return ResponseHelper.notFound("사용자를 찾을 수 없습니다", e.getMessage());
}
@ExceptionHandler(AuthenticationException.class)
public ResponseEntity<CusApiResponse<Void>> handleAuthenticationException(AuthenticationException e) {
logger.error("Authentication error: {}", e.getMessage());
return ResponseHelper.unauthorized("인증에 실패했습니다", e.getMessage());
}
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<CusApiResponse<Void>> handleAccessDeniedException(AccessDeniedException e) {
logger.error("Access denied: {}", e.getMessage());
return ResponseHelper.forbidden("접근이 거부되었습니다", e.getMessage());
}
@ExceptionHandler(Exception.class)
public ResponseEntity<CusApiResponse<Void>> handleGenericException(Exception e) {
logger.error("Unexpected error: {}", e.getMessage(), e);
return ResponseHelper.internalServerError("서버 오류가 발생했습니다", e.getMessage());
}
}

View File

@ -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 <T> ResponseEntity<CusApiResponse<T>> success(T data, String message) {
return ResponseEntity.ok(CusApiResponse.success(data, message));
}
public static <T> ResponseEntity<CusApiResponse<T>> created(T data, String message) {
return ResponseEntity.status(HttpStatus.CREATED).body(CusApiResponse.success(data, message));
}
public static <T> ResponseEntity<CusApiResponse<T>> badRequest(String message, String error) {
return ResponseEntity.badRequest().body(CusApiResponse.error(message, error));
}
public static <T> ResponseEntity<CusApiResponse<T>> unauthorized(String message, String error) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(CusApiResponse.error(message, error));
}
public static <T> ResponseEntity<CusApiResponse<T>> forbidden(String message, String error) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CusApiResponse.error(message, error));
}
public static <T> ResponseEntity<CusApiResponse<T>> notFound(String message, String error) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(CusApiResponse.error(message, error));
}
public static <T> ResponseEntity<CusApiResponse<T>> internalServerError(String message, String error) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(CusApiResponse.error(message, error));
}
}

View File

@ -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());
}
}

View File

@ -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<SecurityContext> 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<SecurityContext> jwkSource) {
return new NimbusJwtEncoder(jwkSource);
}
@Bean
public JwtDecoder jwtDecoder(RSAPublicKey publicKey) {
return NimbusJwtDecoder.withPublicKey(publicKey).build();
}
}

View File

@ -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;
}
}

View File

@ -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")
);
}
}

View File

@ -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;
}
}

View File

@ -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<CusApiResponse<HealthProfileHistoryResponse>> 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<HealthCheckupRaw> 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<HealthProfileHistoryResponse> 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<CusApiResponse<HealthProfileHistoryResponse>> processExistingRawData(
User user, LocalDate birthDate, Long memberSerialNumber, List<HealthCheckupRaw> 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<HealthCheckup> 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<HealthProfileHistoryResponse> 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<CusApiResponse<HealthProfileHistoryResponse>> 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<HealthProfileHistoryResponse> 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<HealthCheckupRaw> 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<HealthCheckupRaw> rawDataList) {
// 같은 raw_id의 원본 Raw 데이터 찾기
Optional<HealthCheckupRaw> 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; // 기본값: 남성
}
}

View File

@ -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;
}
}

View File

@ -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; }
}

View File

@ -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() : "미상";
}
}

View File

@ -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; }
}

View File

@ -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; // 기본값
}
}

View File

@ -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; }
}

Some files were not shown because too many files have changed in this diff Show More