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