feat : initial commit

This commit is contained in:
2025-06-20 05:42:24 +00:00
commit 409d7abdc6
245 changed files with 17069 additions and 0 deletions
+8
View 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);
}
}
@@ -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();
}
}