# Conflicts:
#	build.gradle
This commit is contained in:
정유빈 2025-06-12 17:36:43 +09:00
commit de5ba56c95
23 changed files with 326 additions and 214 deletions

View File

@ -31,13 +31,13 @@ import java.util.Map;
@RequiredArgsConstructor @RequiredArgsConstructor
public class RedisConfig { public class RedisConfig {
@Value("${spring.redis.host}") @Value("${spring.data.redis.host}")
private String redisHost; private String redisHost;
@Value("${spring.redis.port}") @Value("${spring.data.redis.port}")
private int redisPort; private int redisPort;
@Value("${spring.redis.password:}") @Value("${spring.data.redis.password:}")
private String redisPassword; private String redisPassword;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;

View File

@ -8,4 +8,8 @@ public class ActionPlanNotFoundException extends AnalyticsException {
public ActionPlanNotFoundException(Long planId) { public ActionPlanNotFoundException(Long planId) {
super("ACTION_PLAN_NOT_FOUND", "실행 계획을 찾을 수 없습니다: " + planId); super("ACTION_PLAN_NOT_FOUND", "실행 계획을 찾을 수 없습니다: " + planId);
} }
public ActionPlanNotFoundException(String message) {
super("ACTION_PLAN_NOT_FOUND", message);
}
} }

View File

@ -1,6 +1,9 @@
package com.ktds.hi.analytics.infra.exception; package com.ktds.hi.analytics.infra.exception;
import com.ktds.hi.common.dto.ErrorResponse; import com.ktds.hi.common.dto.ErrorResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.ConstraintViolationException;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
@ -11,14 +14,13 @@ import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import jakarta.validation.*; import java.util.List;
import java.time.LocalDateTime;
import java.util.stream.Collectors; import java.util.stream.Collectors;
/** /**
* 글로벌 예외 처리 핸들러 * 글로벌 예외 처리 핸들러 (수정 완료)
* 모든 컨트롤러에서 발생하는 예외를 중앙에서 처리 * 모든 컨트롤러에서 발생하는 예외를 중앙에서 처리
* 새로운 ErrorResponse 필드 구조에 맞게 수정
*/ */
@Slf4j @Slf4j
@RestControllerAdvice @RestControllerAdvice
@ -28,16 +30,15 @@ public class GlobalExceptionHandler {
* 분석 서비스 커스텀 예외 처리 * 분석 서비스 커스텀 예외 처리
*/ */
@ExceptionHandler(AnalyticsException.class) @ExceptionHandler(AnalyticsException.class)
public ResponseEntity<ErrorResponse> handleAnalyticsException(AnalyticsException ex) { public ResponseEntity<ErrorResponse<Void>> handleAnalyticsException(
AnalyticsException ex, HttpServletRequest request) {
log.error("Analytics Exception: {}", ex.getMessage(), ex); log.error("Analytics Exception: {}", ex.getMessage(), ex);
ErrorResponse errorResponse = ErrorResponse.builder() ErrorResponse<Void> errorResponse = ErrorResponse.of(
.timestamp(LocalDateTime.now()) ex.getErrorCode(),
.status(HttpStatus.BAD_REQUEST.value()) ex.getMessage(),
.error("Analytics Error") request.getRequestURI()
.message(ex.getMessage()) );
.path("/api/analytics")
.build();
return ResponseEntity.badRequest().body(errorResponse); return ResponseEntity.badRequest().body(errorResponse);
} }
@ -46,16 +47,15 @@ public class GlobalExceptionHandler {
* 매장 정보 없음 예외 처리 * 매장 정보 없음 예외 처리
*/ */
@ExceptionHandler(StoreNotFoundException.class) @ExceptionHandler(StoreNotFoundException.class)
public ResponseEntity<ErrorResponse> handleStoreNotFoundException(StoreNotFoundException ex) { public ResponseEntity<ErrorResponse<Void>> handleStoreNotFoundException(
StoreNotFoundException ex, HttpServletRequest request) {
log.error("Store Not Found: {}", ex.getMessage()); log.error("Store Not Found: {}", ex.getMessage());
ErrorResponse errorResponse = ErrorResponse.builder() ErrorResponse<Void> errorResponse = ErrorResponse.of(
.timestamp(LocalDateTime.now()) "STORE_NOT_FOUND",
.status(HttpStatus.NOT_FOUND.value()) ex.getMessage(),
.error("Store Not Found") request.getRequestURI()
.message(ex.getMessage()) );
.path("/api/analytics")
.build();
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorResponse); return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorResponse);
} }
@ -64,16 +64,15 @@ public class GlobalExceptionHandler {
* 실행 계획 없음 예외 처리 * 실행 계획 없음 예외 처리
*/ */
@ExceptionHandler(ActionPlanNotFoundException.class) @ExceptionHandler(ActionPlanNotFoundException.class)
public ResponseEntity<ErrorResponse> handleActionPlanNotFoundException(ActionPlanNotFoundException ex) { public ResponseEntity<ErrorResponse<Void>> handleActionPlanNotFoundException(
ActionPlanNotFoundException ex, HttpServletRequest request) {
log.error("Action Plan Not Found: {}", ex.getMessage()); log.error("Action Plan Not Found: {}", ex.getMessage());
ErrorResponse errorResponse = ErrorResponse.builder() ErrorResponse<Void> errorResponse = ErrorResponse.of(
.timestamp(LocalDateTime.now()) "ACTION_PLAN_NOT_FOUND",
.status(HttpStatus.NOT_FOUND.value()) ex.getMessage(),
.error("Action Plan Not Found") request.getRequestURI()
.message(ex.getMessage()) );
.path("/api/action-plans")
.build();
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorResponse); return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorResponse);
} }
@ -82,16 +81,15 @@ public class GlobalExceptionHandler {
* AI 서비스 예외 처리 * AI 서비스 예외 처리
*/ */
@ExceptionHandler(AIServiceException.class) @ExceptionHandler(AIServiceException.class)
public ResponseEntity<ErrorResponse> handleAIServiceException(AIServiceException ex) { public ResponseEntity<ErrorResponse<Void>> handleAIServiceException(
AIServiceException ex, HttpServletRequest request) {
log.error("AI Service Exception: {}", ex.getMessage(), ex); log.error("AI Service Exception: {}", ex.getMessage(), ex);
ErrorResponse errorResponse = ErrorResponse.builder() ErrorResponse<Void> errorResponse = ErrorResponse.of(
.timestamp(LocalDateTime.now()) "AI_SERVICE_ERROR",
.status(HttpStatus.SERVICE_UNAVAILABLE.value()) "AI 서비스 연동 중 오류가 발생했습니다.",
.error("AI Service Error") request.getRequestURI()
.message("AI 서비스 연동 중 오류가 발생했습니다.") );
.path("/api/analytics")
.build();
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(errorResponse); return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(errorResponse);
} }
@ -100,16 +98,15 @@ public class GlobalExceptionHandler {
* 외부 서비스 예외 처리 * 외부 서비스 예외 처리
*/ */
@ExceptionHandler(ExternalServiceException.class) @ExceptionHandler(ExternalServiceException.class)
public ResponseEntity<ErrorResponse> handleExternalServiceException(ExternalServiceException ex) { public ResponseEntity<ErrorResponse<Void>> handleExternalServiceException(
ExternalServiceException ex, HttpServletRequest request) {
log.error("External Service Exception: {}", ex.getMessage(), ex); log.error("External Service Exception: {}", ex.getMessage(), ex);
ErrorResponse errorResponse = ErrorResponse.builder() ErrorResponse<Void> errorResponse = ErrorResponse.of(
.timestamp(LocalDateTime.now()) ex.getErrorCode(),
.status(HttpStatus.SERVICE_UNAVAILABLE.value()) "외부 서비스 연동 중 오류가 발생했습니다.",
.error("External Service Error") request.getRequestURI()
.message("외부 서비스 연동 중 오류가 발생했습니다.") );
.path("/api/analytics")
.build();
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(errorResponse); return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(errorResponse);
} }
@ -118,20 +115,25 @@ public class GlobalExceptionHandler {
* 입력 검증 예외 처리 * 입력 검증 예외 처리
*/ */
@ExceptionHandler(MethodArgumentNotValidException.class) @ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidationException(MethodArgumentNotValidException ex) { public ResponseEntity<ErrorResponse<Void>> handleValidationException(
MethodArgumentNotValidException ex, HttpServletRequest request) {
log.error("Validation Exception: {}", ex.getMessage()); log.error("Validation Exception: {}", ex.getMessage());
String errorMessage = ex.getBindingResult().getFieldErrors().stream() List<ErrorResponse.ValidationError> validationErrors = ex.getBindingResult()
.map(FieldError::getDefaultMessage) .getFieldErrors()
.collect(Collectors.joining(", ")); .stream()
.map(error -> ErrorResponse.ValidationError.builder()
.field(error.getField())
.rejectedValue(error.getRejectedValue())
.message(error.getDefaultMessage())
.build())
.collect(Collectors.toList());
ErrorResponse errorResponse = ErrorResponse.builder() ErrorResponse<Void> errorResponse = ErrorResponse.ofValidation(
.timestamp(LocalDateTime.now()) "입력값 검증 실패",
.status(HttpStatus.BAD_REQUEST.value()) request.getRequestURI(),
.error("Validation Error") validationErrors
.message(errorMessage) );
.path("/api/analytics")
.build();
return ResponseEntity.badRequest().body(errorResponse); return ResponseEntity.badRequest().body(errorResponse);
} }
@ -140,20 +142,24 @@ public class GlobalExceptionHandler {
* 바인딩 예외 처리 * 바인딩 예외 처리
*/ */
@ExceptionHandler(BindException.class) @ExceptionHandler(BindException.class)
public ResponseEntity<ErrorResponse> handleBindException(BindException ex) { public ResponseEntity<ErrorResponse<Void>> handleBindException(
BindException ex, HttpServletRequest request) {
log.error("Bind Exception: {}", ex.getMessage()); log.error("Bind Exception: {}", ex.getMessage());
String errorMessage = ex.getFieldErrors().stream() List<ErrorResponse.ValidationError> validationErrors = ex.getFieldErrors()
.map(FieldError::getDefaultMessage) .stream()
.collect(Collectors.joining(", ")); .map(error -> ErrorResponse.ValidationError.builder()
.field(error.getField())
.rejectedValue(error.getRejectedValue())
.message(error.getDefaultMessage())
.build())
.collect(Collectors.toList());
ErrorResponse errorResponse = ErrorResponse.builder() ErrorResponse<Void> errorResponse = ErrorResponse.ofValidation(
.timestamp(LocalDateTime.now()) "바인딩 실패",
.status(HttpStatus.BAD_REQUEST.value()) request.getRequestURI(),
.error("Binding Error") validationErrors
.message(errorMessage) );
.path("/api/analytics")
.build();
return ResponseEntity.badRequest().body(errorResponse); return ResponseEntity.badRequest().body(errorResponse);
} }
@ -162,20 +168,19 @@ public class GlobalExceptionHandler {
* 제약 조건 위반 예외 처리 * 제약 조건 위반 예외 처리
*/ */
@ExceptionHandler(ConstraintViolationException.class) @ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<ErrorResponse> handleConstraintViolationException(ConstraintViolationException ex) { public ResponseEntity<ErrorResponse<Void>> handleConstraintViolationException(
ConstraintViolationException ex, HttpServletRequest request) {
log.error("Constraint Violation Exception: {}", ex.getMessage()); log.error("Constraint Violation Exception: {}", ex.getMessage());
String errorMessage = ex.getConstraintViolations().stream() String errorMessage = ex.getConstraintViolations().stream()
.map(ConstraintViolation::getMessage) .map(ConstraintViolation::getMessage)
.collect(Collectors.joining(", ")); .collect(Collectors.joining(", "));
ErrorResponse errorResponse = ErrorResponse.builder() ErrorResponse<Void> errorResponse = ErrorResponse.of(
.timestamp(LocalDateTime.now()) "CONSTRAINT_VIOLATION",
.status(HttpStatus.BAD_REQUEST.value()) errorMessage,
.error("Constraint Violation") request.getRequestURI()
.message(errorMessage) );
.path("/api/analytics")
.build();
return ResponseEntity.badRequest().body(errorResponse); return ResponseEntity.badRequest().body(errorResponse);
} }
@ -184,16 +189,15 @@ public class GlobalExceptionHandler {
* 타입 불일치 예외 처리 * 타입 불일치 예외 처리
*/ */
@ExceptionHandler(MethodArgumentTypeMismatchException.class) @ExceptionHandler(MethodArgumentTypeMismatchException.class)
public ResponseEntity<ErrorResponse> handleTypeMismatchException(MethodArgumentTypeMismatchException ex) { public ResponseEntity<ErrorResponse<Void>> handleTypeMismatchException(
MethodArgumentTypeMismatchException ex, HttpServletRequest request) {
log.error("Type Mismatch Exception: {}", ex.getMessage()); log.error("Type Mismatch Exception: {}", ex.getMessage());
ErrorResponse errorResponse = ErrorResponse.builder() ErrorResponse<Void> errorResponse = ErrorResponse.of(
.timestamp(LocalDateTime.now()) "TYPE_MISMATCH",
.status(HttpStatus.BAD_REQUEST.value()) "잘못된 파라미터 타입입니다: " + ex.getName(),
.error("Type Mismatch") request.getRequestURI()
.message("잘못된 파라미터 타입입니다: " + ex.getName()) );
.path("/api/analytics")
.build();
return ResponseEntity.badRequest().body(errorResponse); return ResponseEntity.badRequest().body(errorResponse);
} }
@ -202,16 +206,15 @@ public class GlobalExceptionHandler {
* 일반적인 RuntimeException 처리 * 일반적인 RuntimeException 처리
*/ */
@ExceptionHandler(RuntimeException.class) @ExceptionHandler(RuntimeException.class)
public ResponseEntity<ErrorResponse> handleRuntimeException(RuntimeException ex) { public ResponseEntity<ErrorResponse<Void>> handleRuntimeException(
RuntimeException ex, HttpServletRequest request) {
log.error("Runtime Exception: {}", ex.getMessage(), ex); log.error("Runtime Exception: {}", ex.getMessage(), ex);
ErrorResponse errorResponse = ErrorResponse.builder() ErrorResponse<Void> errorResponse = ErrorResponse.of(
.timestamp(LocalDateTime.now()) "RUNTIME_ERROR",
.status(HttpStatus.INTERNAL_SERVER_ERROR.value()) "내부 서버 오류가 발생했습니다.",
.error("Internal Server Error") request.getRequestURI()
.message("내부 서버 오류가 발생했습니다.") );
.path("/api/analytics")
.build();
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse);
} }
@ -220,16 +223,13 @@ public class GlobalExceptionHandler {
* 모든 예외의 최종 처리 * 모든 예외의 최종 처리
*/ */
@ExceptionHandler(Exception.class) @ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleAllExceptions(Exception ex) { public ResponseEntity<ErrorResponse<Void>> handleAllExceptions(
Exception ex, HttpServletRequest request) {
log.error("Unexpected Exception: {}", ex.getMessage(), ex); log.error("Unexpected Exception: {}", ex.getMessage(), ex);
ErrorResponse errorResponse = ErrorResponse.builder() ErrorResponse<Void> errorResponse = ErrorResponse.ofInternalError(
.timestamp(LocalDateTime.now()) "예상치 못한 오류가 발생했습니다."
.status(HttpStatus.INTERNAL_SERVER_ERROR.value()) );
.error("Unexpected Error")
.message("예상치 못한 오류가 발생했습니다.")
.path("/api/analytics")
.build();
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse);
} }

View File

@ -8,4 +8,8 @@ public class StoreNotFoundException extends AnalyticsException {
public StoreNotFoundException(Long storeId) { public StoreNotFoundException(Long storeId) {
super("STORE_NOT_FOUND", "매장을 찾을 수 없습니다: " + storeId); super("STORE_NOT_FOUND", "매장을 찾을 수 없습니다: " + storeId);
} }
public StoreNotFoundException(String message) {
super("STORE_NOT_FOUND", message);
}
} }

View File

@ -1,9 +1,12 @@
package com.ktds.hi.analytics.infra.gateway; package com.ktds.hi.analytics.infra.gateway;
import static com.azure.ai.textanalytics.models.TextSentiment.*;
import com.azure.ai.textanalytics.TextAnalyticsClient; import com.azure.ai.textanalytics.TextAnalyticsClient;
import com.azure.ai.textanalytics.TextAnalyticsClientBuilder; import com.azure.ai.textanalytics.TextAnalyticsClientBuilder;
import com.azure.ai.textanalytics.models.AnalyzeSentimentResult; import com.azure.ai.textanalytics.models.AnalyzeSentimentResult;
import com.azure.ai.textanalytics.models.DocumentSentiment; import com.azure.ai.textanalytics.models.DocumentSentiment;
import com.azure.ai.textanalytics.models.TextSentiment;
import com.azure.core.credential.AzureKeyCredential; import com.azure.core.credential.AzureKeyCredential;
import com.ktds.hi.analytics.biz.domain.AiFeedback; import com.ktds.hi.analytics.biz.domain.AiFeedback;
import com.ktds.hi.analytics.biz.domain.SentimentType; import com.ktds.hi.analytics.biz.domain.SentimentType;
@ -97,15 +100,18 @@ public class AIServiceAdapter implements AIServicePort {
@Override @Override
public SentimentType analyzeSentiment(String content) { public SentimentType analyzeSentiment(String content) {
try { try {
AnalyzeSentimentResult result = textAnalyticsClient.analyzeSentiment(content); DocumentSentiment documentSentiment = textAnalyticsClient.analyzeSentiment(content);
DocumentSentiment sentiment = result.getDocumentSentiment(); TextSentiment sentiment = documentSentiment.getSentiment();
switch (sentiment) { if (sentiment == TextSentiment.POSITIVE) {
case POSITIVE:
return SentimentType.POSITIVE; return SentimentType.POSITIVE;
case NEGATIVE: } else if (sentiment == TextSentiment.NEGATIVE) {
return SentimentType.NEGATIVE; return SentimentType.NEGATIVE;
default: } else if (sentiment == TextSentiment.NEUTRAL) {
return SentimentType.NEUTRAL;
} else if (sentiment == TextSentiment.MIXED) {
return SentimentType.NEUTRAL; // MIXED는 NEUTRAL로 처리
} else {
return SentimentType.NEUTRAL; return SentimentType.NEUTRAL;
} }

View File

@ -31,7 +31,7 @@ import java.util.concurrent.Executors;
@Slf4j @Slf4j
@Component @Component
@RequiredArgsConstructor @RequiredArgsConstructor
public class EventHubAdapter implements EventPort { public class EventHubAdapter {
@Qualifier("reviewEventConsumer") @Qualifier("reviewEventConsumer")
private final EventHubConsumerClient reviewEventConsumer; private final EventHubConsumerClient reviewEventConsumer;
@ -61,7 +61,6 @@ public class EventHubAdapter implements EventPort {
aiAnalysisEventProducer.close(); aiAnalysisEventProducer.close();
} }
@Override
public void publishAnalysisCompletedEvent(Long storeId, AnalysisType analysisType) { public void publishAnalysisCompletedEvent(Long storeId, AnalysisType analysisType) {
try { try {
Map<String, Object> eventData = new HashMap<>(); Map<String, Object> eventData = new HashMap<>();
@ -84,7 +83,7 @@ public class EventHubAdapter implements EventPort {
} }
} }
@Override
public void publishActionPlanCreatedEvent(ActionPlan actionPlan) { public void publishActionPlanCreatedEvent(ActionPlan actionPlan) {
try { try {
Map<String, Object> eventData = new HashMap<>(); Map<String, Object> eventData = new HashMap<>();

View File

@ -26,7 +26,13 @@ import java.time.LocalDateTime;
* 점주의 개선 실행 계획을 저장 * 점주의 개선 실행 계획을 저장
*/ */
@Entity @Entity
@Table(name = "action_plan") @Table(name = "action_plan",
indexes = {
@Index(name = "idx_action_plan_store_id", columnList = "store_id"),
@Index(name = "idx_action_plan_user_id", columnList = "user_id"),
@Index(name = "idx_action_plan_status", columnList = "status"),
@Index(name = "idx_action_plan_created_at", columnList = "created_at")
})
@Getter @Getter
@Builder @Builder
@NoArgsConstructor @NoArgsConstructor
@ -73,10 +79,4 @@ public class ActionPlanEntity {
@LastModifiedDate @LastModifiedDate
@Column(name = "updated_at") @Column(name = "updated_at")
private LocalDateTime updatedAt; private LocalDateTime updatedAt;
@Index(name = "idx_action_plan_store_id", columnList = "store_id")
@Index(name = "idx_action_plan_user_id", columnList = "user_id")
@Index(name = "idx_action_plan_status", columnList = "status")
public static class Indexes {
}
} }

View File

@ -16,7 +16,13 @@ import java.time.LocalDateTime;
* AI가 생성한 피드백 정보를 저장 * AI가 생성한 피드백 정보를 저장
*/ */
@Entity @Entity
@Table(name = "ai_feedback") @Table(name = "ai_feedback",
indexes = {
@Index(name = "idx_ai_feedback_store_id", columnList = "store_id"),
@Index(name = "idx_ai_feedback_generated_at", columnList = "generated_at"),
@Index(name = "idx_ai_feedback_created_at", columnList = "created_at"),
@Index(name = "idx_ai_feedback_confidence_score", columnList = "confidence_score")
})
@Getter @Getter
@Builder @Builder
@NoArgsConstructor @NoArgsConstructor
@ -60,7 +66,4 @@ public class AiFeedbackEntity {
@Column(name = "updated_at") @Column(name = "updated_at")
private LocalDateTime updatedAt; private LocalDateTime updatedAt;
@Index(name = "idx_ai_feedback_store_id", columnList = "store_id")
public static class Indexes {
}
} }

View File

@ -16,7 +16,13 @@ import java.time.LocalDateTime;
* 매장의 분석 정보를 저장 * 매장의 분석 정보를 저장
*/ */
@Entity @Entity
@Table(name = "analytics") @Table(name = "analytics",
indexes = {
@Index(name = "idx_analytics_store_id", columnList = "store_id"),
@Index(name = "idx_analytics_last_analysis_date", columnList = "last_analysis_date"),
@Index(name = "idx_analytics_created_at", columnList = "created_at"),
@Index(name = "idx_analytics_average_rating", columnList = "average_rating")
})
@Getter @Getter
@Builder @Builder
@NoArgsConstructor @NoArgsConstructor
@ -57,7 +63,4 @@ public class AnalyticsEntity {
@Column(name = "updated_at") @Column(name = "updated_at")
private LocalDateTime updatedAt; private LocalDateTime updatedAt;
@Index(name = "idx_analytics_store_id", columnList = "store_id")
public static class Indexes {
}
} }

View File

@ -13,7 +13,7 @@ spring:
jpa: jpa:
hibernate: hibernate:
ddl-auto: ${JPA_DDL_AUTO:validate} ddl-auto: ${JPA_DDL_AUTO:create}
show-sql: ${JPA_SHOW_SQL:false} show-sql: ${JPA_SHOW_SQL:false}
properties: properties:
hibernate: hibernate:
@ -44,6 +44,13 @@ ai-api:
# api-key: ${CLAUDE_API_KEY:} # api-key: ${CLAUDE_API_KEY:}
# base-url: https://api.anthropic.com # base-url: https://api.anthropic.com
# 외부 서비스 설정
external:
services:
review: ${EXTERNAL_SERVICES_REVIEW:http://localhost:8082}
store: ${EXTERNAL_SERVICES_STORE:http://localhost:8081}
member: ${EXTERNAL_SERVICES_MEMBER:http://localhost:8080}
springdoc: springdoc:
api-docs: api-docs:
path: /api-docs path: /api-docs
@ -55,3 +62,24 @@ management:
web: web:
exposure: exposure:
include: health,info,metrics include: health,info,metrics
# AI 서비스 설정
ai:
azure:
cognitive:
endpoint: ${AI_AZURE_COGNITIVE_ENDPOINT:https://your-cognitive-service.cognitiveservices.azure.com}
key: ${AI_AZURE_COGNITIVE_KEY:your-cognitive-service-key}
openai:
api-key: ${AI_OPENAI_API_KEY:your-openai-api-key}
# Azure Event Hub 설정
azure:
eventhub:
connection-string: ${AZURE_EVENTHUB_CONNECTION_STRING:Endpoint=sb://your-eventhub.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=your-key}
consumer-group: ${AZURE_EVENTHUB_CONSUMER_GROUP:analytics-consumer}
event-hubs:
review-events: ${AZURE_EVENTHUB_REVIEW_EVENTS:review-events}
ai-analysis-events: ${AZURE_EVENTHUB_AI_ANALYSIS_EVENTS:ai-analysis-events}
storage:
connection-string: ${AZURE_STORAGE_CONNECTION_STRING:DefaultEndpointsProtocol=https;AccountName=yourstorageaccount;AccountKey=your-storage-key;EndpointSuffix=core.windows.net}
container-name: ${AZURE_STORAGE_CONTAINER_NAME:eventhub-checkpoints}

View File

@ -1,7 +1,7 @@
plugins { plugins {
id 'java' id 'java'
id 'org.springframework.boot' version '3.4.0' apply false id 'org.springframework.boot' version '3.4.0' apply false
id 'io.spring.dependency-management' version '1.1.6' apply false id 'io.spring.dependency-management' version '1.1.4' apply false
} }
allprojects { allprojects {
@ -19,35 +19,48 @@ subprojects {
apply plugin: 'io.spring.dependency-management' apply plugin: 'io.spring.dependency-management'
java { java {
sourceCompatibility = '21' toolchain {
} languageVersion = JavaLanguageVersion.of(21)
configurations {
compileOnly {
extendsFrom annotationProcessor
} }
} }
//
dependencies { dependencies {
// Lombok ( ) //
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.springframework.boot:spring-boot-starter-actuator'
// Swagger
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0'
// JWT
implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3'
// Database
runtimeOnly 'org.postgresql:postgresql'
// Lombok
compileOnly 'org.projectlombok:lombok' compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok'
// ( ) // MapStruct
implementation 'org.mapstruct:mapstruct:1.5.5.Final'
annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.5.Final'
// Test
testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher' testImplementation 'org.springframework.security:spring-security-test'
testImplementation 'org.testcontainers:junit-jupiter'
testImplementation 'org.testcontainers:postgresql'
} }
//
tasks.withType(JavaCompile) {
options.encoding = 'UTF-8'
options.compilerArgs += ['-parameters']
}
//
tasks.named('test') { tasks.named('test') {
useJUnitPlatform() useJUnitPlatform()
systemProperty 'spring.profiles.active', 'test'
} }
} }

View File

@ -5,11 +5,14 @@ plugins {
description = 'Common utilities and shared components' description = 'Common utilities and shared components'
dependencies { dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
// Spring Boot Starters // Spring Boot Starters
api 'org.springframework.boot:spring-boot-starter-web' api 'org.springframework.boot:spring-boot-starter-web'
api 'org.springframework.boot:spring-boot-starter-data-jpa' api 'org.springframework.boot:spring-boot-starter-data-jpa'
api 'org.springframework.boot:spring-boot-starter-validation' api 'org.springframework.boot:spring-boot-starter-validation'
api 'org.springframework.boot:spring-boot-starter-security' api 'org.springframework.boot:spring-boot-starter-security'
api 'org.springframework.data:spring-data-commons'
// AOP (AspectJ) - // AOP (AspectJ) -
api 'org.springframework.boot:spring-boot-starter-aop' api 'org.springframework.boot:spring-boot-starter-aop'
@ -36,6 +39,10 @@ dependencies {
compileOnly 'org.projectlombok:lombok' compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok'
// Database
runtimeOnly 'org.postgresql:postgresql'
testRuntimeOnly 'com.h2database:h2'
// //
testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test' testImplementation 'org.springframework.security:spring-security-test'

View File

@ -11,7 +11,10 @@ import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
*/ */
@Configuration @Configuration
@ComponentScan(basePackages = "com.ktds.hi.common") @ComponentScan(basePackages = "com.ktds.hi.common")
@EntityScan(basePackages = "com.ktds.hi.common.entity") @EntityScan(basePackages = {
"com.ktds.hi.common.entity",
"com.ktds.hi.common.audit"
})
@EnableJpaRepositories(basePackages = "com.ktds.hi.common.repository") @EnableJpaRepositories(basePackages = "com.ktds.hi.common.repository")
public class CommonModuleConfiguration { public class CommonModuleConfiguration {
// 설정 클래스는 어노테이션만으로도 충분 // 설정 클래스는 어노테이션만으로도 충분

View File

@ -1,5 +1,8 @@
package com.ktds.hi.common.audit; package com.ktds.hi.common.audit;
import jakarta.persistence.Entity;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.Id;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;
import lombok.Getter; import lombok.Getter;
@ -7,15 +10,20 @@ import lombok.NoArgsConstructor;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
/** /**
* 감사 로그 엔티티 * 감사 로그 엔티티
*/ */
@Getter @Getter
@Entity
@Builder @Builder
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@EntityListeners(AuditingEntityListener.class)
public class AuditLog { public class AuditLog {
@Id
private Long id; private Long id;
private String entityType; private String entityType;
private String entityId; private String entityId;
@ -26,4 +34,8 @@ public class AuditLog {
private String userAgent; private String userAgent;
private String ipAddress; private String ipAddress;
private LocalDateTime timestamp; private LocalDateTime timestamp;
private LocalDateTime createdAt;
} }

View File

@ -1,11 +0,0 @@
package com.ktds.hi.common.audit;
/**
* 감사 로그 리포지토리 인터페이스
*/
public interface AuditLogRepository {
void save(AuditLog auditLog);
AuditLog findById(Long id);
}

View File

@ -1,5 +1,6 @@
package com.ktds.hi.common.audit; package com.ktds.hi.common.audit;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.data.domain.AuditorAware; import org.springframework.data.domain.AuditorAware;
import org.springframework.security.core.Authentication; import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.context.SecurityContextHolder;
@ -11,6 +12,7 @@ import java.util.Optional;
* JPA Auditing을 위한 사용자 정보 제공자 * JPA Auditing을 위한 사용자 정보 제공자
*/ */
@Component @Component
@ConditionalOnClass(AuditorAware.class) // 👈 어노테이션 추가
public class CustomAuditorAware implements AuditorAware<String> { public class CustomAuditorAware implements AuditorAware<String> {
@Override @Override

View File

@ -3,6 +3,7 @@ package com.ktds.hi.member.config;
import com.ktds.hi.member.service.JwtTokenProvider; import com.ktds.hi.member.service.JwtTokenProvider;
import com.ktds.hi.member.service.AuthService; import com.ktds.hi.member.service.AuthService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.AuthenticationManager;
@ -24,6 +25,7 @@ import org.springframework.security.web.authentication.UsernamePasswordAuthentic
@RequiredArgsConstructor @RequiredArgsConstructor
public class SecurityConfig { public class SecurityConfig {
@Qualifier("memberJwtTokenProvider")
private final JwtTokenProvider jwtTokenProvider; private final JwtTokenProvider jwtTokenProvider;
private final AuthService authService; private final AuthService authService;

View File

@ -6,6 +6,7 @@ import com.ktds.hi.member.repository.jpa.MemberRepository;
import com.ktds.hi.common.exception.BusinessException; import com.ktds.hi.common.exception.BusinessException;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@ -23,6 +24,7 @@ public class AuthServiceImpl implements AuthService {
private final MemberRepository memberRepository; private final MemberRepository memberRepository;
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
@Qualifier("memberJwtTokenProvider")
private final JwtTokenProvider jwtTokenProvider; private final JwtTokenProvider jwtTokenProvider;
private final SmsService smsService; private final SmsService smsService;
private final RedisTemplate<String, String> redisTemplate; private final RedisTemplate<String, String> redisTemplate;

View File

@ -17,7 +17,7 @@ import java.util.Date;
* JWT 토큰 프로바이더 클래스 * JWT 토큰 프로바이더 클래스
* JWT 토큰 생성, 검증, 파싱 기능을 제공 * JWT 토큰 생성, 검증, 파싱 기능을 제공
*/ */
@Component @Component("memberJwtTokenProvider") // 기존: @Component
@Slf4j @Slf4j
public class JwtTokenProvider { public class JwtTokenProvider {

View File

@ -2,5 +2,7 @@ dependencies {
implementation project(':common') implementation project(':common')
// External API Integration // External API Integration
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.springframework.boot:spring-boot-starter-webflux' implementation 'org.springframework.boot:spring-boot-starter-webflux'
} }

View File

@ -0,0 +1,33 @@
package com.ktds.hi.store;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import org.springframework.web.client.RestTemplate;
/**
* 추천 서비스 메인 애플리케이션 클래스
* 가게 추천, 취향 분석 기능을 제공
*
* @author 하이오더 개발팀
* @version 1.0.0
*/
@SpringBootApplication(scanBasePackages = {
"com.ktds.hi.store",
"com.ktds.hi.common"
})
@EnableJpaAuditing
public class StoreApplication {
public static void main(String[] args) {
SpringApplication.run(StoreApplication.class, args);
}
// 👈 부분만 추가
@Bean
public RestTemplate restTemplate() {
return new RestTemplate();
}
}

View File

@ -13,7 +13,7 @@ spring:
jpa: jpa:
hibernate: hibernate:
ddl-auto: ${JPA_DDL_AUTO:validate} ddl-auto: ${JPA_DDL_AUTO:create}
show-sql: ${JPA_SHOW_SQL:false} show-sql: ${JPA_SHOW_SQL:false}
properties: properties:
hibernate: hibernate: