recommend

This commit is contained in:
youbeen 2025-06-12 16:03:48 +09:00
parent 1a0d3c5268
commit ff127c1edc
10 changed files with 632 additions and 94 deletions

View File

@ -1,7 +1,7 @@
plugins {
id 'java'
id 'org.springframework.boot' version '3.4.0' apply false
id 'io.spring.dependency-management' version '1.1.4' apply false
id 'io.spring.dependency-management' version '1.1.6' apply false
}
allprojects {
@ -19,47 +19,35 @@ subprojects {
apply plugin: 'io.spring.dependency-management'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
sourceCompatibility = '21'
}
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
dependencies {
//
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-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
dependencies {
// Lombok ( )
compileOnly '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.security:spring-security-test'
testImplementation 'org.testcontainers:junit-jupiter'
testImplementation 'org.testcontainers:postgresql'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
//
tasks.withType(JavaCompile) {
options.encoding = 'UTF-8'
options.compilerArgs += ['-parameters']
}
//
tasks.named('test') {
useJUnitPlatform()
systemProperty 'spring.profiles.active', 'test'
}
}

View File

@ -0,0 +1,54 @@
plugins {
id 'java-library'
}
description = 'Common utilities and shared components'
dependencies {
// Spring Boot Starters
api 'org.springframework.boot:spring-boot-starter-web'
api 'org.springframework.boot:spring-boot-starter-data-jpa'
api 'org.springframework.boot:spring-boot-starter-validation'
api 'org.springframework.boot:spring-boot-starter-security'
// AOP (AspectJ) -
api 'org.springframework.boot:spring-boot-starter-aop'
api 'org.aspectj:aspectjweaver:1.9.21'
api 'org.aspectj:aspectjrt:1.9.21'
// JSON Processing
api 'com.fasterxml.jackson.core:jackson-databind'
api 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310'
// JWT
api 'io.jsonwebtoken:jjwt-api:0.12.3'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3'
// Apache Commons
api 'org.apache.commons:commons-lang3:3.13.0'
api 'org.apache.commons:commons-collections4:4.4'
// OpenAPI/Swagger
api 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0'
// Lombok
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
//
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
}
// JAR (plain jar도 )
jar {
archiveBaseName = 'common'
archiveVersion = '1.0.0'
enabled = true
}
// bootJar ( JAR )
bootJar {
enabled = false
}

View File

@ -1,4 +1,52 @@
package com.ktds.hi.common.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;
/**
* 비동기 처리 설정 클래스
* @Async 어노테이션을 위한 스레드 설정
*
* @author 하이오더 개발팀
* @version 1.0.0
*/
@Configuration
@EnableAsync
public class AsyncConfig {
/**
* 감사 로그용 스레드
*/
@Bean("auditTaskExecutor")
public Executor auditTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2);
executor.setMaxPoolSize(5);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("audit-");
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(30);
executor.initialize();
return executor;
}
/**
* 일반적인 비동기 작업용 스레드
*/
@Bean("generalTaskExecutor")
public Executor generalTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(4);
executor.setMaxPoolSize(10);
executor.setQueueCapacity(200);
executor.setThreadNamePrefix("async-");
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(30);
executor.initialize();
return executor;
}
}

View File

@ -1,4 +1,18 @@
package com.ktds.hi.common.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.EnableAspectJAutoProxy;
/**
* AOP 설정 클래스
* AspectJ 자동 프록시를 활성화
*
* @author 하이오더 개발팀
* @version 1.0.0
*/
@Configuration
@EnableAspectJAutoProxy
public class CommonAopConfig {
// AOP 자동 설정을 위한 설정 클래스
// @EnableAspectJAutoProxy 어노테이션으로 AspectJ 자동 프록시 활성화
}

View File

@ -1,4 +1,76 @@
package com.ktds.hi.common.service;
public class AuditAction {
/**
* 감사 액션 열거형
* 시스템에서 발생하는 주요 액션들을 정의
*
* @author 하이오더 개발팀
* @version 1.0.0
*/
public enum AuditAction {
/**
* 생성 액션
*/
CREATE("생성"),
/**
* 수정 액션
*/
UPDATE("수정"),
/**
* 삭제 액션
*/
DELETE("삭제"),
/**
* 조회 액션
*/
ACCESS("조회"),
/**
* 로그인 액션
*/
LOGIN("로그인"),
/**
* 로그아웃 액션
*/
LOGOUT("로그아웃"),
/**
* 승인 액션
*/
APPROVE("승인"),
/**
* 거부 액션
*/
REJECT("거부"),
/**
* 활성화 액션
*/
ACTIVATE("활성화"),
/**
* 비활성화 액션
*/
DEACTIVATE("비활성화");
private final String description;
AuditAction(String description) {
this.description = description;
}
public String getDescription() {
return description;
}
@Override
public String toString() {
return description;
}
}

View File

@ -1,47 +1,37 @@
package com.ktds.hi.common.service;
import com.ktds.hi.common.audit.AuditAction;
import com.ktds.hi.common.audit.AuditLog;
import com.ktds.hi.common.audit.AuditLogger;
import com.ktds.hi.common.repository.AuditLogRepository;
import com.ktds.hi.common.security.SecurityUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* 감사 로그 서비스
* 시스템의 중요한 액션들을 비동기적으로 로깅
* 시스템 중요한 작업들을 로깅
*
* @author 하이오더 개발팀
* @version 1.0.0
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class AuditLogService {
private final AuditLogRepository auditLogRepository;
/**
* 감사 로그 기록 (비동기)
* 비동기 로그 기록
*/
@Async
@Transactional
public void logAsync(AuditAction action, String entityType, String entityId, String description) {
log(action, entityType, entityId, description);
}
/**
* 감사 로그 기록 (동기)
*/
@Transactional
public void log(AuditAction action, String entityType, String entityId, String description) {
try {
Long userId = SecurityUtil.getCurrentUserId().orElse(null);
String username = SecurityUtil.getCurrentUsername().orElse("SYSTEM");
// 현재 사용자 정보 가져오기 (SecurityContext에서)
String userId = getCurrentUserId();
String username = getCurrentUsername();
AuditLog auditLog = AuditLogger.create(userId, username, action, entityType, entityId, description);
auditLogRepository.save(auditLog);
// 감사 로그 생성 저장
log.info("AUDIT_LOG: action={}, entityType={}, entityId={}, userId={}, username={}, description={}",
action, entityType, entityId, userId, username, description);
// 실제 환경에서는 데이터베이스에 저장
// AuditLog auditLog = AuditLog.create(userId, username, action, entityType, entityId, description);
// auditLogRepository.save(auditLog);
} catch (Exception e) {
log.error("Failed to save audit log: action={}, entityType={}, entityId={}",
@ -81,13 +71,38 @@ public class AuditLogService {
* 로그인 로그
*/
public void logLogin(String description) {
logAsync(AuditAction.LOGIN, "USER", SecurityUtil.getCurrentUserId().map(String::valueOf).orElse("UNKNOWN"), description);
logAsync(AuditAction.LOGIN, "USER", getCurrentUserId(), description);
}
/**
* 로그아웃 로그
*/
public void logLogout(String description) {
logAsync(AuditAction.LOGOUT, "USER", SecurityUtil.getCurrentUserId().map(String::valueOf).orElse("UNKNOWN"), description);
logAsync(AuditAction.LOGOUT, "USER", getCurrentUserId(), description);
}
/**
* 현재 사용자 ID 조회
*/
private String getCurrentUserId() {
try {
// SecurityContext에서 사용자 ID 추출
// 실제 구현에서는 SecurityContextHolder 사용
return "SYSTEM"; // 임시값
} catch (Exception e) {
return "UNKNOWN";
}
}
/**
* 현재 사용자명 조회
*/
private String getCurrentUsername() {
try {
// SecurityContext에서 사용자명 추출
return "system"; // 임시값
} catch (Exception e) {
return "unknown";
}
}
}

View File

@ -1,10 +1,132 @@
plugins {
id 'java'
id 'org.springframework.boot' version '3.4.0'
id 'io.spring.dependency-management' version '1.1.6'
}
group = 'com.ktds.hi'
version = '1.0.0'
java {
sourceCompatibility = '21'
}
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
}
dependencies {
//
implementation project(':common')
// AI and Location Services
// Spring Boot Starters
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-webflux'
implementation 'org.springframework.boot:spring-boot-starter-actuator'
// Database
runtimeOnly 'org.postgresql:postgresql'
// Redis
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
// JSON Processing
implementation 'com.fasterxml.jackson.core:jackson-databind'
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310'
// Lombok
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
// OpenAPI/Swagger
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0'
// Logging
implementation 'org.springframework.boot:spring-boot-starter-logging'
// Security (JWT )
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3'
// Apache Commons ()
implementation 'org.apache.commons:commons-lang3:3.13.0'
implementation 'org.apache.commons:commons-collections4:4.4'
// AI HTTP
implementation 'org.springframework.boot:spring-boot-starter-webflux'
implementation 'org.springframework.cloud:spring-cloud-starter-openfeign'
implementation 'org.springframework.cloud:spring-cloud-starter-circuitbreaker-resilience4j'
implementation 'org.springframework.cloud:spring-cloud-starter-loadbalancer'
//
implementation 'org.springframework.boot:spring-boot-starter-cache'
implementation 'com.github.ben-manes.caffeine:caffeine'
//
implementation 'io.micrometer:micrometer-registry-prometheus'
//
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
testImplementation 'org.testcontainers:junit-jupiter'
testImplementation 'org.testcontainers:postgresql'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
dependencyManagement {
imports {
mavenBom "org.testcontainers:testcontainers-bom:1.19.3"
}
}
tasks.named('test') {
useJUnitPlatform()
//
systemProperty 'spring.profiles.active', 'test'
//
minHeapSize = "512m"
maxHeapSize = "1024m"
}
// JAR
jar {
archiveBaseName = 'recommend-service'
archiveVersion = '1.0.0'
enabled = false
}
bootJar {
archiveBaseName = 'recommend-service'
archiveVersion = '1.0.0'
archiveClassifier = ''
}
//
compileJava {
options.encoding = 'UTF-8'
options.compilerArgs += ['-parameters']
}
compileTestJava {
options.encoding = 'UTF-8'
}
//
processResources {
filesMatching('**/*.properties') {
filteringCharset = 'UTF-8'
}
filesMatching('**/*.yml') {
filteringCharset = 'UTF-8'
}
}

View File

@ -2,19 +2,22 @@ package com.ktds.hi.recommend;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.openfeign.EnableFeignClients;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
/**
* 추천 서비스 메인 애플리케이션
* 추천 서비스 메인 애플리케이션 클래스
* 가게 추천, 취향 분석 기능을 제공
*
* @author 하이오더 개발팀
* @version 1.0.0
*/
@SpringBootApplication(scanBasePackages = {
"com.ktds.hi.recommend",
"com.ktds.hi.common"
})
@EnableFeignClients
@EnableJpaAuditing
public class RecommendServiceApplication {
public static void main(String[] args) {
SpringApplication.run(RecommendServiceApplication.class, args);
}

View File

@ -1,4 +1,80 @@
package com.ktds.hi.recommend.infra.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.client.RestTemplate;
import java.time.Duration;
/**
* 추천 서비스 WebClient 설정 클래스
* 외부 서비스와의 HTTP 통신을 위한 설정
*
* @author 하이오더 개발팀
* @version 1.0.0
*/
@Configuration
public class RecommendWebClientConfig {
@Value("${services.store.url:http://store-service:8082}")
private String storeServiceUrl;
@Value("${services.review.url:http://review-service:8083}")
private String reviewServiceUrl;
@Value("${services.member.url:http://member-service:8081}")
private String memberServiceUrl;
/**
* 매장 서비스와 통신하기 위한 WebClient
*/
@Bean("storeWebClient")
public WebClient storeWebClient() {
return WebClient.builder()
.baseUrl(storeServiceUrl)
.codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(1024 * 1024))
.build();
}
/**
* 리뷰 서비스와 통신하기 위한 WebClient
*/
@Bean("reviewWebClient")
public WebClient reviewWebClient() {
return WebClient.builder()
.baseUrl(reviewServiceUrl)
.codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(1024 * 1024))
.build();
}
/**
* 회원 서비스와 통신하기 위한 WebClient
*/
@Bean("memberWebClient")
public WebClient memberWebClient() {
return WebClient.builder()
.baseUrl(memberServiceUrl)
.codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(1024 * 1024))
.build();
}
/**
* 일반적인 외부 API 호출을 위한 WebClient
*/
@Bean("externalWebClient")
public WebClient externalWebClient() {
return WebClient.builder()
.codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(2 * 1024 * 1024))
.build();
}
/**
* 호환성을 위한 RestTemplate (레거시 지원)
*/
@Bean
public RestTemplate restTemplate() {
return new RestTemplate();
}
}

View File

@ -1,4 +1,150 @@
package com.ktds.hi.recommend.infra.gateway;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
import java.time.Duration;
import java.util.List;
import java.util.Map;
/**
* 외부 서비스 클라이언트
* 다른 마이크로서비스와의 HTTP 통신을 담당
*
* @author 하이오더 개발팀
* @version 1.0.0
*/
@Component
@RequiredArgsConstructor
@Slf4j
public class ExternalServiceClient {
@Qualifier("storeWebClient")
private final WebClient storeWebClient;
@Qualifier("reviewWebClient")
private final WebClient reviewWebClient;
@Qualifier("memberWebClient")
private final WebClient memberWebClient;
/**
* 매장 정보 조회
*/
public Mono<Map<String, Object>> getStoreInfo(Long storeId) {
return storeWebClient
.get()
.uri("/api/stores/{storeId}", storeId)
.retrieve()
.bodyToMono(new ParameterizedTypeReference<Map<String, Object>>() {})
.timeout(Duration.ofSeconds(5))
.doOnError(error -> log.error("매장 정보 조회 실패: storeId={}, error={}", storeId, error.getMessage()))
.onErrorReturn(Map.of());
}
/**
* 매장 목록 조회 (위치 기반)
*/
public Mono<List<Map<String, Object>>> getStoresByLocation(Double latitude, Double longitude, Integer radius) {
return storeWebClient
.get()
.uri(uriBuilder -> uriBuilder
.path("/api/stores/search")
.queryParam("latitude", latitude)
.queryParam("longitude", longitude)
.queryParam("radius", radius)
.build())
.retrieve()
.bodyToMono(new ParameterizedTypeReference<List<Map<String, Object>>>() {})
.timeout(Duration.ofSeconds(10))
.doOnError(error -> log.error("위치 기반 매장 조회 실패: lat={}, lng={}, radius={}, error={}",
latitude, longitude, radius, error.getMessage()))
.onErrorReturn(List.of());
}
/**
* 매장별 리뷰 조회
*/
public Mono<List<Map<String, Object>>> getStoreReviews(Long storeId, Integer limit) {
return reviewWebClient
.get()
.uri(uriBuilder -> uriBuilder
.path("/api/reviews/store/{storeId}")
.queryParam("limit", limit)
.build(storeId))
.retrieve()
.bodyToMono(new ParameterizedTypeReference<List<Map<String, Object>>>() {})
.timeout(Duration.ofSeconds(5))
.doOnError(error -> log.error("매장 리뷰 조회 실패: storeId={}, error={}", storeId, error.getMessage()))
.onErrorReturn(List.of());
}
/**
* 회원 취향 정보 조회
*/
public Mono<Map<String, Object>> getMemberPreferences(Long memberId) {
return memberWebClient
.get()
.uri("/api/members/{memberId}/preferences", memberId)
.retrieve()
.bodyToMono(new ParameterizedTypeReference<Map<String, Object>>() {})
.timeout(Duration.ofSeconds(5))
.doOnError(error -> log.error("회원 취향 정보 조회 실패: memberId={}, error={}", memberId, error.getMessage()))
.onErrorReturn(Map.of());
}
/**
* 회원 주문 이력 조회
*/
public Mono<List<Map<String, Object>>> getMemberOrderHistory(Long memberId, Integer limit) {
return memberWebClient
.get()
.uri(uriBuilder -> uriBuilder
.path("/api/members/{memberId}/orders")
.queryParam("limit", limit)
.build(memberId))
.retrieve()
.bodyToMono(new ParameterizedTypeReference<List<Map<String, Object>>>() {})
.timeout(Duration.ofSeconds(5))
.doOnError(error -> log.error("회원 주문 이력 조회 실패: memberId={}, error={}", memberId, error.getMessage()))
.onErrorReturn(List.of());
}
/**
* 매장 평점 통계 조회
*/
public Mono<Map<String, Object>> getStoreStatistics(Long storeId) {
return storeWebClient
.get()
.uri("/api/stores/{storeId}/statistics", storeId)
.retrieve()
.bodyToMono(new ParameterizedTypeReference<Map<String, Object>>() {})
.timeout(Duration.ofSeconds(5))
.doOnError(error -> log.error("매장 통계 조회 실패: storeId={}, error={}", storeId, error.getMessage()))
.onErrorReturn(Map.of("rating", 0.0, "reviewCount", 0));
}
/**
* 인기 매장 조회
*/
public Mono<List<Map<String, Object>>> getPopularStores(String category, Integer limit) {
return storeWebClient
.get()
.uri(uriBuilder -> uriBuilder
.path("/api/stores/popular")
.queryParam("category", category)
.queryParam("limit", limit)
.build())
.retrieve()
.bodyToMono(new ParameterizedTypeReference<List<Map<String, Object>>>() {})
.timeout(Duration.ofSeconds(10))
.doOnError(error -> log.error("인기 매장 조회 실패: category={}, limit={}, error={}",
category, limit, error.getMessage()))
.onErrorReturn(List.of());
}
}