init
This commit is contained in:
commit
f0fbb47c51
58
.gitignore
vendored
Normal file
58
.gitignore
vendored
Normal file
@ -0,0 +1,58 @@
|
||||
# Gradle
|
||||
.gradle
|
||||
build/
|
||||
!gradle/wrapper/gradle-wrapper.jar
|
||||
!**/src/main/**/build/
|
||||
!**/src/test/**/build/
|
||||
|
||||
# IntelliJ IDEA
|
||||
.idea
|
||||
*.iws
|
||||
*.iml
|
||||
*.ipr
|
||||
out/
|
||||
!**/src/main/**/out/
|
||||
!**/src/test/**/out/
|
||||
|
||||
# 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/
|
||||
|
||||
# VS Code
|
||||
.vscode/
|
||||
|
||||
# Mac
|
||||
.DS_Store
|
||||
|
||||
# Windows
|
||||
Thumbs.db
|
||||
|
||||
# Log files
|
||||
*.log
|
||||
|
||||
# Package Files
|
||||
*.jar
|
||||
*.war
|
||||
*.nar
|
||||
*.ear
|
||||
*.zip
|
||||
*.tar.gz
|
||||
*.rar
|
||||
|
||||
# virtual machine crash logs
|
||||
hs_err_pid*
|
||||
6
analytics/build.gradle
Normal file
6
analytics/build.gradle
Normal file
@ -0,0 +1,6 @@
|
||||
dependencies {
|
||||
implementation project(':common')
|
||||
|
||||
// AI APIs
|
||||
implementation 'org.springframework.boot:spring-boot-starter-webflux'
|
||||
}
|
||||
@ -0,0 +1,18 @@
|
||||
package com.ktds.hi.analytics.infra.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 실행 계획 완료 요청 DTO
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class ActionPlanCompleteRequest {
|
||||
|
||||
private String note;
|
||||
}
|
||||
@ -0,0 +1,22 @@
|
||||
package com.ktds.hi.analytics.infra.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 실행 계획 완료 응답 DTO
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class ActionPlanCompleteResponse {
|
||||
|
||||
private Boolean success;
|
||||
private String message;
|
||||
private LocalDateTime completedAt;
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
package com.ktds.hi.analytics.infra.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 실행 계획 삭제 응답 DTO
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class ActionPlanDeleteResponse {
|
||||
|
||||
private Boolean success;
|
||||
private String message;
|
||||
}
|
||||
@ -0,0 +1,20 @@
|
||||
package com.ktds.hi.analytics.infra.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 실행 계획 저장 응답 DTO
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class ActionPlanSaveResponse {
|
||||
|
||||
private Boolean success;
|
||||
private String message;
|
||||
private Long planId;
|
||||
}
|
||||
@ -0,0 +1,109 @@
|
||||
package com.ktds.hi.analytics.infra.gateway;
|
||||
|
||||
import com.ktds.hi.analytics.biz.domain.ActionPlan;
|
||||
import com.ktds.hi.analytics.biz.domain.PlanStatus;
|
||||
import com.ktds.hi.analytics.biz.usecase.out.ActionPlanPort;
|
||||
import com.ktds.hi.analytics.infra.gateway.entity.ActionPlanEntity;
|
||||
import com.ktds.hi.analytics.infra.gateway.repository.ActionPlanJpaRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 실행 계획 리포지토리 어댑터 클래스
|
||||
* ActionPlan Port를 구현하여 데이터 영속성 기능을 제공
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class ActionPlanRepositoryAdapter implements ActionPlanPort {
|
||||
|
||||
private final ActionPlanJpaRepository actionPlanJpaRepository;
|
||||
|
||||
@Override
|
||||
public List<ActionPlan> findActionPlansByStoreId(Long storeId) {
|
||||
return actionPlanJpaRepository.findByStoreIdOrderByCreatedAtDesc(storeId)
|
||||
.stream()
|
||||
.map(this::toDomain)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<ActionPlan> findActionPlanById(Long planId) {
|
||||
return actionPlanJpaRepository.findById(planId)
|
||||
.map(this::toDomain);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ActionPlan saveActionPlan(ActionPlan actionPlan) {
|
||||
ActionPlanEntity entity = toEntity(actionPlan);
|
||||
ActionPlanEntity saved = actionPlanJpaRepository.save(entity);
|
||||
return toDomain(saved);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteActionPlan(Long planId) {
|
||||
actionPlanJpaRepository.deleteById(planId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Entity를 Domain으로 변환
|
||||
*/
|
||||
private ActionPlan toDomain(ActionPlanEntity entity) {
|
||||
return ActionPlan.builder()
|
||||
.id(entity.getId())
|
||||
.storeId(entity.getStoreId())
|
||||
.userId(entity.getUserId())
|
||||
.title(entity.getTitle())
|
||||
.description(entity.getDescription())
|
||||
.period(entity.getPeriod())
|
||||
.status(entity.getStatus())
|
||||
.tasks(entity.getTasksJson() != null ? parseTasksJson(entity.getTasksJson()) : List.of())
|
||||
.note(entity.getNote())
|
||||
.createdAt(entity.getCreatedAt())
|
||||
.completedAt(entity.getCompletedAt())
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain을 Entity로 변환
|
||||
*/
|
||||
private ActionPlanEntity toEntity(ActionPlan domain) {
|
||||
return ActionPlanEntity.builder()
|
||||
.id(domain.getId())
|
||||
.storeId(domain.getStoreId())
|
||||
.userId(domain.getUserId())
|
||||
.title(domain.getTitle())
|
||||
.description(domain.getDescription())
|
||||
.period(domain.getPeriod())
|
||||
.status(domain.getStatus())
|
||||
.tasksJson(domain.getTasks() != null ? toTasksJsonString(domain.getTasks()) : "[]")
|
||||
.note(domain.getNote())
|
||||
.createdAt(domain.getCreatedAt())
|
||||
.completedAt(domain.getCompletedAt())
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON 문자열을 Tasks List로 파싱
|
||||
*/
|
||||
private List<String> parseTasksJson(String json) {
|
||||
if (json == null || json.trim().isEmpty() || "[]".equals(json.trim())) {
|
||||
return List.of();
|
||||
}
|
||||
return Arrays.asList(json.replace("[", "").replace("]", "").replace("\"", "").split(","));
|
||||
}
|
||||
|
||||
/**
|
||||
* Tasks List를 JSON 문자열로 변환
|
||||
*/
|
||||
private String toTasksJsonString(List<String> tasks) {
|
||||
if (tasks == null || tasks.isEmpty()) {
|
||||
return "[]";
|
||||
}
|
||||
return "[\"" + String.join("\",\"", tasks) + "\"]";
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,266 @@
|
||||
package com.ktds.hi.analytics.infra.gateway;
|
||||
|
||||
import com.ktds.hi.analytics.biz.domain.AiFeedback;
|
||||
import com.ktds.hi.analytics.biz.domain.SentimentType;
|
||||
import com.ktds.hi.analytics.biz.usecase.out.AIServicePort;
|
||||
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.ResponseEntity;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* AI 서비스 어댑터 클래스
|
||||
* AI Service Port를 구현하여 외부 AI API 연동 기능을 제공
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class AiServiceAdapter implements AIServicePort {
|
||||
|
||||
private final RestTemplate restTemplate;
|
||||
|
||||
@Value("${external-api.openai.api-key:}")
|
||||
private String openaiApiKey;
|
||||
|
||||
@Value("${external-api.claude.api-key:}")
|
||||
private String claudeApiKey;
|
||||
|
||||
@Override
|
||||
public AiFeedback generateFeedback(List<String> reviewData) {
|
||||
log.info("AI 피드백 생성 시작: reviewCount={}", reviewData.size());
|
||||
|
||||
try {
|
||||
// OpenAI API를 사용한 감정 분석
|
||||
String combinedReviews = String.join(" ", reviewData);
|
||||
SentimentType sentiment = analyzeSentiment(combinedReviews);
|
||||
|
||||
// Mock AI 피드백 생성 (실제로는 OpenAI/Claude API 호출)
|
||||
AiFeedback feedback = AiFeedback.builder()
|
||||
.summary(generateMockSummary(reviewData, sentiment))
|
||||
.sentiment(sentiment)
|
||||
.positivePoints(generateMockPositivePoints())
|
||||
.negativePoints(generateMockNegativePoints())
|
||||
.recommendations(generateMockRecommendations())
|
||||
.confidence(calculateConfidence(reviewData))
|
||||
.analysisDate(LocalDate.now())
|
||||
.build();
|
||||
|
||||
log.info("AI 피드백 생성 완료: sentiment={}, confidence={}", sentiment, feedback.getConfidence());
|
||||
return feedback;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("AI 피드백 생성 실패: error={}", e.getMessage(), e);
|
||||
return createFallbackFeedback();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public SentimentType analyzeSentiment(String content) {
|
||||
log.debug("감정 분석 시작: contentLength={}", content.length());
|
||||
|
||||
try {
|
||||
// 실제로는 OpenAI API 호출
|
||||
if (openaiApiKey != null && !openaiApiKey.isEmpty()) {
|
||||
return callOpenAISentimentAPI(content);
|
||||
}
|
||||
|
||||
// Fallback: 간단한 키워드 기반 감정 분석
|
||||
return performKeywordBasedSentiment(content);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("감정 분석 실패: error={}", e.getMessage(), e);
|
||||
return SentimentType.NEUTRAL;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> generateActionPlan(AiFeedback feedback) {
|
||||
log.info("실행 계획 생성 시작: sentiment={}", feedback.getSentiment());
|
||||
|
||||
try {
|
||||
// AI 기반 실행 계획 생성 (Mock)
|
||||
List<String> actionPlan = List.of(
|
||||
"고객 서비스 개선을 위한 직원 교육 실시",
|
||||
"주방 청결도 점검 및 개선",
|
||||
"대기시간 단축을 위한 주문 시스템 개선",
|
||||
"메뉴 다양성 확대 검토",
|
||||
"고객 피드백 수집 시스템 구축"
|
||||
);
|
||||
|
||||
log.info("실행 계획 생성 완료: planCount={}", actionPlan.size());
|
||||
return actionPlan;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("실행 계획 생성 실패: error={}", e.getMessage(), e);
|
||||
return List.of("AI 분석을 통한 개선사항 검토");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenAI API를 통한 감정 분석
|
||||
*/
|
||||
private SentimentType callOpenAISentimentAPI(String content) {
|
||||
try {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.set("Authorization", "Bearer " + openaiApiKey);
|
||||
headers.set("Content-Type", "application/json");
|
||||
|
||||
Map<String, Object> requestBody = Map.of(
|
||||
"model", "gpt-3.5-turbo",
|
||||
"messages", List.of(
|
||||
Map.of("role", "user", "content",
|
||||
"다음 리뷰의 감정을 분석해주세요. POSITIVE, NEGATIVE, NEUTRAL 중 하나로 답해주세요: " + content)
|
||||
),
|
||||
"max_tokens", 10
|
||||
);
|
||||
|
||||
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(requestBody, headers);
|
||||
ResponseEntity<Map> response = restTemplate.exchange(
|
||||
"https://api.openai.com/v1/chat/completions",
|
||||
HttpMethod.POST,
|
||||
entity,
|
||||
Map.class
|
||||
);
|
||||
|
||||
// API 응답 파싱
|
||||
Map<String, Object> responseBody = response.getBody();
|
||||
if (responseBody != null && responseBody.containsKey("choices")) {
|
||||
List<Map<String, Object>> choices = (List<Map<String, Object>>) responseBody.get("choices");
|
||||
if (!choices.isEmpty()) {
|
||||
Map<String, Object> message = (Map<String, Object>) choices.get(0).get("message");
|
||||
String result = (String) message.get("content");
|
||||
return SentimentType.valueOf(result.trim().toUpperCase());
|
||||
}
|
||||
}
|
||||
|
||||
return SentimentType.NEUTRAL;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.warn("OpenAI API 호출 실패, 키워드 분석으로 대체: error={}", e.getMessage());
|
||||
return performKeywordBasedSentiment(content);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 키워드 기반 감정 분석
|
||||
*/
|
||||
private SentimentType performKeywordBasedSentiment(String content) {
|
||||
String lowerContent = content.toLowerCase();
|
||||
|
||||
List<String> positiveKeywords = List.of("맛있", "좋", "최고", "추천", "만족", "친절", "깔끔");
|
||||
List<String> negativeKeywords = List.of("맛없", "나쁘", "별로", "실망", "불친절", "더러", "느리");
|
||||
|
||||
long positiveCount = positiveKeywords.stream()
|
||||
.mapToLong(keyword -> countOccurrences(lowerContent, keyword))
|
||||
.sum();
|
||||
|
||||
long negativeCount = negativeKeywords.stream()
|
||||
.mapToLong(keyword -> countOccurrences(lowerContent, keyword))
|
||||
.sum();
|
||||
|
||||
if (positiveCount > negativeCount) {
|
||||
return SentimentType.POSITIVE;
|
||||
} else if (negativeCount > positiveCount) {
|
||||
return SentimentType.NEGATIVE;
|
||||
} else {
|
||||
return SentimentType.NEUTRAL;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 문자열에서 특정 키워드 출현 횟수 계산
|
||||
*/
|
||||
private long countOccurrences(String text, String keyword) {
|
||||
return (text.length() - text.replace(keyword, "").length()) / keyword.length();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock 요약 생성
|
||||
*/
|
||||
private String generateMockSummary(List<String> reviewData, SentimentType sentiment) {
|
||||
switch (sentiment) {
|
||||
case POSITIVE:
|
||||
return "고객들이 음식의 맛과 서비스에 대해 전반적으로 만족하고 있습니다. 특히 음식의 품질과 직원의 친절함이 높이 평가받고 있습니다.";
|
||||
case NEGATIVE:
|
||||
return "일부 고객들이 음식의 맛이나 서비스에 대해 불만을 표현하고 있습니다. 주로 대기시간과 음식의 온도에 대한 개선이 필요해 보입니다.";
|
||||
default:
|
||||
return "고객 리뷰가 긍정적인 면과 개선이 필요한 면이 혼재되어 있습니다. 지속적인 품질 관리가 필요합니다.";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock 긍정 포인트 생성
|
||||
*/
|
||||
private List<String> generateMockPositivePoints() {
|
||||
return List.of(
|
||||
"음식의 맛이 좋다는 평가",
|
||||
"직원들이 친절하다는 의견",
|
||||
"매장이 깔끔하고 청결함",
|
||||
"가격 대비 만족스러운 품질"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock 부정 포인트 생성
|
||||
*/
|
||||
private List<String> generateMockNegativePoints() {
|
||||
return List.of(
|
||||
"주문 후 대기시간이 다소 길음",
|
||||
"일부 메뉴의 간이 짜다는 의견",
|
||||
"주차 공간이 부족함"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock 추천사항 생성
|
||||
*/
|
||||
private List<String> generateMockRecommendations() {
|
||||
return List.of(
|
||||
"주문 처리 시간 단축을 위한 시스템 개선",
|
||||
"메뉴별 간 조절에 대한 재검토",
|
||||
"고객 대기 공간 개선",
|
||||
"직원 서비스 교육 지속 실시",
|
||||
"주차 환경 개선 방안 검토"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 신뢰도 계산
|
||||
*/
|
||||
private Double calculateConfidence(List<String> reviewData) {
|
||||
// 리뷰 수와 내용 길이를 기반으로 신뢰도 계산
|
||||
int reviewCount = reviewData.size();
|
||||
double avgLength = reviewData.stream()
|
||||
.mapToInt(String::length)
|
||||
.average()
|
||||
.orElse(0.0);
|
||||
|
||||
// 기본 신뢰도 계산 로직
|
||||
double confidence = Math.min(0.95, 0.5 + (reviewCount * 0.05) + (avgLength * 0.001));
|
||||
return Math.round(confidence * 100.0) / 100.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback 피드백 생성
|
||||
*/
|
||||
private AiFeedback createFallbackFeedback() {
|
||||
return AiFeedback.builder()
|
||||
.summary("AI 분석을 수행할 수 없어 기본 분석 결과를 제공합니다.")
|
||||
.sentiment(SentimentType.NEUTRAL)
|
||||
.positivePoints(List.of("분석 데이터 부족"))
|
||||
.negativePoints(List.of("분석 데이터 부족"))
|
||||
.recommendations(List.of("더 많은 리뷰 데이터 수집 필요"))
|
||||
.confidence(0.3)
|
||||
.analysisDate(LocalDate.now())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
package com.ktds.hi.analytics.infra.gateway;
|
||||
|
||||
import com.ktds.hi.analytics.biz.domain.AiFeedback;
|
||||
import com.ktds.hi.analytics.biz.domain.SentimentType;
|
||||
import com.ktds.hi.analytics.biz.usecase.out.AIServicePort;
|
||||
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.ResponseEntity;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.HashMap; // 추가
|
||||
@ -0,0 +1,141 @@
|
||||
package com.ktds.hi.analytics.infra.gateway;
|
||||
|
||||
import com.ktds.hi.analytics.biz.domain.Analytics;
|
||||
import com.ktds.hi.analytics.biz.domain.AiFeedback;
|
||||
import com.ktds.hi.analytics.biz.usecase.out.AnalyticsPort;
|
||||
import com.ktds.hi.analytics.infra.gateway.entity.AnalyticsEntity;
|
||||
import com.ktds.hi.analytics.infra.gateway.entity.AiFeedbackEntity;
|
||||
import com.ktds.hi.analytics.infra.gateway.repository.AnalyticsJpaRepository;
|
||||
import com.ktds.hi.analytics.infra.gateway.repository.AiFeedbackJpaRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 분석 리포지토리 어댑터 클래스
|
||||
* Analytics Port를 구현하여 데이터 영속성 기능을 제공
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class AnalyticsRepositoryAdapter implements AnalyticsPort {
|
||||
|
||||
private final AnalyticsJpaRepository analyticsJpaRepository;
|
||||
private final AiFeedbackJpaRepository aiFeedbackJpaRepository;
|
||||
|
||||
@Override
|
||||
public Optional<Analytics> findAnalyticsByStoreId(Long storeId) {
|
||||
return analyticsJpaRepository.findByStoreId(storeId)
|
||||
.map(this::toDomain);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Analytics saveAnalytics(Analytics analytics) {
|
||||
AnalyticsEntity entity = toEntity(analytics);
|
||||
AnalyticsEntity saved = analyticsJpaRepository.save(entity);
|
||||
return toDomain(saved);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<AiFeedback> findAIFeedbackByStoreId(Long storeId) {
|
||||
return aiFeedbackJpaRepository.findByStoreId(storeId)
|
||||
.map(this::toAiFeedbackDomain);
|
||||
}
|
||||
|
||||
@Override
|
||||
public AiFeedback saveAIFeedback(AiFeedback feedback) {
|
||||
AiFeedbackEntity entity = toAiFeedbackEntity(feedback);
|
||||
AiFeedbackEntity saved = aiFeedbackJpaRepository.save(entity);
|
||||
return toAiFeedbackDomain(saved);
|
||||
}
|
||||
|
||||
/**
|
||||
* Entity를 Domain으로 변환
|
||||
*/
|
||||
private Analytics toDomain(AnalyticsEntity entity) {
|
||||
return Analytics.builder()
|
||||
.id(entity.getId())
|
||||
.storeId(entity.getStoreId())
|
||||
.totalReviews(entity.getTotalReviews())
|
||||
.averageRating(entity.getAverageRating())
|
||||
.sentimentScore(entity.getSentimentScore())
|
||||
.createdAt(entity.getCreatedAt())
|
||||
.updatedAt(entity.getUpdatedAt())
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain을 Entity로 변환
|
||||
*/
|
||||
private AnalyticsEntity toEntity(Analytics domain) {
|
||||
return AnalyticsEntity.builder()
|
||||
.id(domain.getId())
|
||||
.storeId(domain.getStoreId())
|
||||
.totalReviews(domain.getTotalReviews())
|
||||
.averageRating(domain.getAverageRating())
|
||||
.sentimentScore(domain.getSentimentScore())
|
||||
.createdAt(domain.getCreatedAt())
|
||||
.updatedAt(domain.getUpdatedAt())
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* AiFeedback Entity를 Domain으로 변환
|
||||
*/
|
||||
private AiFeedback toAiFeedbackDomain(AiFeedbackEntity entity) {
|
||||
return AiFeedback.builder()
|
||||
.id(entity.getId())
|
||||
.storeId(entity.getStoreId())
|
||||
.summary(entity.getSummary())
|
||||
.sentiment(entity.getSentiment())
|
||||
.positivePoints(entity.getPositivePointsJson() != null ?
|
||||
parseJsonList(entity.getPositivePointsJson()) : List.of())
|
||||
.negativePoints(entity.getNegativePointsJson() != null ?
|
||||
parseJsonList(entity.getNegativePointsJson()) : List.of())
|
||||
.recommendations(entity.getRecommendationsJson() != null ?
|
||||
parseJsonList(entity.getRecommendationsJson()) : List.of())
|
||||
.confidence(entity.getConfidence())
|
||||
.analysisDate(entity.getAnalysisDate())
|
||||
.createdAt(entity.getCreatedAt())
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* AiFeedback Domain을 Entity로 변환
|
||||
*/
|
||||
private AiFeedbackEntity toAiFeedbackEntity(AiFeedback domain) {
|
||||
return AiFeedbackEntity.builder()
|
||||
.id(domain.getId())
|
||||
.storeId(domain.getStoreId())
|
||||
.summary(domain.getSummary())
|
||||
.sentiment(domain.getSentiment())
|
||||
.positivePointsJson(toJsonString(domain.getPositivePoints()))
|
||||
.negativePointsJson(toJsonString(domain.getNegativePoints()))
|
||||
.recommendationsJson(toJsonString(domain.getRecommendations()))
|
||||
.confidence(domain.getConfidence())
|
||||
.analysisDate(domain.getAnalysisDate())
|
||||
.createdAt(domain.getCreatedAt())
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON 문자열을 List로 파싱
|
||||
*/
|
||||
private List<String> parseJsonList(String json) {
|
||||
// 실제로는 Jackson 등을 사용하여 파싱
|
||||
if (json == null || json.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
return Arrays.asList(json.replace("[", "").replace("]", "").replace("\"", "").split(","));
|
||||
}
|
||||
|
||||
/**
|
||||
* List를 JSON 문자열로 변환
|
||||
*/
|
||||
private String toJsonString(List<String> list) {
|
||||
if (list == null || list.isEmpty()) {
|
||||
return "[]";
|
||||
}
|
||||
return "[\"" + String.join("\",\"", list) + "\"]";
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
package com.ktds.hi.analytics.infra.gateway;
|
||||
|
||||
import com.ktds.hi.analytics.biz.domain.Analytics;
|
||||
import com.ktds.hi.analytics.biz.domain.AiFeedback;
|
||||
import com.ktds.hi.analytics.biz.usecase.out.AnalyticsPort;
|
||||
import com.ktds.hi.analytics.infra.gateway.entity.AnalyticsEntity;
|
||||
import com.ktds.hi.analytics.infra.gateway.entity.AiFeedbackEntity;
|
||||
import com.ktds.hi.analytics.infra.gateway.repository.AnalyticsJpaRepository;
|
||||
import com.ktds.hi.analytics.infra.gateway.repository.AiFeedbackJpaRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Arrays; // 추가
|
||||
import java.util.List; // 추가
|
||||
import java.util.Optional;
|
||||
@ -0,0 +1,51 @@
|
||||
package com.ktds.hi.analytics.infra.gateway;
|
||||
|
||||
import com.ktds.hi.analytics.biz.domain.ActionPlan;
|
||||
import com.ktds.hi.analytics.biz.usecase.out.EventPort;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 이벤트 어댑터 클래스
|
||||
* Event Port를 구현하여 이벤트 발행 기능을 제공
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class EventAdapter implements EventPort {
|
||||
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
|
||||
@Override
|
||||
public void publishActionPlanCreatedEvent(ActionPlan actionPlan) {
|
||||
log.info("실행 계획 생성 이벤트 발행: planId={}, storeId={}", actionPlan.getId(), actionPlan.getStoreId());
|
||||
|
||||
try {
|
||||
// 실행 계획 생성 이벤트 객체 생성 및 발행
|
||||
ActionPlanCreatedEvent event = new ActionPlanCreatedEvent(actionPlan);
|
||||
eventPublisher.publishEvent(event);
|
||||
|
||||
log.info("실행 계획 생성 이벤트 발행 완료: planId={}", actionPlan.getId());
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("실행 계획 생성 이벤트 발행 실패: planId={}, error={}", actionPlan.getId(), e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 실행 계획 생성 이벤트 클래스
|
||||
*/
|
||||
public static class ActionPlanCreatedEvent {
|
||||
private final ActionPlan actionPlan;
|
||||
|
||||
public ActionPlanCreatedEvent(ActionPlan actionPlan) {
|
||||
this.actionPlan = actionPlan;
|
||||
}
|
||||
|
||||
public ActionPlan getActionPlan() {
|
||||
return actionPlan;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,85 @@
|
||||
package com.ktds.hi.analytics.infra.gateway;
|
||||
|
||||
import com.ktds.hi.analytics.biz.usecase.out.ExternalReviewPort;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 외부 리뷰 어댑터 클래스
|
||||
* External Review Port를 구현하여 외부 리뷰 데이터 연동 기능을 제공
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class ExternalReviewAdapter implements ExternalReviewPort {
|
||||
|
||||
@Override
|
||||
public List<String> getReviewData(Long storeId) {
|
||||
log.info("외부 리뷰 데이터 조회 시작: storeId={}", storeId);
|
||||
|
||||
try {
|
||||
// 실제로는 Review Service와 연동하여 리뷰 데이터를 가져옴
|
||||
// Mock 데이터 반환
|
||||
List<String> reviews = new ArrayList<>();
|
||||
reviews.add("음식이 정말 맛있고 서비스도 친절해요. 다음에 또 올게요!");
|
||||
reviews.add("가격 대비 양이 많고 맛도 괜찮습니다. 추천해요.");
|
||||
reviews.add("분위기가 좋고 음식도 맛있어요. 특히 김치찌개가 일품이네요.");
|
||||
reviews.add("조금 대기시간이 길었지만 음식은 만족스러웠습니다.");
|
||||
reviews.add("깔끔하고 맛있어요. 직원분들도 친절하시고 좋았습니다.");
|
||||
reviews.add("기대보다는 평범했지만 나쁘지 않았어요.");
|
||||
reviews.add("음식이 너무 짜서 별로였습니다. 개선이 필요할 것 같아요.");
|
||||
reviews.add("가성비 좋고 맛도 괜찮아요. 재방문 의사 있습니다.");
|
||||
reviews.add("위생 상태가 좋고 음식도 깔끔해요. 만족합니다.");
|
||||
reviews.add("주차하기 어려워서 불편했지만 음식은 맛있었어요.");
|
||||
|
||||
log.info("외부 리뷰 데이터 조회 완료: storeId={}, count={}", storeId, reviews.size());
|
||||
return reviews;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("외부 리뷰 데이터 조회 실패: storeId={}, error={}", storeId, e.getMessage(), e);
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> getRecentReviews(Long storeId, Integer days) {
|
||||
log.info("최근 리뷰 데이터 조회 시작: storeId={}, days={}", storeId, days);
|
||||
|
||||
try {
|
||||
// 실제로는 최근 N일간의 리뷰만 필터링
|
||||
List<String> allReviews = getReviewData(storeId);
|
||||
|
||||
// Mock: 최근 리뷰는 전체 리뷰의 70% 정도로 가정
|
||||
int recentCount = (int) (allReviews.size() * 0.7);
|
||||
List<String> recentReviews = allReviews.subList(0, Math.min(recentCount, allReviews.size()));
|
||||
|
||||
log.info("최근 리뷰 데이터 조회 완료: storeId={}, days={}, count={}", storeId, days, recentReviews.size());
|
||||
return recentReviews;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("최근 리뷰 데이터 조회 실패: storeId={}, days={}, error={}", storeId, days, e.getMessage(), e);
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Integer getReviewCount(Long storeId) {
|
||||
log.info("리뷰 수 조회 시작: storeId={}", storeId);
|
||||
|
||||
try {
|
||||
List<String> reviews = getReviewData(storeId);
|
||||
int count = reviews.size();
|
||||
|
||||
log.info("리뷰 수 조회 완료: storeId={}, count={}", storeId, count);
|
||||
return count;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("리뷰 수 조회 실패: storeId={}, error={}", storeId, e.getMessage(), e);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,78 @@
|
||||
package com.ktds.hi.analytics.infra.gateway.entity;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.ktds.hi.analytics.biz.domain.PlanStatus;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.springframework.data.annotation.CreatedDate;
|
||||
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 실행 계획 엔티티 클래스
|
||||
* 데이터베이스 action_plans 테이블과 매핑되는 JPA 엔티티
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "action_plans")
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@EntityListeners(AuditingEntityListener.class)
|
||||
public class ActionPlanEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "store_id", nullable = false)
|
||||
private Long storeId;
|
||||
|
||||
@Column(name = "user_id", nullable = false)
|
||||
private Long userId;
|
||||
|
||||
@Column(nullable = false, length = 200)
|
||||
private String title;
|
||||
|
||||
@Column(columnDefinition = "TEXT")
|
||||
private String description;
|
||||
|
||||
@Column(length = 50)
|
||||
private String period;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 20)
|
||||
@Builder.Default
|
||||
private PlanStatus status = PlanStatus.PLANNED;
|
||||
|
||||
@Column(name = "feedback_ids_json", columnDefinition = "TEXT")
|
||||
private String feedbackIdsJson;
|
||||
|
||||
@Column(columnDefinition = "TEXT")
|
||||
private String note;
|
||||
|
||||
@CreatedDate
|
||||
@Column(name = "created_at", updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(name = "completed_at")
|
||||
private LocalDateTime completedAt;
|
||||
|
||||
/**
|
||||
* JSON 문자열을 List로 변환
|
||||
*/
|
||||
public List<Long> getFeedbackIdsList() {
|
||||
try {
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
return mapper.readValue(feedbackIdsJson, new TypeReference<List<Long>>() {});
|
||||
} catch (Exception e) {
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,93 @@
|
||||
package com.ktds.hi.analytics.infra.gateway.entity;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.springframework.data.annotation.CreatedDate;
|
||||
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* AI 피드백 엔티티 클래스
|
||||
* 데이터베이스 ai_feedback 테이블과 매핑되는 JPA 엔티티
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "ai_feedback")
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@EntityListeners(AuditingEntityListener.class)
|
||||
public class AiFeedbackEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "store_id", nullable = false)
|
||||
private Long storeId;
|
||||
|
||||
@Column(columnDefinition = "TEXT")
|
||||
private String summary;
|
||||
|
||||
@Column(length = 20)
|
||||
private String sentiment;
|
||||
|
||||
@Column(name = "positive_points_json", columnDefinition = "TEXT")
|
||||
private String positivePointsJson;
|
||||
|
||||
@Column(name = "negative_points_json", columnDefinition = "TEXT")
|
||||
private String negativePointsJson;
|
||||
|
||||
@Column(name = "recommendations_json", columnDefinition = "TEXT")
|
||||
private String recommendationsJson;
|
||||
|
||||
@Column(precision = 3, scale = 2)
|
||||
private BigDecimal confidence;
|
||||
|
||||
@Column(name = "analysis_date")
|
||||
private LocalDate analysisDate;
|
||||
|
||||
@CreatedDate
|
||||
@Column(name = "created_at", updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
/**
|
||||
* JSON 문자열을 객체로 변환하는 메서드들
|
||||
*/
|
||||
public Map<String, Object> getPositivePointsMap() {
|
||||
try {
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
return mapper.readValue(positivePointsJson, new TypeReference<Map<String, Object>>() {});
|
||||
} catch (Exception e) {
|
||||
return Map.of();
|
||||
}
|
||||
}
|
||||
|
||||
public Map<String, Object> getNegativePointsMap() {
|
||||
try {
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
return mapper.readValue(negativePointsJson, new TypeReference<Map<String, Object>>() {});
|
||||
} catch (Exception e) {
|
||||
return Map.of();
|
||||
}
|
||||
}
|
||||
|
||||
public List<String> getRecommendationsList() {
|
||||
try {
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
return mapper.readValue(recommendationsJson, new TypeReference<List<String>>() {});
|
||||
} catch (Exception e) {
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,107 @@
|
||||
package com.ktds.hi.analytics.infra.gateway.entity;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.springframework.data.annotation.CreatedDate;
|
||||
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 통계 엔티티 클래스
|
||||
* 데이터베이스 order_statistics 테이블과 매핑되는 JPA 엔티티
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "order_statistics")
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@EntityListeners(AuditingEntityListener.class)
|
||||
public class StatisticsEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "store_id", nullable = false)
|
||||
private Long storeId;
|
||||
|
||||
@Column(name = "analysis_date")
|
||||
private LocalDate analysisDate;
|
||||
|
||||
@Column(name = "total_orders")
|
||||
private Integer totalOrders;
|
||||
|
||||
@Column(name = "total_revenue", precision = 15, scale = 2)
|
||||
private BigDecimal totalRevenue;
|
||||
|
||||
@Column(name = "avg_order_amount", precision = 10, scale = 2)
|
||||
private BigDecimal avgOrderAmount;
|
||||
|
||||
@Column(name = "peak_hour")
|
||||
private Integer peakHour;
|
||||
|
||||
@Column(name = "age_statistics_json", columnDefinition = "TEXT")
|
||||
private String ageStatisticsJson;
|
||||
|
||||
@Column(name = "gender_statistics_json", columnDefinition = "TEXT")
|
||||
private String genderStatisticsJson;
|
||||
|
||||
@Column(name = "time_statistics_json", columnDefinition = "TEXT")
|
||||
private String timeStatisticsJson;
|
||||
|
||||
@Column(name = "menu_popularity_json", columnDefinition = "TEXT")
|
||||
private String menuPopularityJson;
|
||||
|
||||
@CreatedDate
|
||||
@Column(name = "created_at", updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
/**
|
||||
* JSON 문자열을 객체로 변환하는 메서드들
|
||||
*/
|
||||
public Map<String, Object> getAgeStatisticsMap() {
|
||||
try {
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
return mapper.readValue(ageStatisticsJson, new TypeReference<Map<String, Object>>() {});
|
||||
} catch (Exception e) {
|
||||
return Map.of();
|
||||
}
|
||||
}
|
||||
|
||||
public Map<String, Object> getGenderStatisticsMap() {
|
||||
try {
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
return mapper.readValue(genderStatisticsJson, new TypeReference<Map<String, Object>>() {});
|
||||
} catch (Exception e) {
|
||||
return Map.of();
|
||||
}
|
||||
}
|
||||
|
||||
public Map<String, Object> getTimeStatisticsMap() {
|
||||
try {
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
return mapper.readValue(timeStatisticsJson, new TypeReference<Map<String, Object>>() {});
|
||||
} catch (Exception e) {
|
||||
return Map.of();
|
||||
}
|
||||
}
|
||||
|
||||
public Map<String, Object> getMenuPopularityMap() {
|
||||
try {
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
return mapper.readValue(menuPopularityJson, new TypeReference<Map<String, Object>>() {});
|
||||
} catch (Exception e) {
|
||||
return Map.of();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,36 @@
|
||||
package com.ktds.hi.analytics.infra.gateway.repository;
|
||||
|
||||
import com.ktds.hi.analytics.biz.domain.PlanStatus;
|
||||
import com.ktds.hi.analytics.infra.gateway.entity.ActionPlanEntity;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 실행 계획 JPA 리포지토리 인터페이스
|
||||
* 실행 계획 데이터의 CRUD 작업을 담당
|
||||
*/
|
||||
@Repository
|
||||
public interface ActionPlanJpaRepository extends JpaRepository<ActionPlanEntity, Long> {
|
||||
|
||||
/**
|
||||
* 매장 ID로 실행 계획 목록 조회 (최신순)
|
||||
*/
|
||||
List<ActionPlanEntity> findByStoreIdOrderByCreatedAtDesc(Long storeId);
|
||||
|
||||
/**
|
||||
* 매장 ID와 상태로 실행 계획 목록 조회 (최신순)
|
||||
*/
|
||||
List<ActionPlanEntity> findByStoreIdAndStatusOrderByCreatedAtDesc(Long storeId, PlanStatus status);
|
||||
|
||||
/**
|
||||
* 사용자 ID로 실행 계획 목록 조회
|
||||
*/
|
||||
List<ActionPlanEntity> findByUserIdOrderByCreatedAtDesc(Long userId);
|
||||
|
||||
/**
|
||||
* 매장 ID와 사용자 ID로 실행 계획 목록 조회
|
||||
*/
|
||||
List<ActionPlanEntity> findByStoreIdAndUserIdOrderByCreatedAtDesc(Long storeId, Long userId);
|
||||
}
|
||||
@ -0,0 +1,32 @@
|
||||
package com.ktds.hi.analytics.infra.gateway.repository;
|
||||
|
||||
import com.ktds.hi.analytics.infra.gateway.entity.AiFeedbackEntity;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* AI 피드백 JPA 리포지토리 인터페이스
|
||||
* AI 피드백 데이터의 CRUD 작업을 담당
|
||||
*/
|
||||
@Repository
|
||||
public interface AiFeedbackJpaRepository extends JpaRepository<AiFeedbackEntity, Long> {
|
||||
|
||||
/**
|
||||
* 매장 ID와 분석 기간으로 AI 피드백 목록 조회
|
||||
*/
|
||||
List<AiFeedbackEntity> findByStoreIdAndAnalysisDateBetweenOrderByAnalysisDateDesc(
|
||||
Long storeId, LocalDate startDate, LocalDate endDate);
|
||||
|
||||
/**
|
||||
* 매장 ID로 최신 AI 피드백 조회
|
||||
*/
|
||||
AiFeedbackEntity findTopByStoreIdOrderByCreatedAtDesc(Long storeId);
|
||||
|
||||
/**
|
||||
* 특정 날짜의 AI 피드백 조회
|
||||
*/
|
||||
List<AiFeedbackEntity> findByStoreIdAndAnalysisDate(Long storeId, LocalDate analysisDate);
|
||||
}
|
||||
@ -0,0 +1,33 @@
|
||||
package com.ktds.hi.analytics.infra.gateway.repository;
|
||||
|
||||
import com.ktds.hi.analytics.infra.gateway.entity.StatisticsEntity;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 통계 JPA 리포지토리 인터페이스
|
||||
* 통계 데이터의 CRUD 작업을 담당
|
||||
*/
|
||||
@Repository
|
||||
public interface StatisticsJpaRepository extends JpaRepository<StatisticsEntity, Long> {
|
||||
|
||||
/**
|
||||
* 매장 ID와 분석 기간으로 통계 조회
|
||||
*/
|
||||
List<StatisticsEntity> findByStoreIdAndAnalysisDateBetween(
|
||||
Long storeId, LocalDate startDate, LocalDate endDate);
|
||||
|
||||
/**
|
||||
* 매장 ID와 특정 날짜로 통계 조회
|
||||
*/
|
||||
Optional<StatisticsEntity> findByStoreIdAndAnalysisDate(Long storeId, LocalDate analysisDate);
|
||||
|
||||
/**
|
||||
* 매장 ID로 최신 통계 조회
|
||||
*/
|
||||
Optional<StatisticsEntity> findTopByStoreIdOrderByAnalysisDateDesc(Long storeId);
|
||||
}
|
||||
42
analytics/src/main/resources/application.yml
Normal file
42
analytics/src/main/resources/application.yml
Normal file
@ -0,0 +1,42 @@
|
||||
server:
|
||||
port: ${ANALYTICS_SERVICE_PORT:8084}
|
||||
|
||||
spring:
|
||||
application:
|
||||
name: analytics-service
|
||||
|
||||
datasource:
|
||||
url: ${ANALYTICS_DB_URL:jdbc:postgresql://localhost:5432/hiorder_analytics}
|
||||
username: ${ANALYTICS_DB_USERNAME:hiorder_user}
|
||||
password: ${ANALYTICS_DB_PASSWORD:hiorder_pass}
|
||||
driver-class-name: org.postgresql.Driver
|
||||
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: ${JPA_DDL_AUTO:validate}
|
||||
show-sql: ${JPA_SHOW_SQL:false}
|
||||
properties:
|
||||
hibernate:
|
||||
format_sql: true
|
||||
dialect: org.hibernate.dialect.PostgreSQLDialect
|
||||
|
||||
redis:
|
||||
host: ${REDIS_HOST:localhost}
|
||||
port: ${REDIS_PORT:6379}
|
||||
password: ${REDIS_PASSWORD:}
|
||||
|
||||
ai-api:
|
||||
openai:
|
||||
api-key: ${OPENAI_API_KEY:}
|
||||
base-url: https://api.openai.com/v1
|
||||
model: gpt-4o-mini
|
||||
claude:
|
||||
api-key: ${CLAUDE_API_KEY:}
|
||||
base-url: https://api.anthropic.com
|
||||
model: claude-3-sonnet-20240229
|
||||
|
||||
springdoc:
|
||||
api-docs:
|
||||
path: /api-docs
|
||||
swagger-ui:
|
||||
path: /swagger-ui.html
|
||||
@ -0,0 +1,46 @@
|
||||
server:
|
||||
port: ${ANALYTICS_SERVICE_PORT:8084}
|
||||
|
||||
spring:
|
||||
application:
|
||||
name: analytics-service
|
||||
|
||||
datasource:
|
||||
url: ${ANALYTICS_DB_URL:jdbc:postgresql://localhost:5432/hiorder_analytics}
|
||||
username: ${ANALYTICS_DB_USERNAME:hiorder_user}
|
||||
password: ${ANALYTICS_DB_PASSWORD:hiorder_pass}
|
||||
driver-class-name: org.postgresql.Driver
|
||||
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: ${JPA_DDL_AUTO:validate}
|
||||
show-sql: ${JPA_SHOW_SQL:false}
|
||||
properties:
|
||||
hibernate:
|
||||
format_sql: true
|
||||
dialect: org.hibernate.dialect.PostgreSQLDialect
|
||||
|
||||
redis:
|
||||
host: ${REDIS_HOST:localhost}
|
||||
port: ${REDIS_PORT:6379}
|
||||
password: ${REDIS_PASSWORD:}
|
||||
|
||||
external-api:
|
||||
openai:
|
||||
api-key: ${OPENAI_API_KEY:}
|
||||
base-url: https://api.openai.com
|
||||
claude:
|
||||
api-key: ${CLAUDE_API_KEY:}
|
||||
base-url: https://api.anthropic.com
|
||||
|
||||
springdoc:
|
||||
api-docs:
|
||||
path: /api-docs
|
||||
swagger-ui:
|
||||
path: /swagger-ui.html
|
||||
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health,info,metrics
|
||||
65
build.gradle (Root Project)
Normal file
65
build.gradle (Root Project)
Normal file
@ -0,0 +1,65 @@
|
||||
plugins {
|
||||
id 'java'
|
||||
id 'org.springframework.boot' version '3.4.0' apply false
|
||||
id 'io.spring.dependency-management' version '1.1.4' apply false
|
||||
}
|
||||
|
||||
allprojects {
|
||||
group = 'com.ktds.hi'
|
||||
version = '1.0.0'
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
subprojects {
|
||||
apply plugin: 'java'
|
||||
apply plugin: 'org.springframework.boot'
|
||||
apply plugin: 'io.spring.dependency-management'
|
||||
|
||||
java {
|
||||
toolchain {
|
||||
languageVersion = JavaLanguageVersion.of(21)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
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'
|
||||
}
|
||||
|
||||
tasks.named('test') {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,18 @@
|
||||
package com.ktds.hi.common;
|
||||
|
||||
import org.springframework.boot.autoconfigure.domain.EntityScan;
|
||||
import org.springframework.context.annotation.ComponentScan;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
|
||||
|
||||
/**
|
||||
* Common 모듈 설정 클래스
|
||||
* 다른 모듈에서 Common 모듈을 사용할 때 필요한 설정을 자동으로 적용
|
||||
*/
|
||||
@Configuration
|
||||
@ComponentScan(basePackages = "com.ktds.hi.common")
|
||||
@EntityScan(basePackages = "com.ktds.hi.common.entity")
|
||||
@EnableJpaRepositories(basePackages = "com.ktds.hi.common.repository")
|
||||
public class CommonModuleConfiguration {
|
||||
// 설정 클래스는 어노테이션만으로도 충분
|
||||
}
|
||||
@ -0,0 +1,62 @@
|
||||
package com.ktds.hi.common.aspect;
|
||||
|
||||
import com.ktds.hi.common.service.AuditLogService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.aspectj.lang.JoinPoint;
|
||||
import org.aspectj.lang.annotation.AfterReturning;
|
||||
import org.aspectj.lang.annotation.Aspect;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 감사 로깅 AOP
|
||||
* 특정 메서드 실행 시 자동으로 감사 로그를 기록
|
||||
*/
|
||||
@Aspect
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class AuditAspect {
|
||||
|
||||
private final AuditLogService auditLogService;
|
||||
|
||||
/**
|
||||
* 서비스 메서드 실행 후 감사 로그 기록
|
||||
*/
|
||||
@AfterReturning(
|
||||
pointcut = "execution(* com.ktds.hi.*.biz.service.*Service.create*(..))",
|
||||
returning = "result"
|
||||
)
|
||||
public void auditCreate(JoinPoint joinPoint, Object result) {
|
||||
try {
|
||||
String methodName = joinPoint.getSignature().getName();
|
||||
String className = joinPoint.getTarget().getClass().getSimpleName();
|
||||
|
||||
auditLogService.logCreate(
|
||||
className.replace("Service", ""),
|
||||
extractEntityId(result),
|
||||
String.format("%s.%s 실행", className, methodName)
|
||||
);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to audit create operation", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 결과 객체에서 ID 추출
|
||||
*/
|
||||
private String extractEntityId(Object result) {
|
||||
if (result == null) {
|
||||
return "UNKNOWN";
|
||||
}
|
||||
|
||||
try {
|
||||
// 리플렉션을 사용하여 getId() 메서드 호출
|
||||
var method = result.getClass().getMethod("getId");
|
||||
Object id = method.invoke(result);
|
||||
return id != null ? id.toString() : "UNKNOWN";
|
||||
} catch (Exception e) {
|
||||
return "UNKNOWN";
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,55 @@
|
||||
package com.ktds.hi.common.repository;
|
||||
|
||||
import com.ktds.hi.common.audit.AuditAction;
|
||||
import com.ktds.hi.common.audit.AuditLog;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
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.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 감사 로그 리포지토리
|
||||
*/
|
||||
@Repository
|
||||
public interface AuditLogRepository extends JpaRepository<AuditLog, Long> {
|
||||
|
||||
/**
|
||||
* 사용자별 감사 로그 조회
|
||||
*/
|
||||
Page<AuditLog> findByUserIdOrderByCreatedAtDesc(Long userId, Pageable pageable);
|
||||
|
||||
/**
|
||||
* 액션별 감사 로그 조회
|
||||
*/
|
||||
Page<AuditLog> findByActionOrderByCreatedAtDesc(AuditAction action, Pageable pageable);
|
||||
|
||||
/**
|
||||
* 엔티티별 감사 로그 조회
|
||||
*/
|
||||
Page<AuditLog> findByEntityTypeAndEntityIdOrderByCreatedAtDesc(String entityType, String entityId, Pageable pageable);
|
||||
|
||||
/**
|
||||
* 기간별 감사 로그 조회
|
||||
*/
|
||||
@Query("SELECT al FROM AuditLog al WHERE al.createdAt BETWEEN :startDate AND :endDate ORDER BY al.createdAt DESC")
|
||||
Page<AuditLog> findByCreatedAtBetween(@Param("startDate") LocalDateTime startDate,
|
||||
@Param("endDate") LocalDateTime endDate,
|
||||
Pageable pageable);
|
||||
|
||||
/**
|
||||
* 사용자 액션 통계 조회
|
||||
*/
|
||||
@Query("SELECT al.action, COUNT(al) FROM AuditLog al WHERE al.userId = :userId GROUP BY al.action")
|
||||
List<Object[]> findActionStatsByUserId(@Param("userId") Long userId);
|
||||
|
||||
/**
|
||||
* 일별 로그 수 조회
|
||||
*/
|
||||
@Query("SELECT DATE(al.createdAt), COUNT(al) FROM AuditLog al WHERE al.createdAt >= :startDate GROUP BY DATE(al.createdAt) ORDER BY DATE(al.createdAt)")
|
||||
List<Object[]> findDailyLogCounts(@Param("startDate") LocalDateTime startDate);
|
||||
}
|
||||
@ -0,0 +1,89 @@
|
||||
package com.ktds.hi.common.security;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 커스텀 사용자 상세 정보
|
||||
* Spring Security UserDetails 인터페이스 구현
|
||||
*/
|
||||
@Getter
|
||||
@AllArgsConstructor
|
||||
public class CustomUserDetails implements UserDetails {
|
||||
|
||||
private final Long id;
|
||||
private final String username;
|
||||
private final String email;
|
||||
private final String password;
|
||||
private final List<String> roles;
|
||||
private final boolean enabled;
|
||||
private final boolean accountNonExpired;
|
||||
private final boolean accountNonLocked;
|
||||
private final boolean credentialsNonExpired;
|
||||
|
||||
@Override
|
||||
public Collection<? extends GrantedAuthority> getAuthorities() {
|
||||
return roles.stream()
|
||||
.map(SimpleGrantedAuthority::new)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getPassword() {
|
||||
return password;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getUsername() {
|
||||
return username;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isAccountNonExpired() {
|
||||
return accountNonExpired;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isAccountNonLocked() {
|
||||
return accountNonLocked;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isCredentialsNonExpired() {
|
||||
return credentialsNonExpired;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 생성자 (활성화된 사용자)
|
||||
*/
|
||||
public static CustomUserDetails of(Long id, String username, String email, String password, List<String> roles) {
|
||||
return new CustomUserDetails(
|
||||
id, username, email, password, roles,
|
||||
true, true, true, true
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 상태를 지정한 생성자
|
||||
*/
|
||||
public static CustomUserDetails of(Long id, String username, String email, String password, List<String> roles,
|
||||
boolean enabled, boolean accountNonExpired,
|
||||
boolean accountNonLocked, boolean credentialsNonExpired) {
|
||||
return new CustomUserDetails(
|
||||
id, username, email, password, roles,
|
||||
enabled, accountNonExpired, accountNonLocked, credentialsNonExpired
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,114 @@
|
||||
package com.ktds.hi.common.security;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.ktds.hi.common.constants.SecurityConstants;
|
||||
import com.ktds.hi.common.response.ApiResponse;
|
||||
import com.ktds.hi.common.response.ResponseCode;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* JWT 인증 필터
|
||||
* HTTP 요청에서 JWT 토큰을 추출하고 검증하여 인증 처리
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
private final JwtTokenProvider jwtTokenProvider;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
|
||||
FilterChain filterChain) throws ServletException, IOException {
|
||||
|
||||
try {
|
||||
String jwt = getJwtFromRequest(request);
|
||||
|
||||
if (StringUtils.hasText(jwt) && jwtTokenProvider.validateToken(jwt)) {
|
||||
// 액세스 토큰인지 확인
|
||||
if (!jwtTokenProvider.isAccessToken(jwt)) {
|
||||
sendErrorResponse(response, ResponseCode.UNAUTHORIZED, "액세스 토큰이 아닙니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
String userId = jwtTokenProvider.getUserIdFromToken(jwt);
|
||||
String roles = jwtTokenProvider.getRolesFromToken(jwt);
|
||||
|
||||
if (StringUtils.hasText(userId)) {
|
||||
List<SimpleGrantedAuthority> authorities = Arrays.stream(roles.split(","))
|
||||
.map(String::trim)
|
||||
.map(SimpleGrantedAuthority::new)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
UsernamePasswordAuthenticationToken authentication =
|
||||
new UsernamePasswordAuthenticationToken(userId, null, authorities);
|
||||
|
||||
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
|
||||
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("JWT authentication failed", e);
|
||||
sendErrorResponse(response, ResponseCode.UNAUTHORIZED, "인증에 실패했습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 요청에서 JWT 토큰 추출
|
||||
*/
|
||||
private String getJwtFromRequest(HttpServletRequest request) {
|
||||
String bearerToken = request.getHeader(SecurityConstants.JWT_HEADER);
|
||||
|
||||
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(SecurityConstants.JWT_PREFIX)) {
|
||||
return bearerToken.substring(SecurityConstants.JWT_PREFIX.length());
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 에러 응답 전송
|
||||
*/
|
||||
private void sendErrorResponse(HttpServletResponse response, ResponseCode responseCode, String message)
|
||||
throws IOException {
|
||||
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||
response.setCharacterEncoding("UTF-8");
|
||||
|
||||
ApiResponse<Void> errorResponse = ApiResponse.error(responseCode, message);
|
||||
response.getWriter().write(objectMapper.writeValueAsString(errorResponse));
|
||||
}
|
||||
|
||||
/**
|
||||
* 공개 경로인지 확인
|
||||
*/
|
||||
@Override
|
||||
protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
|
||||
String path = request.getRequestURI();
|
||||
|
||||
return Arrays.stream(SecurityConstants.PUBLIC_ENDPOINTS)
|
||||
.anyMatch(pattern -> path.matches(pattern.replace("**", ".*")));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,204 @@
|
||||
package com.ktds.hi.common.security;
|
||||
|
||||
import com.ktds.hi.common.constants.SecurityConstants;
|
||||
import io.jsonwebtoken.*;
|
||||
import io.jsonwebtoken.security.Keys;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.crypto.SecretKey;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Date;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* JWT 토큰 생성 및 검증 제공자
|
||||
* JWT 토큰의 생성, 파싱, 검증 기능을 담당
|
||||
*/
|
||||
@Component
|
||||
@Slf4j
|
||||
public class JwtTokenProvider {
|
||||
|
||||
private final SecretKey secretKey;
|
||||
private final long accessTokenValidityInMilliseconds;
|
||||
private final long refreshTokenValidityInMilliseconds;
|
||||
|
||||
public JwtTokenProvider(
|
||||
@Value("${app.jwt.secret-key:hiorder-secret-key-for-jwt-token-generation-2024}") String secretKeyString,
|
||||
@Value("${app.jwt.access-token-validity:3600000}") long accessTokenValidity,
|
||||
@Value("${app.jwt.refresh-token-validity:604800000}") long refreshTokenValidity) {
|
||||
|
||||
// 비밀키 생성 (256비트 이상이어야 함)
|
||||
byte[] keyBytes = secretKeyString.getBytes(StandardCharsets.UTF_8);
|
||||
if (keyBytes.length < 32) {
|
||||
// 32바이트 미만이면 패딩
|
||||
byte[] paddedKey = new byte[32];
|
||||
System.arraycopy(keyBytes, 0, paddedKey, 0, Math.min(keyBytes.length, 32));
|
||||
keyBytes = paddedKey;
|
||||
}
|
||||
this.secretKey = Keys.hmacShaKeyFor(keyBytes);
|
||||
|
||||
this.accessTokenValidityInMilliseconds = accessTokenValidity;
|
||||
this.refreshTokenValidityInMilliseconds = refreshTokenValidity;
|
||||
}
|
||||
|
||||
/**
|
||||
* 액세스 토큰 생성
|
||||
*/
|
||||
public String createAccessToken(Authentication authentication) {
|
||||
return createToken(authentication, accessTokenValidityInMilliseconds, "access");
|
||||
}
|
||||
|
||||
/**
|
||||
* 리프레시 토큰 생성
|
||||
*/
|
||||
public String createRefreshToken(Authentication authentication) {
|
||||
return createToken(authentication, refreshTokenValidityInMilliseconds, "refresh");
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자 정보로 액세스 토큰 생성
|
||||
*/
|
||||
public String createAccessToken(String userId, String username, String roles) {
|
||||
Date now = new Date();
|
||||
Date expiryDate = new Date(now.getTime() + accessTokenValidityInMilliseconds);
|
||||
|
||||
return Jwts.builder()
|
||||
.setSubject(userId)
|
||||
.claim("username", username)
|
||||
.claim("roles", roles)
|
||||
.claim("type", "access")
|
||||
.setIssuedAt(now)
|
||||
.setExpiration(expiryDate)
|
||||
.signWith(secretKey, SignatureAlgorithm.HS512)
|
||||
.compact();
|
||||
}
|
||||
|
||||
/**
|
||||
* 토큰 생성 공통 메서드
|
||||
*/
|
||||
private String createToken(Authentication authentication, long validityInMilliseconds, String tokenType) {
|
||||
String authorities = authentication.getAuthorities().stream()
|
||||
.map(GrantedAuthority::getAuthority)
|
||||
.collect(Collectors.joining(","));
|
||||
|
||||
Date now = new Date();
|
||||
Date expiryDate = new Date(now.getTime() + validityInMilliseconds);
|
||||
|
||||
return Jwts.builder()
|
||||
.setSubject(authentication.getName())
|
||||
.claim("roles", authorities)
|
||||
.claim("type", tokenType)
|
||||
.setIssuedAt(now)
|
||||
.setExpiration(expiryDate)
|
||||
.signWith(secretKey, SignatureAlgorithm.HS512)
|
||||
.compact();
|
||||
}
|
||||
|
||||
/**
|
||||
* 토큰에서 사용자 ID 추출
|
||||
*/
|
||||
public String getUserIdFromToken(String token) {
|
||||
Claims claims = parseClaimsFromToken(token);
|
||||
return claims.getSubject();
|
||||
}
|
||||
|
||||
/**
|
||||
* 토큰에서 사용자명 추출
|
||||
*/
|
||||
public String getUsernameFromToken(String token) {
|
||||
Claims claims = parseClaimsFromToken(token);
|
||||
return claims.get("username", String.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 토큰에서 권한 추출
|
||||
*/
|
||||
public String getRolesFromToken(String token) {
|
||||
Claims claims = parseClaimsFromToken(token);
|
||||
return claims.get("roles", String.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 토큰에서 만료일 추출
|
||||
*/
|
||||
public Date getExpirationDateFromToken(String token) {
|
||||
Claims claims = parseClaimsFromToken(token);
|
||||
return claims.getExpiration();
|
||||
}
|
||||
|
||||
/**
|
||||
* 토큰 유효성 검증
|
||||
*/
|
||||
public boolean validateToken(String token) {
|
||||
try {
|
||||
parseClaimsFromToken(token);
|
||||
return true;
|
||||
} catch (JwtException | IllegalArgumentException e) {
|
||||
log.debug("Invalid JWT token: {}", e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 액세스 토큰인지 확인
|
||||
*/
|
||||
public boolean isAccessToken(String token) {
|
||||
try {
|
||||
Claims claims = parseClaimsFromToken(token);
|
||||
return "access".equals(claims.get("type", String.class));
|
||||
} catch (JwtException | IllegalArgumentException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 리프레시 토큰인지 확인
|
||||
*/
|
||||
public boolean isRefreshToken(String token) {
|
||||
try {
|
||||
Claims claims = parseClaimsFromToken(token);
|
||||
return "refresh".equals(claims.get("type", String.class));
|
||||
} catch (JwtException | IllegalArgumentException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 토큰 만료 확인
|
||||
*/
|
||||
public boolean isTokenExpired(String token) {
|
||||
try {
|
||||
Date expiration = getExpirationDateFromToken(token);
|
||||
return expiration.before(new Date());
|
||||
} catch (JwtException | IllegalArgumentException e) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 토큰에서 Claims 파싱
|
||||
*/
|
||||
private Claims parseClaimsFromToken(String token) {
|
||||
return Jwts.parserBuilder()
|
||||
.setSigningKey(secretKey)
|
||||
.build()
|
||||
.parseClaimsJws(token)
|
||||
.getBody();
|
||||
}
|
||||
|
||||
/**
|
||||
* 토큰 만료 시간까지 남은 시간 (밀리초)
|
||||
*/
|
||||
public long getTimeUntilExpiration(String token) {
|
||||
try {
|
||||
Date expiration = getExpirationDateFromToken(token);
|
||||
return Math.max(0, expiration.getTime() - System.currentTimeMillis());
|
||||
} catch (JwtException | IllegalArgumentException e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,135 @@
|
||||
package com.ktds.hi.common.security;
|
||||
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 보안 관련 유틸리티 클래스
|
||||
* 현재 인증된 사용자 정보 조회 등의 기능을 제공
|
||||
*/
|
||||
public final class SecurityUtil {
|
||||
|
||||
private SecurityUtil() {
|
||||
// 인스턴스 생성 방지
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 인증된 사용자 ID 조회
|
||||
*/
|
||||
public static Optional<Long> getCurrentUserId() {
|
||||
return getCurrentAuthentication()
|
||||
.map(Authentication::getName)
|
||||
.map(Long::parseLong);
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 인증된 사용자명 조회
|
||||
*/
|
||||
public static Optional<String> getCurrentUsername() {
|
||||
return getCurrentAuthentication()
|
||||
.map(Authentication::getName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 인증된 사용자의 권한 조회
|
||||
*/
|
||||
public static Set<String> getCurrentUserRoles() {
|
||||
return getCurrentAuthentication()
|
||||
.map(auth -> auth.getAuthorities().stream()
|
||||
.map(GrantedAuthority::getAuthority)
|
||||
.collect(Collectors.toSet()))
|
||||
.orElse(Set.of());
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 사용자가 특정 권한을 가지는지 확인
|
||||
*/
|
||||
public static boolean hasRole(String role) {
|
||||
return getCurrentUserRoles().contains(role);
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 사용자가 여러 권한 중 하나라도 가지는지 확인
|
||||
*/
|
||||
public static boolean hasAnyRole(String... roles) {
|
||||
Set<String> userRoles = getCurrentUserRoles();
|
||||
for (String role : roles) {
|
||||
if (userRoles.contains(role)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 사용자가 모든 권한을 가지는지 확인
|
||||
*/
|
||||
public static boolean hasAllRoles(String... roles) {
|
||||
Set<String> userRoles = getCurrentUserRoles();
|
||||
for (String role : roles) {
|
||||
if (!userRoles.contains(role)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 사용자가 인증되었는지 확인
|
||||
*/
|
||||
public static boolean isAuthenticated() {
|
||||
return getCurrentAuthentication()
|
||||
.map(Authentication::isAuthenticated)
|
||||
.orElse(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 사용자가 익명 사용자인지 확인
|
||||
*/
|
||||
public static boolean isAnonymous() {
|
||||
return !isAuthenticated();
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 Authentication 객체 조회
|
||||
*/
|
||||
public static Optional<Authentication> getCurrentAuthentication() {
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
return Optional.ofNullable(authentication);
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 사용자가 리소스 소유자인지 확인
|
||||
*/
|
||||
public static boolean isOwner(Long resourceOwnerId) {
|
||||
return getCurrentUserId()
|
||||
.map(currentUserId -> currentUserId.equals(resourceOwnerId))
|
||||
.orElse(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 사용자가 관리자인지 확인
|
||||
*/
|
||||
public static boolean isAdmin() {
|
||||
return hasRole("ROLE_ADMIN");
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 사용자가 매장 소유자인지 확인
|
||||
*/
|
||||
public static boolean isStoreOwner() {
|
||||
return hasRole("ROLE_STORE_OWNER");
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 사용자가 일반 사용자인지 확인
|
||||
*/
|
||||
public static boolean isUser() {
|
||||
return hasRole("ROLE_USER");
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,92 @@
|
||||
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.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;
|
||||
|
||||
/**
|
||||
* 감사 로그 서비스
|
||||
* 시스템의 중요한 액션들을 비동기적으로 로깅
|
||||
*/
|
||||
@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");
|
||||
|
||||
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={}",
|
||||
action, entityType, entityId, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 생성 로그
|
||||
*/
|
||||
public void logCreate(String entityType, String entityId, String description) {
|
||||
logAsync(AuditAction.CREATE, entityType, entityId, description);
|
||||
}
|
||||
|
||||
/**
|
||||
* 수정 로그
|
||||
*/
|
||||
public void logUpdate(String entityType, String entityId, String description) {
|
||||
logAsync(AuditAction.UPDATE, entityType, entityId, description);
|
||||
}
|
||||
|
||||
/**
|
||||
* 삭제 로그
|
||||
*/
|
||||
public void logDelete(String entityType, String entityId, String description) {
|
||||
logAsync(AuditAction.DELETE, entityType, entityId, description);
|
||||
}
|
||||
|
||||
/**
|
||||
* 접근 로그
|
||||
*/
|
||||
public void logAccess(String entityType, String entityId, String description) {
|
||||
logAsync(AuditAction.ACCESS, entityType, entityId, description);
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인 로그
|
||||
*/
|
||||
public void logLogin(String description) {
|
||||
logAsync(AuditAction.LOGIN, "USER", SecurityUtil.getCurrentUserId().map(String::valueOf).orElse("UNKNOWN"), description);
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그아웃 로그
|
||||
*/
|
||||
public void logLogout(String description) {
|
||||
logAsync(AuditAction.LOGOUT, "USER", SecurityUtil.getCurrentUserId().map(String::valueOf).orElse("UNKNOWN"), description);
|
||||
}
|
||||
}
|
||||
94
common/src/main/resources/application-common.yml
Normal file
94
common/src/main/resources/application-common.yml
Normal file
@ -0,0 +1,94 @@
|
||||
spring:
|
||||
# JPA 설정
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: ${JPA_DDL_AUTO:validate}
|
||||
naming:
|
||||
physical-strategy: org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl
|
||||
implicit-strategy: org.hibernate.boot.model.naming.ImplicitNamingStrategyLegacyJpaImpl
|
||||
show-sql: ${JPA_SHOW_SQL:false}
|
||||
properties:
|
||||
hibernate:
|
||||
format_sql: true
|
||||
use_sql_comments: true
|
||||
jdbc:
|
||||
batch_size: 20
|
||||
order_inserts: true
|
||||
order_updates: true
|
||||
batch_versioned_data: true
|
||||
|
||||
# Redis 설정
|
||||
data:
|
||||
redis:
|
||||
host: ${REDIS_HOST:localhost}
|
||||
port: ${REDIS_PORT:6379}
|
||||
password: ${REDIS_PASSWORD:}
|
||||
timeout: 2000ms
|
||||
lettuce:
|
||||
pool:
|
||||
max-active: 8
|
||||
max-wait: -1ms
|
||||
max-idle: 8
|
||||
min-idle: 0
|
||||
|
||||
# Jackson 설정
|
||||
jackson:
|
||||
time-zone: Asia/Seoul
|
||||
date-format: yyyy-MM-dd HH:mm:ss
|
||||
serialization:
|
||||
write-dates-as-timestamps: false
|
||||
deserialization:
|
||||
fail-on-unknown-properties: false
|
||||
|
||||
# 트랜잭션 설정
|
||||
transaction:
|
||||
default-timeout: 30
|
||||
|
||||
# 애플리케이션 설정
|
||||
app:
|
||||
# JWT 설정
|
||||
jwt:
|
||||
secret-key: ${JWT_SECRET_KEY:hiorder-secret-key-for-jwt-token-generation-2024-very-long-secret-key}
|
||||
access-token-validity: ${JWT_ACCESS_TOKEN_VALIDITY:3600000} # 1시간
|
||||
refresh-token-validity: ${JWT_REFRESH_TOKEN_VALIDITY:604800000} # 7일
|
||||
|
||||
# CORS 설정
|
||||
cors:
|
||||
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:3000,http://localhost:8080}
|
||||
allowed-methods: ${CORS_ALLOWED_METHODS:GET,POST,PUT,DELETE,OPTIONS}
|
||||
allowed-headers: ${CORS_ALLOWED_HEADERS:*}
|
||||
exposed-headers: ${CORS_EXPOSED_HEADERS:Authorization}
|
||||
allow-credentials: ${CORS_ALLOW_CREDENTIALS:true}
|
||||
max-age: ${CORS_MAX_AGE:3600}
|
||||
|
||||
# 캐시 설정
|
||||
cache:
|
||||
default-ttl: ${CACHE_DEFAULT_TTL:3600} # 1시간
|
||||
|
||||
# Swagger 설정
|
||||
swagger:
|
||||
title: ${SWAGGER_TITLE:하이오더 API}
|
||||
description: ${SWAGGER_DESCRIPTION:하이오더 백엔드 API 문서}
|
||||
version: ${SWAGGER_VERSION:1.0.0}
|
||||
server-url: ${SWAGGER_SERVER_URL:http://localhost:8080}
|
||||
|
||||
# 로깅 설정
|
||||
logging:
|
||||
level:
|
||||
com.ktds.hi: ${LOG_LEVEL:INFO}
|
||||
org.springframework.security: ${SECURITY_LOG_LEVEL:INFO}
|
||||
org.hibernate.SQL: ${SQL_LOG_LEVEL:INFO}
|
||||
org.hibernate.type.descriptor.sql.BasicBinder: ${SQL_PARAM_LOG_LEVEL:INFO}
|
||||
pattern:
|
||||
console: "%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n"
|
||||
file: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n"
|
||||
|
||||
# 관리 엔드포인트 설정
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health,info,metrics,prometheus
|
||||
endpoint:
|
||||
health:
|
||||
show-details: when-authorized
|
||||
6
member/build.gradle
Normal file
6
member/build.gradle
Normal file
@ -0,0 +1,6 @@
|
||||
dependencies {
|
||||
implementation project(':common')
|
||||
|
||||
// SMS Service (Optional)
|
||||
implementation 'net.nurigo:sdk:4.3.0'
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
package com.ktds.hi.member;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
/**
|
||||
* 회원 관리 서비스 메인 애플리케이션 클래스
|
||||
* 인증, 회원정보 관리, 취향 관리 기능을 제공
|
||||
*
|
||||
* @author 하이오더 개발팀
|
||||
* @version 1.0.0
|
||||
*/
|
||||
@SpringBootApplication(scanBasePackages = {"com.ktds.hi.member", "com.ktds.hi.common"})
|
||||
public class MemberServiceApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(MemberServiceApplication.class, args);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
package com.ktds.hi.member.config;
|
||||
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
|
||||
|
||||
/**
|
||||
* JPA 설정 클래스
|
||||
*/
|
||||
@Configuration
|
||||
@EnableJpaRepositories(basePackages = "com.ktds.hi.member.repository")
|
||||
public class JpaConfig {
|
||||
}
|
||||
@ -0,0 +1,51 @@
|
||||
package com.ktds.hi.member.config;
|
||||
|
||||
import com.ktds.hi.member.service.JwtTokenProvider;
|
||||
import com.ktds.hi.member.service.AuthService;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* JWT 인증 필터 클래스
|
||||
* 요청 헤더의 JWT 토큰을 검증하고 인증 정보를 설정
|
||||
*/
|
||||
@RequiredArgsConstructor
|
||||
public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
private final JwtTokenProvider tokenProvider;
|
||||
private final AuthService authService;
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
|
||||
FilterChain filterChain) throws ServletException, IOException {
|
||||
|
||||
String token = resolveToken(request);
|
||||
|
||||
if (StringUtils.hasText(token) && tokenProvider.validateToken(token)) {
|
||||
Authentication authentication = tokenProvider.getAuthentication(token);
|
||||
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||
}
|
||||
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 요청 헤더에서 JWT 토큰 추출
|
||||
*/
|
||||
private String resolveToken(HttpServletRequest request) {
|
||||
String bearerToken = request.getHeader("Authorization");
|
||||
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
|
||||
return bearerToken.substring(7);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,66 @@
|
||||
package com.ktds.hi.member.config;
|
||||
|
||||
import com.ktds.hi.member.service.JwtTokenProvider;
|
||||
import com.ktds.hi.member.service.AuthService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.authentication.AuthenticationManager;
|
||||
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
|
||||
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.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
|
||||
/**
|
||||
* Spring Security 설정 클래스
|
||||
* JWT 기반 인증 및 권한 관리 설정
|
||||
*/
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@RequiredArgsConstructor
|
||||
public class SecurityConfig {
|
||||
|
||||
private final JwtTokenProvider jwtTokenProvider;
|
||||
private final AuthService authService;
|
||||
|
||||
/**
|
||||
* 보안 필터 체인 설정
|
||||
* JWT 인증 방식을 사용하고 세션은 무상태로 관리
|
||||
*/
|
||||
@Bean
|
||||
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
.csrf(csrf -> csrf.disable())
|
||||
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.authorizeHttpRequests(authz -> authz
|
||||
.requestMatchers("/api/auth/**", "/api/members/register").permitAll()
|
||||
.requestMatchers("/swagger-ui/**", "/api-docs/**").permitAll()
|
||||
.requestMatchers("/actuator/**").permitAll()
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider, authService),
|
||||
UsernamePasswordAuthenticationFilter.class);
|
||||
|
||||
return http.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀번호 암호화 빈
|
||||
*/
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder();
|
||||
}
|
||||
|
||||
/**
|
||||
* 인증 매니저 빈
|
||||
*/
|
||||
@Bean
|
||||
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
|
||||
return config.getAuthenticationManager();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
package com.ktds.hi.member.config;
|
||||
|
||||
import io.swagger.v3.oas.models.OpenAPI;
|
||||
import io.swagger.v3.oas.models.info.Info;
|
||||
import io.swagger.v3.oas.models.servers.Server;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* Swagger 설정 클래스
|
||||
* API 문서화를 위한 OpenAPI 설정
|
||||
*/
|
||||
@Configuration
|
||||
public class SwaggerConfig {
|
||||
|
||||
@Bean
|
||||
public OpenAPI openAPI() {
|
||||
return new OpenAPI()
|
||||
.addServersItem(new Server().url("/"))
|
||||
.info(new Info()
|
||||
.title("하이오더 회원 관리 서비스 API")
|
||||
.description("회원 가입, 로그인, 취향 관리 등 회원 관련 기능을 제공하는 API")
|
||||
.version("1.0.0"));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,99 @@
|
||||
package com.ktds.hi.member.controller;
|
||||
|
||||
import com.ktds.hi.member.dto.*;
|
||||
import com.ktds.hi.member.service.AuthService;
|
||||
import com.ktds.hi.common.dto.SuccessResponse;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
/**
|
||||
* 인증 컨트롤러 클래스
|
||||
* 로그인, 로그아웃, 토큰 갱신 등 인증 관련 API를 제공
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/auth")
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "인증 API", description = "로그인, 로그아웃, 토큰 관리 등 인증 관련 API")
|
||||
public class AuthController {
|
||||
|
||||
private final AuthService authService;
|
||||
|
||||
/**
|
||||
* 로그인 API
|
||||
*/
|
||||
@PostMapping("/login")
|
||||
@Operation(summary = "로그인", description = "사용자명과 비밀번호로 로그인을 수행합니다.")
|
||||
public ResponseEntity<TokenResponse> login(@Valid @RequestBody LoginRequest request) {
|
||||
TokenResponse response = authService.login(request);
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그아웃 API
|
||||
*/
|
||||
@PostMapping("/logout")
|
||||
@Operation(summary = "로그아웃", description = "현재 로그인된 사용자를 로그아웃 처리합니다.")
|
||||
public ResponseEntity<SuccessResponse> logout(@Valid @RequestBody LogoutRequest request) {
|
||||
authService.logout(request);
|
||||
return ResponseEntity.ok(SuccessResponse.of("로그아웃이 완료되었습니다"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 토큰 갱신 API
|
||||
*/
|
||||
@PostMapping("/refresh")
|
||||
@Operation(summary = "토큰 갱신", description = "리프레시 토큰을 사용해 새로운 액세스 토큰을 발급받습니다.")
|
||||
public ResponseEntity<TokenResponse> refreshToken(@RequestParam String refreshToken) {
|
||||
TokenResponse response = authService.refreshToken(refreshToken);
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 아이디 찾기 API
|
||||
*/
|
||||
@PostMapping("/find-username")
|
||||
@Operation(summary = "아이디 찾기", description = "전화번호를 사용해 아이디를 찾습니다.")
|
||||
public ResponseEntity<SuccessResponse> findUsername(@Valid @RequestBody FindUserIdRequest request) {
|
||||
String username = authService.findUserId(request);
|
||||
return ResponseEntity.ok(SuccessResponse.of("아이디: " + username));
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀번호 찾기 API
|
||||
*/
|
||||
@PostMapping("/find-password")
|
||||
@Operation(summary = "비밀번호 찾기", description = "전화번호로 임시 비밀번호를 SMS로 발송합니다.")
|
||||
public ResponseEntity<SuccessResponse> findPassword(@Valid @RequestBody FindUserIdRequest request) {
|
||||
authService.findPassword(request);
|
||||
return ResponseEntity.ok(SuccessResponse.of("임시 비밀번호가 SMS로 발송되었습니다"));
|
||||
}
|
||||
|
||||
/**
|
||||
* SMS 인증번호 발송 API
|
||||
*/
|
||||
@PostMapping("/sms/send")
|
||||
@Operation(summary = "SMS 인증번호 발송", description = "입력한 전화번호로 인증번호를 발송합니다.")
|
||||
public ResponseEntity<SuccessResponse> sendSmsVerification(@RequestParam String phone) {
|
||||
authService.sendSmsVerification(phone);
|
||||
return ResponseEntity.ok(SuccessResponse.of("인증번호가 발송되었습니다"));
|
||||
}
|
||||
|
||||
/**
|
||||
* SMS 인증번호 확인 API
|
||||
*/
|
||||
@PostMapping("/sms/verify")
|
||||
@Operation(summary = "SMS 인증번호 확인", description = "입력한 인증번호가 올바른지 확인합니다.")
|
||||
public ResponseEntity<SuccessResponse> verifySmsCode(@RequestParam String phone, @RequestParam String code) {
|
||||
boolean isValid = authService.verifySmsCode(phone, code);
|
||||
|
||||
if (isValid) {
|
||||
return ResponseEntity.ok(SuccessResponse.of("인증이 완료되었습니다"));
|
||||
} else {
|
||||
return ResponseEntity.badRequest().body(SuccessResponse.of("인증번호가 올바르지 않습니다"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,104 @@
|
||||
package com.ktds.hi.member.controller;
|
||||
|
||||
import com.ktds.hi.member.dto.*;
|
||||
import com.ktds.hi.member.service.MemberService;
|
||||
import com.ktds.hi.common.dto.SuccessResponse;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
/**
|
||||
* 회원 컨트롤러 클래스
|
||||
* 회원 가입, 정보 조회/수정 등 회원 관리 API를 제공
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/members")
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "회원 관리 API", description = "회원 가입, 정보 조회/수정 등 회원 관리 관련 API")
|
||||
public class MemberController {
|
||||
|
||||
private final MemberService memberService;
|
||||
|
||||
/**
|
||||
* 회원 가입 API
|
||||
*/
|
||||
@PostMapping("/register")
|
||||
@Operation(summary = "회원 가입", description = "새로운 회원을 등록합니다.")
|
||||
public ResponseEntity<SuccessResponse> registerMember(@Valid @RequestBody SignupRequest request) {
|
||||
Long memberId = memberService.registerMember(request);
|
||||
return ResponseEntity.ok(SuccessResponse.of("회원 가입이 완료되었습니다. 회원ID: " + memberId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 마이페이지 정보 조회 API
|
||||
*/
|
||||
@GetMapping("/profile")
|
||||
@Operation(summary = "마이페이지 조회", description = "현재 로그인한 회원의 정보를 조회합니다.")
|
||||
public ResponseEntity<MyPageResponse> getMyPageInfo(Authentication authentication) {
|
||||
Long memberId = Long.valueOf(authentication.getName());
|
||||
MyPageResponse response = memberService.getMyPageInfo(memberId);
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 닉네임 변경 API
|
||||
*/
|
||||
@PutMapping("/nickname")
|
||||
@Operation(summary = "닉네임 변경", description = "현재 로그인한 회원의 닉네임을 변경합니다.")
|
||||
public ResponseEntity<SuccessResponse> updateNickname(Authentication authentication,
|
||||
@Valid @RequestBody UpdateNicknameRequest request) {
|
||||
Long memberId = Long.valueOf(authentication.getName());
|
||||
memberService.updateNickname(memberId, request);
|
||||
return ResponseEntity.ok(SuccessResponse.of("닉네임이 변경되었습니다"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 아이디 변경 API
|
||||
*/
|
||||
@PutMapping("/username")
|
||||
@Operation(summary = "아이디 변경", description = "현재 로그인한 회원의 아이디를 변경합니다.")
|
||||
public ResponseEntity<SuccessResponse> updateUsername(Authentication authentication,
|
||||
@RequestParam String username) {
|
||||
Long memberId = Long.valueOf(authentication.getName());
|
||||
memberService.updateUsername(memberId, username);
|
||||
return ResponseEntity.ok(SuccessResponse.of("아이디가 변경되었습니다"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀번호 변경 API
|
||||
*/
|
||||
@PutMapping("/password")
|
||||
@Operation(summary = "비밀번호 변경", description = "현재 로그인한 회원의 비밀번호를 변경합니다.")
|
||||
public ResponseEntity<SuccessResponse> updatePassword(Authentication authentication,
|
||||
@RequestParam String password) {
|
||||
Long memberId = Long.valueOf(authentication.getName());
|
||||
memberService.updatePassword(memberId, password);
|
||||
return ResponseEntity.ok(SuccessResponse.of("비밀번호가 변경되었습니다"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 아이디 중복 확인 API
|
||||
*/
|
||||
@GetMapping("/check-username")
|
||||
@Operation(summary = "아이디 중복 확인", description = "사용 가능한 아이디인지 확인합니다.")
|
||||
public ResponseEntity<SuccessResponse> checkUsernameAvailability(@RequestParam String username) {
|
||||
boolean isAvailable = memberService.checkUsernameAvailability(username);
|
||||
String message = isAvailable ? "사용 가능한 아이디입니다" : "이미 사용 중인 아이디입니다";
|
||||
return ResponseEntity.ok(SuccessResponse.of(message));
|
||||
}
|
||||
|
||||
/**
|
||||
* 닉네임 중복 확인 API
|
||||
*/
|
||||
@GetMapping("/check-nickname")
|
||||
@Operation(summary = "닉네임 중복 확인", description = "사용 가능한 닉네임인지 확인합니다.")
|
||||
public ResponseEntity<SuccessResponse> checkNicknameAvailability(@RequestParam String nickname) {
|
||||
boolean isAvailable = memberService.checkNicknameAvailability(nickname);
|
||||
String message = isAvailable ? "사용 가능한 닉네임입니다" : "이미 사용 중인 닉네임입니다";
|
||||
return ResponseEntity.ok(SuccessResponse.of(message));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,62 @@
|
||||
package com.ktds.hi.member.controller;
|
||||
|
||||
import com.ktds.hi.member.dto.PreferenceRequest;
|
||||
import com.ktds.hi.member.dto.TasteTagResponse;
|
||||
import com.ktds.hi.member.domain.TagType;
|
||||
import com.ktds.hi.member.service.PreferenceService;
|
||||
import com.ktds.hi.common.dto.SuccessResponse;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 취향 관리 컨트롤러 클래스
|
||||
* 취향 정보 등록/수정 및 태그 관리 API를 제공
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/members/preferences")
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "취향 관리 API", description = "회원 취향 정보 등록/수정 및 태그 관리 관련 API")
|
||||
public class PreferenceController {
|
||||
|
||||
private final PreferenceService preferenceService;
|
||||
|
||||
/**
|
||||
* 취향 정보 등록/수정 API
|
||||
*/
|
||||
@PostMapping
|
||||
@Operation(summary = "취향 정보 등록", description = "회원의 취향 정보를 등록하거나 수정합니다.")
|
||||
public ResponseEntity<SuccessResponse> savePreference(Authentication authentication,
|
||||
@Valid @RequestBody PreferenceRequest request) {
|
||||
Long memberId = Long.valueOf(authentication.getName());
|
||||
preferenceService.savePreference(memberId, request);
|
||||
return ResponseEntity.ok(SuccessResponse.of("취향 정보가 저장되었습니다"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용 가능한 취향 태그 목록 조회 API
|
||||
*/
|
||||
@GetMapping("/tags")
|
||||
@Operation(summary = "취향 태그 목록 조회", description = "사용 가능한 모든 취향 태그 목록을 조회합니다.")
|
||||
public ResponseEntity<List<TasteTagResponse>> getAvailableTags() {
|
||||
List<TasteTagResponse> tags = preferenceService.getAvailableTags();
|
||||
return ResponseEntity.ok(tags);
|
||||
}
|
||||
|
||||
/**
|
||||
* 태그 유형별 태그 목록 조회 API
|
||||
*/
|
||||
@GetMapping("/tags/by-type")
|
||||
@Operation(summary = "유형별 태그 목록 조회", description = "특정 유형의 취향 태그 목록을 조회합니다.")
|
||||
public ResponseEntity<List<TasteTagResponse>> getTagsByType(@RequestParam TagType tagType) {
|
||||
List<TasteTagResponse> tags = preferenceService.getTagsByType(tagType);
|
||||
return ResponseEntity.ok(tags);
|
||||
}
|
||||
}
|
||||
|
||||
76
member/src/main/java/com/ktds/hi/member/domain/Member.java
Normal file
76
member/src/main/java/com/ktds/hi/member/domain/Member.java
Normal file
@ -0,0 +1,76 @@
|
||||
package com.ktds.hi.member.domain;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 회원 도메인 클래스
|
||||
* 회원의 기본 정보를 담는 도메인 객체
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class Member {
|
||||
|
||||
private Long id;
|
||||
private String username;
|
||||
private String password;
|
||||
private String nickname;
|
||||
private String phone;
|
||||
private String role;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
/**
|
||||
* 닉네임 변경
|
||||
*/
|
||||
public Member updateNickname(String newNickname) {
|
||||
return Member.builder()
|
||||
.id(this.id)
|
||||
.username(this.username)
|
||||
.password(this.password)
|
||||
.nickname(newNickname)
|
||||
.phone(this.phone)
|
||||
.role(this.role)
|
||||
.createdAt(this.createdAt)
|
||||
.updatedAt(LocalDateTime.now())
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 아이디 변경
|
||||
*/
|
||||
public Member updateUsername(String newUsername) {
|
||||
return Member.builder()
|
||||
.id(this.id)
|
||||
.username(newUsername)
|
||||
.password(this.password)
|
||||
.nickname(this.nickname)
|
||||
.phone(this.phone)
|
||||
.role(this.role)
|
||||
.createdAt(this.createdAt)
|
||||
.updatedAt(LocalDateTime.now())
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀번호 변경
|
||||
*/
|
||||
public Member updatePassword(String newPassword) {
|
||||
return Member.builder()
|
||||
.id(this.id)
|
||||
.username(this.username)
|
||||
.password(newPassword)
|
||||
.nickname(this.nickname)
|
||||
.phone(this.phone)
|
||||
.role(this.role)
|
||||
.createdAt(this.createdAt)
|
||||
.updatedAt(LocalDateTime.now())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,43 @@
|
||||
package com.ktds.hi.member.domain;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 취향 정보 도메인 클래스
|
||||
* 회원의 음식 취향 및 건강 정보를 담는 도메인 객체
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class Preference {
|
||||
|
||||
private Long id;
|
||||
private Long memberId;
|
||||
private List<String> tags;
|
||||
private String healthInfo;
|
||||
private String spicyLevel;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
/**
|
||||
* 취향 정보 업데이트
|
||||
*/
|
||||
public Preference updatePreference(List<String> newTags, String newHealthInfo, String newSpicyLevel) {
|
||||
return Preference.builder()
|
||||
.id(this.id)
|
||||
.memberId(this.memberId)
|
||||
.tags(newTags)
|
||||
.healthInfo(newHealthInfo)
|
||||
.spicyLevel(newSpicyLevel)
|
||||
.createdAt(this.createdAt)
|
||||
.updatedAt(LocalDateTime.now())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
23
member/src/main/java/com/ktds/hi/member/domain/TagType.java
Normal file
23
member/src/main/java/com/ktds/hi/member/domain/TagType.java
Normal file
@ -0,0 +1,23 @@
|
||||
package com.ktds.hi.member.domain;
|
||||
|
||||
/**
|
||||
* 태그 유형 열거형
|
||||
* 취향 태그의 카테고리를 정의
|
||||
*/
|
||||
public enum TagType {
|
||||
CUISINE("음식 종류"),
|
||||
FLAVOR("맛"),
|
||||
DIETARY("식이 제한"),
|
||||
ATMOSPHERE("분위기"),
|
||||
PRICE("가격대");
|
||||
|
||||
private final String description;
|
||||
|
||||
TagType(String description) {
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
}
|
||||
23
member/src/main/java/com/ktds/hi/member/domain/TasteTag.java
Normal file
23
member/src/main/java/com/ktds/hi/member/domain/TasteTag.java
Normal file
@ -0,0 +1,23 @@
|
||||
package com.ktds.hi.member.domain;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 취향 태그 도메인 클래스
|
||||
* 사용 가능한 취향 태그 정보를 담는 도메인 객체
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class TasteTag {
|
||||
|
||||
private Long id;
|
||||
private String tagName;
|
||||
private TagType tagType;
|
||||
private String description;
|
||||
private Boolean isActive;
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
package com.ktds.hi.member.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 아이디 찾기 요청 DTO
|
||||
*/
|
||||
@Getter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Schema(description = "아이디 찾기 요청")
|
||||
public class FindUserIdRequest {
|
||||
|
||||
@NotBlank(message = "전화번호는 필수입니다")
|
||||
@Pattern(regexp = "^010-\\d{4}-\\d{4}$", message = "전화번호 형식이 올바르지 않습니다")
|
||||
@Schema(description = "전화번호", example = "010-1234-5678")
|
||||
private String phone;
|
||||
}
|
||||
@ -0,0 +1,26 @@
|
||||
package com.ktds.hi.member.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 로그인 요청 DTO
|
||||
* 사용자 로그인 시 필요한 정보를 담는 데이터 전송 객체
|
||||
*/
|
||||
@Getter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Schema(description = "로그인 요청")
|
||||
public class LoginRequest {
|
||||
|
||||
@NotBlank(message = "사용자명은 필수입니다")
|
||||
@Schema(description = "사용자명", example = "test@example.com")
|
||||
private String username;
|
||||
|
||||
@NotBlank(message = "비밀번호는 필수입니다")
|
||||
@Schema(description = "비밀번호", example = "password123")
|
||||
private String password;
|
||||
}
|
||||
@ -0,0 +1,21 @@
|
||||
package com.ktds.hi.member.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 로그아웃 요청 DTO
|
||||
*/
|
||||
@Getter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Schema(description = "로그아웃 요청")
|
||||
public class LogoutRequest {
|
||||
|
||||
@NotBlank(message = "리프레시 토큰은 필수입니다")
|
||||
@Schema(description = "리프레시 토큰")
|
||||
private String refreshToken;
|
||||
}
|
||||
@ -0,0 +1,38 @@
|
||||
package com.ktds.hi.member.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 마이페이지 응답 DTO
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Schema(description = "마이페이지 정보")
|
||||
public class MyPageResponse {
|
||||
|
||||
@Schema(description = "사용자명")
|
||||
private String username;
|
||||
|
||||
@Schema(description = "닉네임")
|
||||
private String nickname;
|
||||
|
||||
@Schema(description = "전화번호")
|
||||
private String phone;
|
||||
|
||||
@Schema(description = "취향 태그 목록")
|
||||
private List<String> preferences;
|
||||
|
||||
@Schema(description = "건강 정보")
|
||||
private String healthInfo;
|
||||
|
||||
@Schema(description = "매운맛 선호도")
|
||||
private String spicyLevel;
|
||||
}
|
||||
@ -0,0 +1,29 @@
|
||||
package com.ktds.hi.member.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 취향 정보 등록 요청 DTO
|
||||
*/
|
||||
@Getter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Schema(description = "취향 정보 등록 요청")
|
||||
public class PreferenceRequest {
|
||||
|
||||
@NotEmpty(message = "취향 태그는 최소 1개 이상 선택해야 합니다")
|
||||
@Schema(description = "취향 태그 목록", example = "[\"한식\", \"매운맛\", \"저칼로리\"]")
|
||||
private List<String> tags;
|
||||
|
||||
@Schema(description = "건강 정보", example = "당뇨 있음")
|
||||
private String healthInfo;
|
||||
|
||||
@Schema(description = "매운맛 선호도", example = "보통")
|
||||
private String spicyLevel;
|
||||
}
|
||||
@ -0,0 +1,37 @@
|
||||
package com.ktds.hi.member.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 회원가입 요청 DTO
|
||||
*/
|
||||
@Getter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Schema(description = "회원가입 요청")
|
||||
public class SignupRequest {
|
||||
|
||||
@NotBlank(message = "사용자명은 필수입니다")
|
||||
@Schema(description = "사용자명", example = "test@example.com")
|
||||
private String username;
|
||||
|
||||
@NotBlank(message = "비밀번호는 필수입니다")
|
||||
@Size(min = 8, message = "비밀번호는 8자 이상이어야 합니다")
|
||||
@Schema(description = "비밀번호", example = "password123")
|
||||
private String password;
|
||||
|
||||
@NotBlank(message = "닉네임은 필수입니다")
|
||||
@Size(min = 2, max = 20, message = "닉네임은 2-20자 사이여야 합니다")
|
||||
@Schema(description = "닉네임", example = "홍길동")
|
||||
private String nickname;
|
||||
|
||||
@Pattern(regexp = "^010-\\d{4}-\\d{4}$", message = "전화번호 형식이 올바르지 않습니다")
|
||||
@Schema(description = "전화번호", example = "010-1234-5678")
|
||||
private String phone;
|
||||
}
|
||||
@ -0,0 +1,31 @@
|
||||
package com.ktds.hi.member.dto;
|
||||
|
||||
import com.ktds.hi.member.domain.TagType;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 취향 태그 응답 DTO
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Schema(description = "취향 태그 정보")
|
||||
public class TasteTagResponse {
|
||||
|
||||
@Schema(description = "태그 ID")
|
||||
private Long id;
|
||||
|
||||
@Schema(description = "태그명")
|
||||
private String tagName;
|
||||
|
||||
@Schema(description = "태그 유형")
|
||||
private TagType tagType;
|
||||
|
||||
@Schema(description = "태그 설명")
|
||||
private String description;
|
||||
}
|
||||
@ -0,0 +1,31 @@
|
||||
package com.ktds.hi.member.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 토큰 응답 DTO
|
||||
* 로그인 성공 시 반환되는 JWT 토큰 정보
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Schema(description = "토큰 응답")
|
||||
public class TokenResponse {
|
||||
|
||||
@Schema(description = "액세스 토큰")
|
||||
private String accessToken;
|
||||
|
||||
@Schema(description = "리프레시 토큰")
|
||||
private String refreshToken;
|
||||
|
||||
@Schema(description = "회원 ID")
|
||||
private Long memberId;
|
||||
|
||||
@Schema(description = "사용자 역할")
|
||||
private String role;
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
package com.ktds.hi.member.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 닉네임 변경 요청 DTO
|
||||
*/
|
||||
@Getter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Schema(description = "닉네임 변경 요청")
|
||||
public class UpdateNicknameRequest {
|
||||
|
||||
@NotBlank(message = "닉네임은 필수입니다")
|
||||
@Size(min = 2, max = 20, message = "닉네임은 2-20자 사이여야 합니다")
|
||||
@Schema(description = "새 닉네임", example = "새닉네임")
|
||||
private String nickname;
|
||||
}
|
||||
@ -0,0 +1,74 @@
|
||||
package com.ktds.hi.member.repository.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
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 엔티티
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "members")
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@EntityListeners(AuditingEntityListener.class)
|
||||
public class MemberEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(unique = true, nullable = false, length = 100)
|
||||
private String username;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String password;
|
||||
|
||||
@Column(unique = true, nullable = false, length = 50)
|
||||
private String nickname;
|
||||
|
||||
@Column(length = 20)
|
||||
private String phone;
|
||||
|
||||
@Column(length = 20)
|
||||
@Builder.Default
|
||||
private String role = "USER";
|
||||
|
||||
@CreatedDate
|
||||
@Column(updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@LastModifiedDate
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
/**
|
||||
* 닉네임 변경
|
||||
*/
|
||||
public void updateNickname(String newNickname) {
|
||||
this.nickname = newNickname;
|
||||
}
|
||||
|
||||
/**
|
||||
* 아이디 변경
|
||||
*/
|
||||
public void updateUsername(String newUsername) {
|
||||
this.username = newUsername;
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀번호 변경
|
||||
*/
|
||||
public void updatePassword(String newPassword) {
|
||||
this.password = newPassword;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,63 @@
|
||||
package com.ktds.hi.member.repository.entity;
|
||||
|
||||
import com.ktds.hi.member.domain.TagType;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.springframework.data.annotation.CreatedDate;
|
||||
import org.springframework.data.annotation.LastModifiedDate;
|
||||
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 취향 정보 엔티티 클래스
|
||||
* 데이터베이스 preferences 테이블과 매핑되는 JPA 엔티티
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "preferences")
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@EntityListeners(AuditingEntityListener.class)
|
||||
public class PreferenceEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "member_id", nullable = false)
|
||||
private Long memberId;
|
||||
|
||||
@ElementCollection
|
||||
@CollectionTable(name = "preference_tags",
|
||||
joinColumns = @JoinColumn(name = "preference_id"))
|
||||
@Column(name = "tag")
|
||||
private List<String> tags;
|
||||
|
||||
@Column(name = "health_info", length = 500)
|
||||
private String healthInfo;
|
||||
|
||||
@Column(name = "spicy_level", length = 20)
|
||||
private String spicyLevel;
|
||||
|
||||
@CreatedDate
|
||||
@Column(updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@LastModifiedDate
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
/**
|
||||
* 취향 정보 업데이트
|
||||
*/
|
||||
public void updatePreference(List<String> newTags, String newHealthInfo, String newSpicyLevel) {
|
||||
this.tags = newTags;
|
||||
this.healthInfo = newHealthInfo;
|
||||
this.spicyLevel = newSpicyLevel;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,39 @@
|
||||
package com.ktds.hi.member.repository.entity;
|
||||
|
||||
import com.ktds.hi.member.domain.TagType;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 취향 태그 엔티티 클래스
|
||||
* 데이터베이스 taste_tags 테이블과 매핑되는 JPA 엔티티
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "taste_tags")
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class TasteTagEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "tag_name", unique = true, nullable = false, length = 50)
|
||||
private String tagName;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "tag_type", nullable = false)
|
||||
private TagType tagType;
|
||||
|
||||
@Column(length = 200)
|
||||
private String description;
|
||||
|
||||
@Column(name = "is_active")
|
||||
@Builder.Default
|
||||
private Boolean isActive = true;
|
||||
}
|
||||
@ -0,0 +1,40 @@
|
||||
package com.ktds.hi.member.repository.jpa;
|
||||
|
||||
import com.ktds.hi.member.repository.entity.MemberEntity;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 회원 JPA 리포지토리 인터페이스
|
||||
* 회원 데이터의 CRUD 작업을 담당
|
||||
*/
|
||||
@Repository
|
||||
public interface MemberRepository extends JpaRepository<MemberEntity, Long> {
|
||||
|
||||
/**
|
||||
* 사용자명으로 회원 조회
|
||||
*/
|
||||
Optional<MemberEntity> findByUsername(String username);
|
||||
|
||||
/**
|
||||
* 닉네임으로 회원 조회
|
||||
*/
|
||||
Optional<MemberEntity> findByNickname(String nickname);
|
||||
|
||||
/**
|
||||
* 전화번호로 회원 조회
|
||||
*/
|
||||
Optional<MemberEntity> findByPhone(String phone);
|
||||
|
||||
/**
|
||||
* 사용자명 존재 여부 확인
|
||||
*/
|
||||
boolean existsByUsername(String username);
|
||||
|
||||
/**
|
||||
* 닉네임 존재 여부 확인
|
||||
*/
|
||||
boolean existsByNickname(String nickname);
|
||||
}
|
||||
@ -0,0 +1,30 @@
|
||||
package com.ktds.hi.member.repository.jpa;
|
||||
|
||||
import com.ktds.hi.member.repository.entity.PreferenceEntity;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 취향 정보 JPA 리포지토리 인터페이스
|
||||
* 취향 정보 데이터의 CRUD 작업을 담당
|
||||
*/
|
||||
@Repository
|
||||
public interface PreferenceRepository extends JpaRepository<PreferenceEntity, Long> {
|
||||
|
||||
/**
|
||||
* 회원 ID로 취향 정보 조회
|
||||
*/
|
||||
Optional<PreferenceEntity> findByMemberId(Long memberId);
|
||||
|
||||
/**
|
||||
* 회원 ID로 취향 정보 존재 여부 확인
|
||||
*/
|
||||
boolean existsByMemberId(Long memberId);
|
||||
|
||||
/**
|
||||
* 회원 ID로 취향 정보 삭제
|
||||
*/
|
||||
void deleteByMemberId(Long memberId);
|
||||
}
|
||||
@ -0,0 +1,31 @@
|
||||
package com.ktds.hi.member.repository.jpa;
|
||||
|
||||
import com.ktds.hi.member.domain.TagType;
|
||||
import com.ktds.hi.member.repository.entity.TasteTagEntity;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 취향 태그 JPA 리포지토리 인터페이스
|
||||
* 취향 태그 데이터의 CRUD 작업을 담당
|
||||
*/
|
||||
@Repository
|
||||
public interface TasteTagRepository extends JpaRepository<TasteTagEntity, Long> {
|
||||
|
||||
/**
|
||||
* 활성화된 태그 목록 조회
|
||||
*/
|
||||
List<TasteTagEntity> findByIsActiveTrue();
|
||||
|
||||
/**
|
||||
* 태그 유형별 태그 목록 조회
|
||||
*/
|
||||
List<TasteTagEntity> findByTagTypeAndIsActiveTrue(TagType tagType);
|
||||
|
||||
/**
|
||||
* 태그명으로 태그 조회
|
||||
*/
|
||||
List<TasteTagEntity> findByTagNameIn(List<String> tagNames);
|
||||
}
|
||||
@ -0,0 +1,45 @@
|
||||
package com.ktds.hi.member.service;
|
||||
|
||||
import com.ktds.hi.member.dto.*;
|
||||
|
||||
/**
|
||||
* 인증 서비스 인터페이스
|
||||
* 로그인, 로그아웃, 토큰 관리 등 인증 관련 기능을 정의
|
||||
*/
|
||||
public interface AuthService {
|
||||
|
||||
/**
|
||||
* 로그인 처리
|
||||
*/
|
||||
TokenResponse login(LoginRequest request);
|
||||
|
||||
/**
|
||||
* 로그아웃 처리
|
||||
*/
|
||||
void logout(LogoutRequest request);
|
||||
|
||||
/**
|
||||
* 토큰 갱신
|
||||
*/
|
||||
TokenResponse refreshToken(String refreshToken);
|
||||
|
||||
/**
|
||||
* 아이디 찾기
|
||||
*/
|
||||
String findUserId(FindUserIdRequest request);
|
||||
|
||||
/**
|
||||
* 비밀번호 찾기 (SMS 발송)
|
||||
*/
|
||||
void findPassword(FindUserIdRequest request);
|
||||
|
||||
/**
|
||||
* SMS 인증번호 발송
|
||||
*/
|
||||
void sendSmsVerification(String phone);
|
||||
|
||||
/**
|
||||
* SMS 인증번호 확인
|
||||
*/
|
||||
boolean verifySmsCode(String phone, String code);
|
||||
}
|
||||
@ -0,0 +1,170 @@
|
||||
package com.ktds.hi.member.service;
|
||||
|
||||
import com.ktds.hi.member.dto.*;
|
||||
import com.ktds.hi.member.repository.entity.MemberEntity;
|
||||
import com.ktds.hi.member.repository.jpa.MemberRepository;
|
||||
import com.ktds.hi.common.exception.BusinessException;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* 인증 서비스 구현체
|
||||
* 로그인, 로그아웃, 토큰 관리 등 인증 관련 기능을 구현
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class AuthServiceImpl implements AuthService {
|
||||
|
||||
private final MemberRepository memberRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final JwtTokenProvider jwtTokenProvider;
|
||||
private final SmsService smsService;
|
||||
private final RedisTemplate<String, String> redisTemplate;
|
||||
|
||||
@Override
|
||||
public TokenResponse login(LoginRequest request) {
|
||||
// 회원 조회
|
||||
MemberEntity member = memberRepository.findByUsername(request.getUsername())
|
||||
.orElseThrow(() -> new BusinessException("존재하지 않는 사용자입니다"));
|
||||
|
||||
// 비밀번호 검증
|
||||
if (!passwordEncoder.matches(request.getPassword(), member.getPassword())) {
|
||||
throw new BusinessException("비밀번호가 일치하지 않습니다");
|
||||
}
|
||||
|
||||
// JWT 토큰 생성
|
||||
String accessToken = jwtTokenProvider.generateAccessToken(member.getId(), member.getRole());
|
||||
String refreshToken = jwtTokenProvider.generateRefreshToken(member.getId());
|
||||
|
||||
// 리프레시 토큰 Redis 저장
|
||||
redisTemplate.opsForValue().set(
|
||||
"refresh_token:" + member.getId(),
|
||||
refreshToken,
|
||||
7, TimeUnit.DAYS
|
||||
);
|
||||
|
||||
return TokenResponse.builder()
|
||||
.accessToken(accessToken)
|
||||
.refreshToken(refreshToken)
|
||||
.memberId(member.getId())
|
||||
.role(member.getRole())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void logout(LogoutRequest request) {
|
||||
// 리프레시 토큰에서 사용자 ID 추출
|
||||
Long memberId = jwtTokenProvider.getMemberIdFromToken(request.getRefreshToken());
|
||||
|
||||
// Redis에서 리프레시 토큰 삭제
|
||||
redisTemplate.delete("refresh_token:" + memberId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public TokenResponse refreshToken(String refreshToken) {
|
||||
// 토큰 유효성 검증
|
||||
if (!jwtTokenProvider.validateToken(refreshToken)) {
|
||||
throw new BusinessException("유효하지 않은 리프레시 토큰입니다");
|
||||
}
|
||||
|
||||
Long memberId = jwtTokenProvider.getMemberIdFromToken(refreshToken);
|
||||
|
||||
// Redis에서 리프레시 토큰 확인
|
||||
String storedToken = redisTemplate.opsForValue().get("refresh_token:" + memberId);
|
||||
if (!refreshToken.equals(storedToken)) {
|
||||
throw new BusinessException("유효하지 않은 리프레시 토큰입니다");
|
||||
}
|
||||
|
||||
// 회원 정보 조회
|
||||
MemberEntity member = memberRepository.findById(memberId)
|
||||
.orElseThrow(() -> new BusinessException("존재하지 않는 사용자입니다"));
|
||||
|
||||
// 새 토큰 생성
|
||||
String newAccessToken = jwtTokenProvider.generateAccessToken(member.getId(), member.getRole());
|
||||
String newRefreshToken = jwtTokenProvider.generateRefreshToken(member.getId());
|
||||
|
||||
// 새 리프레시 토큰 Redis 저장
|
||||
redisTemplate.opsForValue().set(
|
||||
"refresh_token:" + member.getId(),
|
||||
newRefreshToken,
|
||||
7, TimeUnit.DAYS
|
||||
);
|
||||
|
||||
return TokenResponse.builder()
|
||||
.accessToken(newAccessToken)
|
||||
.refreshToken(newRefreshToken)
|
||||
.memberId(member.getId())
|
||||
.role(member.getRole())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String findUserId(FindUserIdRequest request) {
|
||||
MemberEntity member = memberRepository.findByPhone(request.getPhone())
|
||||
.orElseThrow(() -> new BusinessException("해당 전화번호로 가입된 계정이 없습니다"));
|
||||
|
||||
return member.getUsername();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void findPassword(FindUserIdRequest request) {
|
||||
MemberEntity member = memberRepository.findByPhone(request.getPhone())
|
||||
.orElseThrow(() -> new BusinessException("해당 전화번호로 가입된 계정이 없습니다"));
|
||||
|
||||
// 임시 비밀번호 생성 및 SMS 발송
|
||||
String tempPassword = generateTempPassword();
|
||||
smsService.sendTempPassword(request.getPhone(), tempPassword);
|
||||
|
||||
// 임시 비밀번호로 업데이트
|
||||
member.updatePassword(passwordEncoder.encode(tempPassword));
|
||||
memberRepository.save(member);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void sendSmsVerification(String phone) {
|
||||
String verificationCode = generateVerificationCode();
|
||||
|
||||
// SMS 발송
|
||||
smsService.sendVerificationCode(phone, verificationCode);
|
||||
|
||||
// Redis에 인증코드 저장 (5분 만료)
|
||||
redisTemplate.opsForValue().set(
|
||||
"sms_code:" + phone,
|
||||
verificationCode,
|
||||
5, TimeUnit.MINUTES
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean verifySmsCode(String phone, String code) {
|
||||
String storedCode = redisTemplate.opsForValue().get("sms_code:" + phone);
|
||||
|
||||
if (storedCode != null && storedCode.equals(code)) {
|
||||
// 인증 성공 시 코드 삭제
|
||||
redisTemplate.delete("sms_code:" + phone);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 임시 비밀번호 생성
|
||||
*/
|
||||
private String generateTempPassword() {
|
||||
return "temp" + System.currentTimeMillis();
|
||||
}
|
||||
|
||||
/**
|
||||
* SMS 인증코드 생성
|
||||
*/
|
||||
private String generateVerificationCode() {
|
||||
return String.valueOf((int)(Math.random() * 900000) + 100000);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,114 @@
|
||||
package com.ktds.hi.member.service;
|
||||
|
||||
import io.jsonwebtoken.*;
|
||||
import io.jsonwebtoken.security.Keys;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.crypto.SecretKey;
|
||||
import java.util.Collections;
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* JWT 토큰 프로바이더 클래스
|
||||
* JWT 토큰 생성, 검증, 파싱 기능을 제공
|
||||
*/
|
||||
@Component
|
||||
@Slf4j
|
||||
public class JwtTokenProvider {
|
||||
|
||||
private final SecretKey secretKey;
|
||||
private final long accessTokenExpiration;
|
||||
private final long refreshTokenExpiration;
|
||||
|
||||
public JwtTokenProvider(@Value("${jwt.secret}") String secret,
|
||||
@Value("${jwt.access-token-expiration}") long accessTokenExpiration,
|
||||
@Value("${jwt.refresh-token-expiration}") long refreshTokenExpiration) {
|
||||
this.secretKey = Keys.hmacShaKeyFor(secret.getBytes());
|
||||
this.accessTokenExpiration = accessTokenExpiration;
|
||||
this.refreshTokenExpiration = refreshTokenExpiration;
|
||||
}
|
||||
|
||||
/**
|
||||
* 액세스 토큰 생성
|
||||
*/
|
||||
public String generateAccessToken(Long memberId, String role) {
|
||||
Date now = new Date();
|
||||
Date expiration = new Date(now.getTime() + accessTokenExpiration);
|
||||
|
||||
return Jwts.builder()
|
||||
.setSubject(memberId.toString())
|
||||
.claim("role", role)
|
||||
.setIssuedAt(now)
|
||||
.setExpiration(expiration)
|
||||
.signWith(secretKey)
|
||||
.compact();
|
||||
}
|
||||
|
||||
/**
|
||||
* 리프레시 토큰 생성
|
||||
*/
|
||||
public String generateRefreshToken(Long memberId) {
|
||||
Date now = new Date();
|
||||
Date expiration = new Date(now.getTime() + refreshTokenExpiration);
|
||||
|
||||
return Jwts.builder()
|
||||
.setSubject(memberId.toString())
|
||||
.setIssuedAt(now)
|
||||
.setExpiration(expiration)
|
||||
.signWith(secretKey)
|
||||
.compact();
|
||||
}
|
||||
|
||||
/**
|
||||
* 토큰에서 인증 정보 추출
|
||||
*/
|
||||
public Authentication getAuthentication(String token) {
|
||||
Claims claims = parseClaims(token);
|
||||
|
||||
String memberId = claims.getSubject();
|
||||
String role = claims.get("role", String.class);
|
||||
|
||||
return new UsernamePasswordAuthenticationToken(
|
||||
memberId,
|
||||
null,
|
||||
Collections.singletonList(new SimpleGrantedAuthority("ROLE_" + role))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 토큰에서 회원 ID 추출
|
||||
*/
|
||||
public Long getMemberIdFromToken(String token) {
|
||||
Claims claims = parseClaims(token);
|
||||
return Long.valueOf(claims.getSubject());
|
||||
}
|
||||
|
||||
/**
|
||||
* 토큰 유효성 검증
|
||||
*/
|
||||
public boolean validateToken(String token) {
|
||||
try {
|
||||
parseClaims(token);
|
||||
return true;
|
||||
} catch (JwtException | IllegalArgumentException e) {
|
||||
log.warn("Invalid JWT token: {}", e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 토큰 파싱
|
||||
*/
|
||||
private Claims parseClaims(String token) {
|
||||
return Jwts.parserBuilder()
|
||||
.setSigningKey(secretKey)
|
||||
.build()
|
||||
.parseClaimsJws(token)
|
||||
.getBody();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,45 @@
|
||||
package com.ktds.hi.member.service;
|
||||
|
||||
import com.ktds.hi.member.dto.*;
|
||||
|
||||
/**
|
||||
* 회원 서비스 인터페이스
|
||||
* 회원 가입, 정보 조회/수정 등 회원 관리 기능을 정의
|
||||
*/
|
||||
public interface MemberService {
|
||||
|
||||
/**
|
||||
* 회원 가입
|
||||
*/
|
||||
Long registerMember(SignupRequest request);
|
||||
|
||||
/**
|
||||
* 마이페이지 정보 조회
|
||||
*/
|
||||
MyPageResponse getMyPageInfo(Long memberId);
|
||||
|
||||
/**
|
||||
* 닉네임 변경
|
||||
*/
|
||||
void updateNickname(Long memberId, UpdateNicknameRequest request);
|
||||
|
||||
/**
|
||||
* 아이디 변경
|
||||
*/
|
||||
void updateUsername(Long memberId, String newUsername);
|
||||
|
||||
/**
|
||||
* 비밀번호 변경
|
||||
*/
|
||||
void updatePassword(Long memberId, String newPassword);
|
||||
|
||||
/**
|
||||
* 아이디 중복 확인
|
||||
*/
|
||||
boolean checkUsernameAvailability(String username);
|
||||
|
||||
/**
|
||||
* 닉네임 중복 확인
|
||||
*/
|
||||
boolean checkNicknameAvailability(String nickname);
|
||||
}
|
||||
@ -0,0 +1,131 @@
|
||||
package com.ktds.hi.member.service;
|
||||
|
||||
import com.ktds.hi.member.dto.*;
|
||||
import com.ktds.hi.member.repository.entity.MemberEntity;
|
||||
import com.ktds.hi.member.repository.entity.PreferenceEntity;
|
||||
import com.ktds.hi.member.repository.jpa.MemberRepository;
|
||||
import com.ktds.hi.member.repository.jpa.PreferenceRepository;
|
||||
import com.ktds.hi.common.exception.BusinessException;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.Collections;
|
||||
|
||||
/**
|
||||
* 회원 서비스 구현체
|
||||
* 회원 가입, 정보 조회/수정 등 회원 관리 기능을 구현
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
@Transactional
|
||||
public class MemberServiceImpl implements MemberService {
|
||||
|
||||
private final MemberRepository memberRepository;
|
||||
private final PreferenceRepository preferenceRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
|
||||
@Override
|
||||
public Long registerMember(SignupRequest request) {
|
||||
// 중복 검사
|
||||
if (memberRepository.existsByUsername(request.getUsername())) {
|
||||
throw new BusinessException("이미 사용 중인 사용자명입니다");
|
||||
}
|
||||
|
||||
if (memberRepository.existsByNickname(request.getNickname())) {
|
||||
throw new BusinessException("이미 사용 중인 닉네임입니다");
|
||||
}
|
||||
|
||||
// 회원 생성
|
||||
MemberEntity member = MemberEntity.builder()
|
||||
.username(request.getUsername())
|
||||
.password(passwordEncoder.encode(request.getPassword()))
|
||||
.nickname(request.getNickname())
|
||||
.phone(request.getPhone())
|
||||
.role("USER")
|
||||
.build();
|
||||
|
||||
MemberEntity savedMember = memberRepository.save(member);
|
||||
|
||||
log.info("회원 가입 완료: memberId={}, username={}", savedMember.getId(), savedMember.getUsername());
|
||||
|
||||
return savedMember.getId();
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public MyPageResponse getMyPageInfo(Long memberId) {
|
||||
MemberEntity member = memberRepository.findById(memberId)
|
||||
.orElseThrow(() -> new BusinessException("존재하지 않는 회원입니다"));
|
||||
|
||||
PreferenceEntity preference = preferenceRepository.findByMemberId(memberId)
|
||||
.orElse(null);
|
||||
|
||||
return MyPageResponse.builder()
|
||||
.username(member.getUsername())
|
||||
.nickname(member.getNickname())
|
||||
.phone(member.getPhone())
|
||||
.preferences(preference != null ? preference.getTags() : Collections.emptyList())
|
||||
.healthInfo(preference != null ? preference.getHealthInfo() : null)
|
||||
.spicyLevel(preference != null ? preference.getSpicyLevel() : null)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateNickname(Long memberId, UpdateNicknameRequest request) {
|
||||
MemberEntity member = memberRepository.findById(memberId)
|
||||
.orElseThrow(() -> new BusinessException("존재하지 않는 회원입니다"));
|
||||
|
||||
// 닉네임 중복 검사
|
||||
if (memberRepository.existsByNickname(request.getNickname())) {
|
||||
throw new BusinessException("이미 사용 중인 닉네임입니다");
|
||||
}
|
||||
|
||||
member.updateNickname(request.getNickname());
|
||||
memberRepository.save(member);
|
||||
|
||||
log.info("닉네임 변경 완료: memberId={}, newNickname={}", memberId, request.getNickname());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateUsername(Long memberId, String newUsername) {
|
||||
MemberEntity member = memberRepository.findById(memberId)
|
||||
.orElseThrow(() -> new BusinessException("존재하지 않는 회원입니다"));
|
||||
|
||||
// 아이디 중복 검사
|
||||
if (memberRepository.existsByUsername(newUsername)) {
|
||||
throw new BusinessException("이미 사용 중인 사용자명입니다");
|
||||
}
|
||||
|
||||
member.updateUsername(newUsername);
|
||||
memberRepository.save(member);
|
||||
|
||||
log.info("아이디 변경 완료: memberId={}, newUsername={}", memberId, newUsername);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updatePassword(Long memberId, String newPassword) {
|
||||
MemberEntity member = memberRepository.findById(memberId)
|
||||
.orElseThrow(() -> new BusinessException("존재하지 않는 회원입니다"));
|
||||
|
||||
member.updatePassword(passwordEncoder.encode(newPassword));
|
||||
memberRepository.save(member);
|
||||
|
||||
log.info("비밀번호 변경 완료: memberId={}", memberId);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public boolean checkUsernameAvailability(String username) {
|
||||
return !memberRepository.existsByUsername(username);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public boolean checkNicknameAvailability(String nickname) {
|
||||
return !memberRepository.existsByNickname(nickname);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,29 @@
|
||||
package com.ktds.hi.member.service;
|
||||
|
||||
import com.ktds.hi.member.dto.PreferenceRequest;
|
||||
import com.ktds.hi.member.dto.TasteTagResponse;
|
||||
import com.ktds.hi.member.domain.TagType;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 취향 관리 서비스 인터페이스
|
||||
* 취향 정보 등록/수정 및 태그 관리 기능을 정의
|
||||
*/
|
||||
public interface PreferenceService {
|
||||
|
||||
/**
|
||||
* 취향 정보 등록/수정
|
||||
*/
|
||||
void savePreference(Long memberId, PreferenceRequest request);
|
||||
|
||||
/**
|
||||
* 사용 가능한 취향 태그 목록 조회
|
||||
*/
|
||||
List<TasteTagResponse> getAvailableTags();
|
||||
|
||||
/**
|
||||
* 태그 유형별 태그 목록 조회
|
||||
*/
|
||||
List<TasteTagResponse> getTagsByType(TagType tagType);
|
||||
}
|
||||
@ -0,0 +1,91 @@
|
||||
package com.ktds.hi.member.service;
|
||||
|
||||
import com.ktds.hi.member.dto.PreferenceRequest;
|
||||
import com.ktds.hi.member.dto.TasteTagResponse;
|
||||
import com.ktds.hi.member.domain.TagType;
|
||||
import com.ktds.hi.member.repository.entity.PreferenceEntity;
|
||||
import com.ktds.hi.member.repository.entity.TasteTagEntity;
|
||||
import com.ktds.hi.member.repository.jpa.PreferenceRepository;
|
||||
import com.ktds.hi.member.repository.jpa.TasteTagRepository;
|
||||
import com.ktds.hi.common.exception.BusinessException;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 취향 관리 서비스 구현체
|
||||
* 취향 정보 등록/수정 및 태그 관리 기능을 구현
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
@Transactional
|
||||
public class PreferenceServiceImpl implements PreferenceService {
|
||||
|
||||
private final PreferenceRepository preferenceRepository;
|
||||
private final TasteTagRepository tasteTagRepository;
|
||||
|
||||
@Override
|
||||
public void savePreference(Long memberId, PreferenceRequest request) {
|
||||
// 태그 유효성 검증
|
||||
List<TasteTagEntity> existingTags = tasteTagRepository.findByTagNameIn(request.getTags());
|
||||
if (existingTags.size() != request.getTags().size()) {
|
||||
throw new BusinessException("유효하지 않은 태그가 포함되어 있습니다");
|
||||
}
|
||||
|
||||
// 기존 취향 정보 조회
|
||||
PreferenceEntity preference = preferenceRepository.findByMemberId(memberId)
|
||||
.orElse(null);
|
||||
|
||||
if (preference != null) {
|
||||
// 기존 정보 업데이트
|
||||
preference.updatePreference(request.getTags(), request.getHealthInfo(), request.getSpicyLevel());
|
||||
} else {
|
||||
// 새로운 취향 정보 생성
|
||||
preference = PreferenceEntity.builder()
|
||||
.memberId(memberId)
|
||||
.tags(request.getTags())
|
||||
.healthInfo(request.getHealthInfo())
|
||||
.spicyLevel(request.getSpicyLevel())
|
||||
.build();
|
||||
}
|
||||
|
||||
preferenceRepository.save(preference);
|
||||
|
||||
log.info("취향 정보 저장 완료: memberId={}, tags={}", memberId, request.getTags());
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public List<TasteTagResponse> getAvailableTags() {
|
||||
List<TasteTagEntity> tags = tasteTagRepository.findByIsActiveTrue();
|
||||
|
||||
return tags.stream()
|
||||
.map(tag -> TasteTagResponse.builder()
|
||||
.id(tag.getId())
|
||||
.tagName(tag.getTagName())
|
||||
.tagType(tag.getTagType())
|
||||
.description(tag.getDescription())
|
||||
.build())
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public List<TasteTagResponse> getTagsByType(TagType tagType) {
|
||||
List<TasteTagEntity> tags = tasteTagRepository.findByTagTypeAndIsActiveTrue(tagType);
|
||||
|
||||
return tags.stream()
|
||||
.map(tag -> TasteTagResponse.builder()
|
||||
.id(tag.getId())
|
||||
.tagName(tag.getTagName())
|
||||
.tagType(tag.getTagType())
|
||||
.description(tag.getDescription())
|
||||
.build())
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,43 @@
|
||||
package com.ktds.hi.member.service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* SMS 서비스 클래스
|
||||
* SMS 발송 기능을 담당 (실제 구현은 외부 SMS API 연동 필요)
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
public class SmsService {
|
||||
|
||||
@Value("${sms.api-key:}")
|
||||
private String apiKey;
|
||||
|
||||
@Value("${sms.api-secret:}")
|
||||
private String apiSecret;
|
||||
|
||||
@Value("${sms.from-number:}")
|
||||
private String fromNumber;
|
||||
|
||||
/**
|
||||
* SMS 인증번호 발송
|
||||
*/
|
||||
public void sendVerificationCode(String phone, String code) {
|
||||
String message = String.format("[하이오더] 인증번호는 %s입니다.", code);
|
||||
|
||||
// TODO: 실제 SMS API 연동 구현
|
||||
log.info("SMS 발송: phone={}, message={}", phone, message);
|
||||
}
|
||||
|
||||
/**
|
||||
* 임시 비밀번호 SMS 발송
|
||||
*/
|
||||
public void sendTempPassword(String phone, String tempPassword) {
|
||||
String message = String.format("[하이오더] 임시 비밀번호는 %s입니다. 로그인 후 비밀번호를 변경해주세요.", tempPassword);
|
||||
|
||||
// TODO: 실제 SMS API 연동 구현
|
||||
log.info("임시 비밀번호 SMS 발송: phone={}, tempPassword={}", phone, tempPassword);
|
||||
}
|
||||
}
|
||||
55
member/src/main/resources/application.yml
Normal file
55
member/src/main/resources/application.yml
Normal file
@ -0,0 +1,55 @@
|
||||
server:
|
||||
port: ${MEMBER_SERVICE_PORT:8081}
|
||||
|
||||
spring:
|
||||
application:
|
||||
name: member-service
|
||||
|
||||
datasource:
|
||||
url: ${MEMBER_DB_URL:jdbc:postgresql://localhost:5432/hiorder_member}
|
||||
username: ${MEMBER_DB_USERNAME:hiorder_user}
|
||||
password: ${MEMBER_DB_PASSWORD:hiorder_pass}
|
||||
driver-class-name: org.postgresql.Driver
|
||||
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: ${JPA_DDL_AUTO:validate}
|
||||
show-sql: ${JPA_SHOW_SQL:false}
|
||||
properties:
|
||||
hibernate:
|
||||
format_sql: true
|
||||
dialect: org.hibernate.dialect.PostgreSQLDialect
|
||||
|
||||
redis:
|
||||
host: ${REDIS_HOST:localhost}
|
||||
port: ${REDIS_PORT:6379}
|
||||
password: ${REDIS_PASSWORD:}
|
||||
timeout: 2000ms
|
||||
lettuce:
|
||||
pool:
|
||||
max-active: 8
|
||||
max-wait: -1ms
|
||||
max-idle: 8
|
||||
min-idle: 0
|
||||
|
||||
jwt:
|
||||
secret: ${JWT_SECRET:hiorder-secret-key-for-jwt-token-generation-must-be-long-enough}
|
||||
access-token-expiration: ${JWT_ACCESS_EXPIRATION:3600000} # 1시간
|
||||
refresh-token-expiration: ${JWT_REFRESH_EXPIRATION:604800000} # 7일
|
||||
|
||||
sms:
|
||||
api-key: ${SMS_API_KEY:}
|
||||
api-secret: ${SMS_API_SECRET:}
|
||||
from-number: ${SMS_FROM_NUMBER:}
|
||||
|
||||
springdoc:
|
||||
api-docs:
|
||||
path: /api-docs
|
||||
swagger-ui:
|
||||
path: /swagger-ui.html
|
||||
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health,info,metrics
|
||||
6
recommend/build.gradle
Normal file
6
recommend/build.gradle
Normal file
@ -0,0 +1,6 @@
|
||||
dependencies {
|
||||
implementation project(':common')
|
||||
|
||||
// AI and Location Services
|
||||
implementation 'org.springframework.boot:spring-boot-starter-webflux'
|
||||
}
|
||||
@ -0,0 +1,21 @@
|
||||
package com.ktds.hi.recommend;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
|
||||
|
||||
/**
|
||||
* 추천 서비스 메인 애플리케이션 클래스
|
||||
* 가게 추천, 취향 분석 기능을 제공
|
||||
*
|
||||
* @author 하이오더 개발팀
|
||||
* @version 1.0.0
|
||||
*/
|
||||
@SpringBootApplication(scanBasePackages = {"com.ktds.hi.recommend", "com.ktds.hi.common"})
|
||||
@EnableJpaAuditing
|
||||
public class RecommendServiceApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(RecommendServiceApplication.class, args);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,40 @@
|
||||
package com.ktds.hi.recommend.biz.domain;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 위치 도메인 클래스
|
||||
* 위치 정보를 담는 도메인 객체
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class Location {
|
||||
|
||||
private Long id;
|
||||
private String address;
|
||||
private Double latitude;
|
||||
private Double longitude;
|
||||
private String city;
|
||||
private String district;
|
||||
private String country;
|
||||
|
||||
/**
|
||||
* 좌표 업데이트
|
||||
*/
|
||||
public Location updateCoordinates(Double newLatitude, Double newLongitude) {
|
||||
return Location.builder()
|
||||
.id(this.id)
|
||||
.address(this.address)
|
||||
.latitude(newLatitude)
|
||||
.longitude(newLongitude)
|
||||
.city(this.city)
|
||||
.district(this.district)
|
||||
.country(this.country)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,41 @@
|
||||
package com.ktds.hi.recommend.biz.domain;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 추천 히스토리 도메인 클래스
|
||||
* 사용자의 추천 기록을 담는 도메인 객체
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class RecommendHistory {
|
||||
|
||||
private Long id;
|
||||
private Long memberId;
|
||||
private List<Long> recommendedStoreIds;
|
||||
private RecommendType recommendType;
|
||||
private String criteria;
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
/**
|
||||
* 추천 기준 업데이트
|
||||
*/
|
||||
public RecommendHistory updateCriteria(String newCriteria) {
|
||||
return RecommendHistory.builder()
|
||||
.id(this.id)
|
||||
.memberId(this.memberId)
|
||||
.recommendedStoreIds(this.recommendedStoreIds)
|
||||
.recommendType(this.recommendType)
|
||||
.criteria(newCriteria)
|
||||
.createdAt(this.createdAt)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,69 @@
|
||||
package com.ktds.hi.recommend.biz.domain;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 추천 매장 도메인 클래스
|
||||
* 추천된 매장 정보를 담는 도메인 객체
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class RecommendStore {
|
||||
|
||||
private Long storeId;
|
||||
private String storeName;
|
||||
private String address;
|
||||
private String category;
|
||||
private List<String> tags;
|
||||
private Double rating;
|
||||
private Integer reviewCount;
|
||||
private Double distance;
|
||||
private Double recommendScore;
|
||||
private RecommendType recommendType;
|
||||
private String recommendReason;
|
||||
|
||||
/**
|
||||
* 추천 점수 업데이트
|
||||
*/
|
||||
public RecommendStore updateRecommendScore(Double newScore) {
|
||||
return RecommendStore.builder()
|
||||
.storeId(this.storeId)
|
||||
.storeName(this.storeName)
|
||||
.address(this.address)
|
||||
.category(this.category)
|
||||
.tags(this.tags)
|
||||
.rating(this.rating)
|
||||
.reviewCount(this.reviewCount)
|
||||
.distance(this.distance)
|
||||
.recommendScore(newScore)
|
||||
.recommendType(this.recommendType)
|
||||
.recommendReason(this.recommendReason)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 추천 이유 업데이트
|
||||
*/
|
||||
public RecommendStore updateRecommendReason(String newReason) {
|
||||
return RecommendStore.builder()
|
||||
.storeId(this.storeId)
|
||||
.storeName(this.storeName)
|
||||
.address(this.address)
|
||||
.category(this.category)
|
||||
.tags(this.tags)
|
||||
.rating(this.rating)
|
||||
.reviewCount(this.reviewCount)
|
||||
.distance(this.distance)
|
||||
.recommendScore(this.recommendScore)
|
||||
.recommendType(this.recommendType)
|
||||
.recommendReason(newReason)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,24 @@
|
||||
package com.ktds.hi.recommend.biz.domain;
|
||||
|
||||
/**
|
||||
* 추천 유형 열거형
|
||||
* 추천의 종류를 정의
|
||||
*/
|
||||
public enum RecommendType {
|
||||
TASTE_BASED("취향 기반"),
|
||||
LOCATION_BASED("위치 기반"),
|
||||
POPULARITY_BASED("인기 기반"),
|
||||
COLLABORATIVE_FILTERING("협업 필터링"),
|
||||
AI_RECOMMENDATION("AI 추천"),
|
||||
SIMILAR_USER("유사 사용자 기반");
|
||||
|
||||
private final String description;
|
||||
|
||||
RecommendType(String description) {
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,30 @@
|
||||
package com.ktds.hi.recommend.biz.domain;
|
||||
|
||||
/**
|
||||
* 취향 카테고리 열거형
|
||||
* 음식 카테고리를 정의
|
||||
*/
|
||||
public enum TasteCategory {
|
||||
KOREAN("한식"),
|
||||
CHINESE("중식"),
|
||||
JAPANESE("일식"),
|
||||
WESTERN("양식"),
|
||||
FAST_FOOD("패스트푸드"),
|
||||
CAFE("카페"),
|
||||
DESSERT("디저트"),
|
||||
CHICKEN("치킨"),
|
||||
PIZZA("피자"),
|
||||
ASIAN("아시안"),
|
||||
VEGETARIAN("채식"),
|
||||
SEAFOOD("해산물");
|
||||
|
||||
private final String description;
|
||||
|
||||
TasteCategory(String description) {
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,52 @@
|
||||
package com.ktds.hi.recommend.biz.domain;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 취향 프로필 도메인 클래스
|
||||
* 사용자의 취향 분석 결과를 담는 도메인 객체
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class TasteProfile {
|
||||
|
||||
private Long id;
|
||||
private Long memberId;
|
||||
private List<TasteCategory> preferredCategories;
|
||||
private Map<String, Double> categoryScores;
|
||||
private List<String> preferredTags;
|
||||
private Map<String, Object> behaviorPatterns;
|
||||
private Double pricePreference;
|
||||
private Double distancePreference;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
/**
|
||||
* 취향 프로필 업데이트
|
||||
*/
|
||||
public TasteProfile updateProfile(List<TasteCategory> categories, Map<String, Double> scores,
|
||||
List<String> tags, Map<String, Object> patterns,
|
||||
Double pricePreference, Double distancePreference) {
|
||||
return TasteProfile.builder()
|
||||
.id(this.id)
|
||||
.memberId(this.memberId)
|
||||
.preferredCategories(categories)
|
||||
.categoryScores(scores)
|
||||
.preferredTags(tags)
|
||||
.behaviorPatterns(patterns)
|
||||
.pricePreference(pricePreference)
|
||||
.distancePreference(distancePreference)
|
||||
.createdAt(this.createdAt)
|
||||
.updatedAt(LocalDateTime.now())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,159 @@
|
||||
package com.ktds.hi.recommend.biz.service;
|
||||
|
||||
import com.ktds.hi.recommend.biz.usecase.in.StoreRecommendUseCase;
|
||||
import com.ktds.hi.recommend.biz.usecase.out.*;
|
||||
import com.ktds.hi.recommend.biz.domain.*;
|
||||
import com.ktds.hi.recommend.infra.dto.request.RecommendStoreRequest;
|
||||
import com.ktds.hi.recommend.infra.dto.response.RecommendStoreResponse;
|
||||
import com.ktds.hi.common.exception.BusinessException;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 매장 추천 인터랙터 클래스
|
||||
* 사용자 취향 기반 매장 추천 기능을 구현
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
@Transactional
|
||||
public class StoreRecommendInteractor implements StoreRecommendUseCase {
|
||||
|
||||
private final RecommendRepository recommendRepository;
|
||||
private final AiRecommendRepository aiRecommendRepository;
|
||||
private final LocationRepository locationRepository;
|
||||
private final UserPreferenceRepository userPreferenceRepository;
|
||||
|
||||
@Override
|
||||
public List<RecommendStoreResponse> recommendStores(Long memberId, RecommendStoreRequest request) {
|
||||
// 사용자 취향 프로필 조회
|
||||
TasteProfile tasteProfile = userPreferenceRepository.getMemberPreferences(memberId)
|
||||
.orElseThrow(() -> new BusinessException("사용자 취향 정보를 찾을 수 없습니다. 취향 등록을 먼저 해주세요."));
|
||||
|
||||
// AI 기반 추천
|
||||
Map<String, Object> preferences = Map.of(
|
||||
"categories", tasteProfile.getPreferredCategories(),
|
||||
"tags", tasteProfile.getPreferredTags(),
|
||||
"pricePreference", tasteProfile.getPricePreference(),
|
||||
"distancePreference", tasteProfile.getDistancePreference(),
|
||||
"latitude", request.getLatitude(),
|
||||
"longitude", request.getLongitude()
|
||||
);
|
||||
|
||||
List<RecommendStore> aiRecommendStores = aiRecommendRepository.recommendStoresByAI(memberId, preferences);
|
||||
|
||||
// 위치 기반 추천 결합
|
||||
List<RecommendStore> locationStores = locationRepository.findStoresWithinRadius(
|
||||
request.getLatitude(), request.getLongitude(), request.getRadius());
|
||||
|
||||
// 추천 결과 통합 및 점수 계산
|
||||
List<RecommendStore> combinedStores = combineRecommendations(aiRecommendStores, locationStores, tasteProfile);
|
||||
|
||||
// 추천 히스토리 저장
|
||||
RecommendHistory history = RecommendHistory.builder()
|
||||
.memberId(memberId)
|
||||
.recommendedStoreIds(combinedStores.stream().map(RecommendStore::getStoreId).collect(Collectors.toList()))
|
||||
.recommendType(RecommendType.TASTE_BASED)
|
||||
.criteria("취향 + AI + 위치 기반 통합 추천")
|
||||
.createdAt(LocalDateTime.now())
|
||||
.build();
|
||||
|
||||
recommendRepository.saveRecommendHistory(history);
|
||||
|
||||
log.info("매장 추천 완료: memberId={}, 추천 매장 수={}", memberId, combinedStores.size());
|
||||
|
||||
return combinedStores.stream()
|
||||
.map(this::toRecommendStoreResponse)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public List<RecommendStoreResponse> recommendStoresByLocation(Double latitude, Double longitude, Integer radius) {
|
||||
List<RecommendStore> stores = locationRepository.findStoresWithinRadius(latitude, longitude, radius);
|
||||
|
||||
return stores.stream()
|
||||
.map(store -> store.updateRecommendReason("위치 기반 추천"))
|
||||
.map(this::toRecommendStoreResponse)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public List<RecommendStoreResponse> recommendPopularStores(String category, Integer limit) {
|
||||
// Mock 구현 - 실제로는 인기도 기반 쿼리 필요
|
||||
List<RecommendStore> popularStores = List.of(
|
||||
RecommendStore.builder()
|
||||
.storeId(1L)
|
||||
.storeName("인기 매장 1")
|
||||
.address("서울시 강남구")
|
||||
.category(category)
|
||||
.rating(4.5)
|
||||
.reviewCount(100)
|
||||
.recommendScore(95.0)
|
||||
.recommendType(RecommendType.POPULARITY_BASED)
|
||||
.recommendReason("높은 평점과 많은 리뷰")
|
||||
.build()
|
||||
);
|
||||
|
||||
return popularStores.stream()
|
||||
.limit(limit != null ? limit : 10)
|
||||
.map(this::toRecommendStoreResponse)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* 추천 결과 통합 및 점수 계산
|
||||
*/
|
||||
private List<RecommendStore> combineRecommendations(List<RecommendStore> aiStores,
|
||||
List<RecommendStore> locationStores,
|
||||
TasteProfile profile) {
|
||||
// AI 추천과 위치 기반 추천을 통합하여 최종 점수 계산
|
||||
// 실제로는 더 복잡한 로직이 필요
|
||||
|
||||
return aiStores.stream()
|
||||
.map(store -> store.updateRecommendScore(
|
||||
calculateFinalScore(store, profile)
|
||||
))
|
||||
.sorted((s1, s2) -> Double.compare(s2.getRecommendScore(), s1.getRecommendScore()))
|
||||
.limit(20)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* 최종 추천 점수 계산
|
||||
*/
|
||||
private Double calculateFinalScore(RecommendStore store, TasteProfile profile) {
|
||||
double baseScore = store.getRecommendScore() != null ? store.getRecommendScore() : 0.0;
|
||||
double ratingScore = store.getRating() != null ? store.getRating() * 10 : 0.0;
|
||||
double reviewScore = store.getReviewCount() != null ? Math.min(store.getReviewCount() * 0.1, 10) : 0.0;
|
||||
double distanceScore = store.getDistance() != null ? Math.max(0, 10 - store.getDistance() / 1000) : 0.0;
|
||||
|
||||
return (baseScore * 0.4) + (ratingScore * 0.3) + (reviewScore * 0.2) + (distanceScore * 0.1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 도메인을 응답 DTO로 변환
|
||||
*/
|
||||
private RecommendStoreResponse toRecommendStoreResponse(RecommendStore store) {
|
||||
return RecommendStoreResponse.builder()
|
||||
.storeId(store.getStoreId())
|
||||
.storeName(store.getStoreName())
|
||||
.address(store.getAddress())
|
||||
.category(store.getCategory())
|
||||
.tags(store.getTags())
|
||||
.rating(store.getRating())
|
||||
.reviewCount(store.getReviewCount())
|
||||
.distance(store.getDistance())
|
||||
.recommendScore(store.getRecommendScore())
|
||||
.recommendReason(store.getRecommendReason())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,79 @@
|
||||
package com.ktds.hi.recommend.biz.service;
|
||||
|
||||
import com.ktds.hi.recommend.biz.usecase.in.TasteAnalysisUseCase;
|
||||
import com.ktds.hi.recommend.biz.usecase.out.UserPreferenceRepository;
|
||||
import com.ktds.hi.recommend.biz.domain.TasteProfile;
|
||||
import com.ktds.hi.recommend.biz.domain.TasteCategory;
|
||||
import com.ktds.hi.recommend.infra.dto.response.TasteAnalysisResponse;
|
||||
import com.ktds.hi.common.exception.BusinessException;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 취향 분석 인터랙터 클래스
|
||||
* 사용자 취향 분석 기능을 구현
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
@Transactional
|
||||
public class TasteAnalysisInteractor implements TasteAnalysisUseCase {
|
||||
|
||||
private final UserPreferenceRepository userPreferenceRepository;
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public TasteAnalysisResponse analyzeMemberTaste(Long memberId) {
|
||||
TasteProfile profile = userPreferenceRepository.getMemberPreferences(memberId)
|
||||
.orElseThrow(() -> new BusinessException("사용자 취향 정보를 찾을 수 없습니다"));
|
||||
|
||||
// 취향 분석 결과 생성
|
||||
List<String> preferredCategories = profile.getPreferredCategories()
|
||||
.stream()
|
||||
.map(TasteCategory::getDescription)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
Map<String, Double> categoryScores = profile.getCategoryScores();
|
||||
String topCategory = categoryScores.entrySet()
|
||||
.stream()
|
||||
.max(Map.Entry.comparingByValue())
|
||||
.map(Map.Entry::getKey)
|
||||
.orElse("정보 없음");
|
||||
|
||||
return TasteAnalysisResponse.builder()
|
||||
.memberId(memberId)
|
||||
.preferredCategories(preferredCategories)
|
||||
.topCategory(topCategory)
|
||||
.categoryScores(categoryScores)
|
||||
.preferredTags(profile.getPreferredTags())
|
||||
.pricePreference(profile.getPricePreference())
|
||||
.distancePreference(profile.getDistancePreference())
|
||||
.analysisDate(profile.getUpdatedAt())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateTasteProfile(Long memberId) {
|
||||
log.info("취향 프로필 업데이트 시작: memberId={}", memberId);
|
||||
|
||||
try {
|
||||
// 리뷰 기반 취향 분석
|
||||
Map<String, Object> analysisData = userPreferenceRepository.analyzePreferencesFromReviews(memberId);
|
||||
|
||||
// 취향 프로필 업데이트
|
||||
TasteProfile updatedProfile = userPreferenceRepository.updateTasteProfile(memberId, analysisData);
|
||||
|
||||
log.info("취향 프로필 업데이트 완료: memberId={}, profileId={}", memberId, updatedProfile.getId());
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("취향 프로필 업데이트 실패: memberId={}, error={}", memberId, e.getMessage(), e);
|
||||
throw new BusinessException("취향 프로필 업데이트 중 오류가 발생했습니다: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,28 @@
|
||||
package com.ktds.hi.recommend.biz.usecase.in;
|
||||
|
||||
import com.ktds.hi.recommend.infra.dto.request.RecommendStoreRequest;
|
||||
import com.ktds.hi.recommend.infra.dto.response.RecommendStoreResponse;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 매장 추천 유스케이스 인터페이스
|
||||
* 사용자 취향 기반 매장 추천 기능을 정의
|
||||
*/
|
||||
public interface StoreRecommendUseCase {
|
||||
|
||||
/**
|
||||
* 사용자 취향 기반 매장 추천
|
||||
*/
|
||||
List<RecommendStoreResponse> recommendStores(Long memberId, RecommendStoreRequest request);
|
||||
|
||||
/**
|
||||
* 위치 기반 매장 추천
|
||||
*/
|
||||
List<RecommendStoreResponse> recommendStoresByLocation(Double latitude, Double longitude, Integer radius);
|
||||
|
||||
/**
|
||||
* 인기 매장 추천
|
||||
*/
|
||||
List<RecommendStoreResponse> recommendPopularStores(String category, Integer limit);
|
||||
}
|
||||
@ -0,0 +1,20 @@
|
||||
package com.ktds.hi.recommend.biz.usecase.in;
|
||||
|
||||
import com.ktds.hi.recommend.infra.dto.response.TasteAnalysisResponse;
|
||||
|
||||
/**
|
||||
* 취향 분석 유스케이스 인터페이스
|
||||
* 사용자 취향 분석 기능을 정의
|
||||
*/
|
||||
public interface TasteAnalysisUseCase {
|
||||
|
||||
/**
|
||||
* 사용자 취향 분석
|
||||
*/
|
||||
TasteAnalysisResponse analyzeMemberTaste(Long memberId);
|
||||
|
||||
/**
|
||||
* 취향 프로필 업데이트
|
||||
*/
|
||||
void updateTasteProfile(Long memberId);
|
||||
}
|
||||
@ -0,0 +1,28 @@
|
||||
package com.ktds.hi.recommend.biz.usecase.out;
|
||||
|
||||
import com.ktds.hi.recommend.biz.domain.RecommendStore;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* AI 추천 리포지토리 인터페이스
|
||||
* AI 기반 추천 기능을 정의
|
||||
*/
|
||||
public interface AiRecommendRepository {
|
||||
|
||||
/**
|
||||
* AI 기반 매장 추천
|
||||
*/
|
||||
List<RecommendStore> recommendStoresByAI(Long memberId, Map<String, Object> preferences);
|
||||
|
||||
/**
|
||||
* 유사 사용자 기반 추천
|
||||
*/
|
||||
List<RecommendStore> recommendStoresBySimilarUsers(Long memberId);
|
||||
|
||||
/**
|
||||
* 협업 필터링 추천
|
||||
*/
|
||||
List<RecommendStore> recommendStoresByCollaborativeFiltering(Long memberId);
|
||||
}
|
||||
@ -0,0 +1,33 @@
|
||||
package com.ktds.hi.recommend.biz.usecase.out;
|
||||
|
||||
import com.ktds.hi.recommend.biz.domain.Location;
|
||||
import com.ktds.hi.recommend.biz.domain.RecommendStore;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 위치 기반 서비스 리포지토리 인터페이스
|
||||
* 위치 정보 처리 기능을 정의
|
||||
*/
|
||||
public interface LocationRepository {
|
||||
|
||||
/**
|
||||
* 위치 정보 저장
|
||||
*/
|
||||
Location saveLocation(Location location);
|
||||
|
||||
/**
|
||||
* 반경 내 매장 조회
|
||||
*/
|
||||
List<RecommendStore> findStoresWithinRadius(Double latitude, Double longitude, Integer radius);
|
||||
|
||||
/**
|
||||
* 거리 계산
|
||||
*/
|
||||
Double calculateDistance(Double lat1, Double lon1, Double lat2, Double lon2);
|
||||
|
||||
/**
|
||||
* 주소를 좌표로 변환
|
||||
*/
|
||||
Location geocodeAddress(String address);
|
||||
}
|
||||
@ -0,0 +1,34 @@
|
||||
package com.ktds.hi.recommend.biz.usecase.out;
|
||||
|
||||
import com.ktds.hi.recommend.biz.domain.RecommendHistory;
|
||||
import com.ktds.hi.recommend.biz.domain.TasteProfile;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 추천 리포지토리 인터페이스
|
||||
* 추천 관련 데이터 영속성 기능을 정의
|
||||
*/
|
||||
public interface RecommendRepository {
|
||||
|
||||
/**
|
||||
* 추천 히스토리 저장
|
||||
*/
|
||||
RecommendHistory saveRecommendHistory(RecommendHistory history);
|
||||
|
||||
/**
|
||||
* 회원 ID로 추천 히스토리 조회
|
||||
*/
|
||||
List<RecommendHistory> findRecommendHistoriesByMemberId(Long memberId);
|
||||
|
||||
/**
|
||||
* 취향 프로필 저장
|
||||
*/
|
||||
TasteProfile saveTasteProfile(TasteProfile profile);
|
||||
|
||||
/**
|
||||
* 회원 ID로 취향 프로필 조회
|
||||
*/
|
||||
Optional<TasteProfile> findTasteProfileByMemberId(Long memberId);
|
||||
}
|
||||
@ -0,0 +1,34 @@
|
||||
package com.ktds.hi.recommend.biz.usecase.out;
|
||||
|
||||
import com.ktds.hi.recommend.biz.domain.TasteProfile;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 사용자 선호도 리포지토리 인터페이스
|
||||
* 사용자 취향 데이터 처리 기능을 정의
|
||||
*/
|
||||
public interface UserPreferenceRepository {
|
||||
|
||||
/**
|
||||
* 회원 취향 정보 조회
|
||||
*/
|
||||
Optional<TasteProfile> getMemberPreferences(Long memberId);
|
||||
|
||||
/**
|
||||
* 회원의 리뷰 기반 취향 분석
|
||||
*/
|
||||
Map<String, Object> analyzePreferencesFromReviews(Long memberId);
|
||||
|
||||
/**
|
||||
* 유사한 취향의 사용자 조회
|
||||
*/
|
||||
List<Long> findSimilarTasteMembers(Long memberId);
|
||||
|
||||
/**
|
||||
* 취향 프로필 업데이트
|
||||
*/
|
||||
TasteProfile updateTasteProfile(Long memberId, Map<String, Object> analysisData);
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
package com.ktds.hi.recommend.infra.config;
|
||||
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
|
||||
|
||||
/**
|
||||
* 추천 서비스 설정 클래스
|
||||
*/
|
||||
@Configuration
|
||||
@EnableJpaRepositories(basePackages = "com.ktds.hi.recommend.infra.gateway.repository")
|
||||
public class RecommendConfig {
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
package com.ktds.hi.recommend.infra.config;
|
||||
|
||||
import io.swagger.v3.oas.models.OpenAPI;
|
||||
import io.swagger.v3.oas.models.info.Info;
|
||||
import io.swagger.v3.oas.models.servers.Server;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* Swagger 설정 클래스
|
||||
* API 문서화를 위한 OpenAPI 설정
|
||||
*/
|
||||
@Configuration
|
||||
public class SwaggerConfig {
|
||||
|
||||
@Bean
|
||||
public OpenAPI openAPI() {
|
||||
return new OpenAPI()
|
||||
.addServersItem(new Server().url("/"))
|
||||
.info(new Info()
|
||||
.title("하이오더 추천 서비스 API")
|
||||
.description("사용자 취향 기반 매장 추천 및 취향 분석 관련 기능을 제공하는 API")
|
||||
.version("1.0.0"));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,68 @@
|
||||
package com.ktds.hi.recommend.infra.controller;
|
||||
|
||||
import com.ktds.hi.recommend.biz.usecase.in.StoreRecommendUseCase;
|
||||
import com.ktds.hi.recommend.infra.dto.request.RecommendStoreRequest;
|
||||
import com.ktds.hi.recommend.infra.dto.response.RecommendStoreResponse;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 매장 추천 컨트롤러 클래스
|
||||
* 매장 추천 관련 API를 제공
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/recommend")
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "매장 추천 API", description = "사용자 취향 기반 매장 추천 관련 API")
|
||||
public class StoreRecommendController {
|
||||
|
||||
private final StoreRecommendUseCase storeRecommendUseCase;
|
||||
|
||||
/**
|
||||
* 사용자 취향 기반 매장 추천 API
|
||||
*/
|
||||
@PostMapping("/stores")
|
||||
@Operation(summary = "매장 추천", description = "사용자 취향과 위치를 기반으로 매장을 추천합니다.")
|
||||
public ResponseEntity<List<RecommendStoreResponse>> recommendStores(Authentication authentication,
|
||||
@Valid @RequestBody RecommendStoreRequest request) {
|
||||
Long memberId = Long.valueOf(authentication.getName());
|
||||
List<RecommendStoreResponse> recommendations = storeRecommendUseCase.recommendStores(memberId, request);
|
||||
return ResponseEntity.ok(recommendations);
|
||||
}
|
||||
|
||||
/**
|
||||
* 위치 기반 매장 추천 API
|
||||
*/
|
||||
@GetMapping("/stores/nearby")
|
||||
@Operation(summary = "주변 매장 추천", description = "현재 위치 기반으로 주변 매장을 추천합니다.")
|
||||
public ResponseEntity<List<RecommendStoreResponse>> recommendNearbyStores(
|
||||
@RequestParam Double latitude,
|
||||
@RequestParam Double longitude,
|
||||
@RequestParam(defaultValue = "5000") Integer radius) {
|
||||
|
||||
List<RecommendStoreResponse> recommendations = storeRecommendUseCase
|
||||
.recommendStoresByLocation(latitude, longitude, radius);
|
||||
return ResponseEntity.ok(recommendations);
|
||||
}
|
||||
|
||||
/**
|
||||
* 인기 매장 추천 API
|
||||
*/
|
||||
@GetMapping("/stores/popular")
|
||||
@Operation(summary = "인기 매장 추천", description = "카테고리별 인기 매장을 추천합니다.")
|
||||
public ResponseEntity<List<RecommendStoreResponse>> recommendPopularStores(
|
||||
@RequestParam(required = false) String category,
|
||||
@RequestParam(defaultValue = "10") Integer limit) {
|
||||
|
||||
List<RecommendStoreResponse> recommendations = storeRecommendUseCase
|
||||
.recommendPopularStores(category, limit);
|
||||
return ResponseEntity.ok(recommendations);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,46 @@
|
||||
package com.ktds.hi.recommend.infra.controller;
|
||||
|
||||
import com.ktds.hi.recommend.biz.usecase.in.TasteAnalysisUseCase;
|
||||
import com.ktds.hi.recommend.infra.dto.response.TasteAnalysisResponse;
|
||||
import com.ktds.hi.common.dto.SuccessResponse;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
/**
|
||||
* 취향 분석 컨트롤러 클래스
|
||||
* 사용자 취향 분석 관련 API를 제공
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/recommend/taste")
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "취향 분석 API", description = "사용자 취향 분석 관련 API")
|
||||
public class TasteAnalysisController {
|
||||
|
||||
private final TasteAnalysisUseCase tasteAnalysisUseCase;
|
||||
|
||||
/**
|
||||
* 사용자 취향 분석 조회 API
|
||||
*/
|
||||
@GetMapping("/analysis")
|
||||
@Operation(summary = "취향 분석 조회", description = "현재 로그인한 사용자의 취향 분석 결과를 조회합니다.")
|
||||
public ResponseEntity<TasteAnalysisResponse> getMemberTasteAnalysis(Authentication authentication) {
|
||||
Long memberId = Long.valueOf(authentication.getName());
|
||||
TasteAnalysisResponse analysis = tasteAnalysisUseCase.analyzeMemberTaste(memberId);
|
||||
return ResponseEntity.ok(analysis);
|
||||
}
|
||||
|
||||
/**
|
||||
* 취향 프로필 업데이트 API
|
||||
*/
|
||||
@PostMapping("/update")
|
||||
@Operation(summary = "취향 프로필 업데이트", description = "사용자의 리뷰 데이터를 기반으로 취향 프로필을 업데이트합니다.")
|
||||
public ResponseEntity<SuccessResponse> updateTasteProfile(Authentication authentication) {
|
||||
Long memberId = Long.valueOf(authentication.getName());
|
||||
tasteAnalysisUseCase.updateTasteProfile(memberId);
|
||||
return ResponseEntity.ok(SuccessResponse.of("취향 프로필이 업데이트되었습니다"));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,57 @@
|
||||
package com.ktds.hi.recommend.infra.dto.request;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 매장 추천 요청 DTO
|
||||
*/
|
||||
@Getter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Schema(description = "매장 추천 요청")
|
||||
public class RecommendStoreRequest {
|
||||
|
||||
@NotNull(message = "위도는 필수입니다")
|
||||
@Schema(description = "위도", example = "37.5665")
|
||||
private Double latitude;
|
||||
|
||||
@NotNull(message = "경도는 필수입니다")
|
||||
@Schema(description = "경도", example = "126.9780")
|
||||
private Double longitude;
|
||||
|
||||
@Schema(description = "검색 반경(미터)", example = "5000", defaultValue = "5000")
|
||||
private Integer radius = 5000;
|
||||
|
||||
@Schema(description = "선호 카테고리", example = "[\"한식\", \"일식\"]")
|
||||
private List<String> preferredCategories;
|
||||
|
||||
@Schema(description = "가격 범위", example = "MEDIUM")
|
||||
private String priceRange;
|
||||
|
||||
@Schema(description = "추천 개수", example = "10", defaultValue = "10")
|
||||
private Integer limit = 10;
|
||||
|
||||
/**
|
||||
* 유효성 검증
|
||||
*/
|
||||
public void validate() {
|
||||
if (latitude == null || longitude == null) {
|
||||
throw new IllegalArgumentException("위도와 경도는 필수입니다");
|
||||
}
|
||||
if (latitude < -90 || latitude > 90) {
|
||||
throw new IllegalArgumentException("위도는 -90과 90 사이여야 합니다");
|
||||
}
|
||||
if (longitude < -180 || longitude > 180) {
|
||||
throw new IllegalArgumentException("경도는 -180과 180 사이여야 합니다");
|
||||
}
|
||||
if (radius != null && radius <= 0) {
|
||||
throw new IllegalArgumentException("검색 반경은 0보다 커야 합니다");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,50 @@
|
||||
package com.ktds.hi.recommend.infra.dto.response;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 매장 추천 응답 DTO
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Schema(description = "매장 추천 응답")
|
||||
public class RecommendStoreResponse {
|
||||
|
||||
@Schema(description = "매장 ID")
|
||||
private Long storeId;
|
||||
|
||||
@Schema(description = "매장명")
|
||||
private String storeName;
|
||||
|
||||
@Schema(description = "주소")
|
||||
private String address;
|
||||
|
||||
@Schema(description = "카테고리")
|
||||
private String category;
|
||||
|
||||
@Schema(description = "태그 목록")
|
||||
private List<String> tags;
|
||||
|
||||
@Schema(description = "평점")
|
||||
private Double rating;
|
||||
|
||||
@Schema(description = "리뷰 수")
|
||||
private Integer reviewCount;
|
||||
|
||||
@Schema(description = "거리(미터)")
|
||||
private Double distance;
|
||||
|
||||
@Schema(description = "추천 점수")
|
||||
private Double recommendScore;
|
||||
|
||||
@Schema(description = "추천 이유")
|
||||
private String recommendReason;
|
||||
}
|
||||
@ -0,0 +1,46 @@
|
||||
package com.ktds.hi.recommend.infra.dto.response;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 취향 분석 응답 DTO
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Schema(description = "취향 분석 응답")
|
||||
public class TasteAnalysisResponse {
|
||||
|
||||
@Schema(description = "회원 ID")
|
||||
private Long memberId;
|
||||
|
||||
@Schema(description = "선호 카테고리")
|
||||
private List<String> preferredCategories;
|
||||
|
||||
@Schema(description = "최고 선호 카테고리")
|
||||
private String topCategory;
|
||||
|
||||
@Schema(description = "카테고리별 점수")
|
||||
private Map<String, Double> categoryScores;
|
||||
|
||||
@Schema(description = "선호 태그")
|
||||
private List<String> preferredTags;
|
||||
|
||||
@Schema(description = "가격 선호도")
|
||||
private Double pricePreference;
|
||||
|
||||
@Schema(description = "거리 선호도")
|
||||
private Double distancePreference;
|
||||
|
||||
@Schema(description = "분석 일시")
|
||||
private LocalDateTime analysisDate;
|
||||
}
|
||||
@ -0,0 +1,101 @@
|
||||
package com.ktds.hi.recommend.infra.gateway;
|
||||
|
||||
import com.ktds.hi.recommend.biz.usecase.out.AiRecommendRepository;
|
||||
import com.ktds.hi.recommend.biz.domain.RecommendStore;
|
||||
import com.ktds.hi.recommend.biz.domain.RecommendType;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* AI 추천 어댑터 클래스
|
||||
* AI 기반 추천 기능을 구현 (현재는 Mock 구현)
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class AiRecommendAdapter implements AiRecommendRepository {
|
||||
|
||||
@Override
|
||||
public List<RecommendStore> recommendStoresByAI(Long memberId, Map<String, Object> preferences) {
|
||||
log.info("AI 기반 매장 추천 요청: memberId={}, preferences={}", memberId, preferences);
|
||||
|
||||
// Mock 구현 - 실제로는 AI 모델 API 호출
|
||||
return List.of(
|
||||
RecommendStore.builder()
|
||||
.storeId(1L)
|
||||
.storeName("AI 추천 매장 1")
|
||||
.address("서울시 강남구 역삼동")
|
||||
.category("한식")
|
||||
.tags(List.of("맛집", "깔끔", "한식"))
|
||||
.rating(4.5)
|
||||
.reviewCount(150)
|
||||
.distance(500.0)
|
||||
.recommendScore(92.0)
|
||||
.recommendType(RecommendType.AI_RECOMMENDATION)
|
||||
.recommendReason("사용자 취향과 92% 일치")
|
||||
.build(),
|
||||
|
||||
RecommendStore.builder()
|
||||
.storeId(2L)
|
||||
.storeName("AI 추천 매장 2")
|
||||
.address("서울시 강남구 논현동")
|
||||
.category("일식")
|
||||
.tags(List.of("초밥", "신선", "일식"))
|
||||
.rating(4.3)
|
||||
.reviewCount(89)
|
||||
.distance(800.0)
|
||||
.recommendScore(87.0)
|
||||
.recommendType(RecommendType.AI_RECOMMENDATION)
|
||||
.recommendReason("사용자가 선호하는 일식 카테고리")
|
||||
.build()
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<RecommendStore> recommendStoresBySimilarUsers(Long memberId) {
|
||||
log.info("유사 사용자 기반 추천 요청: memberId={}", memberId);
|
||||
|
||||
// Mock 구현
|
||||
return List.of(
|
||||
RecommendStore.builder()
|
||||
.storeId(3L)
|
||||
.storeName("유사 취향 추천 매장")
|
||||
.address("서울시 서초구 서초동")
|
||||
.category("양식")
|
||||
.tags(List.of("파스타", "분위기", "양식"))
|
||||
.rating(4.4)
|
||||
.reviewCount(203)
|
||||
.distance(1200.0)
|
||||
.recommendScore(85.0)
|
||||
.recommendType(RecommendType.SIMILAR_USER)
|
||||
.recommendReason("비슷한 취향의 사용자들이 좋아하는 매장")
|
||||
.build()
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<RecommendStore> recommendStoresByCollaborativeFiltering(Long memberId) {
|
||||
log.info("협업 필터링 추천 요청: memberId={}", memberId);
|
||||
|
||||
// Mock 구현
|
||||
return List.of(
|
||||
RecommendStore.builder()
|
||||
.storeId(4L)
|
||||
.storeName("협업 필터링 추천 매장")
|
||||
.address("서울시 마포구 홍대입구")
|
||||
.category("카페")
|
||||
.tags(List.of("커피", "디저트", "분위기"))
|
||||
.rating(4.2)
|
||||
.reviewCount(127)
|
||||
.distance(2500.0)
|
||||
.recommendScore(82.0)
|
||||
.recommendType(RecommendType.COLLABORATIVE_FILTERING)
|
||||
.recommendReason("사용자 행동 패턴 기반 추천")
|
||||
.build()
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,106 @@
|
||||
package com.ktds.hi.recommend.infra.gateway;
|
||||
|
||||
import com.ktds.hi.recommend.biz.usecase.out.LocationRepository;
|
||||
import com.ktds.hi.recommend.biz.domain.Location;
|
||||
import com.ktds.hi.recommend.biz.domain.RecommendStore;
|
||||
import com.ktds.hi.recommend.biz.domain.RecommendType;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 위치 서비스 어댑터 클래스
|
||||
* 위치 기반 서비스 기능을 구현 (현재는 Mock 구현)
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class LocationServiceAdapter implements LocationRepository {
|
||||
|
||||
@Override
|
||||
public Location saveLocation(Location location) {
|
||||
log.info("위치 정보 저장: {}", location.getAddress());
|
||||
|
||||
// Mock 구현
|
||||
return Location.builder()
|
||||
.id(1L)
|
||||
.address(location.getAddress())
|
||||
.latitude(location.getLatitude())
|
||||
.longitude(location.getLongitude())
|
||||
.city("서울시")
|
||||
.district("강남구")
|
||||
.country("대한민국")
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<RecommendStore> findStoresWithinRadius(Double latitude, Double longitude, Integer radius) {
|
||||
log.info("반경 내 매장 조회: lat={}, lon={}, radius={}", latitude, longitude, radius);
|
||||
|
||||
// Mock 구현
|
||||
return List.of(
|
||||
RecommendStore.builder()
|
||||
.storeId(5L)
|
||||
.storeName("근처 매장 1")
|
||||
.address("서울시 강남구 역삼동 123-45")
|
||||
.category("한식")
|
||||
.tags(List.of("근처", "맛집"))
|
||||
.rating(4.1)
|
||||
.reviewCount(95)
|
||||
.distance(300.0)
|
||||
.recommendScore(78.0)
|
||||
.recommendType(RecommendType.LOCATION_BASED)
|
||||
.recommendReason("현재 위치에서 300m 거리")
|
||||
.build(),
|
||||
|
||||
RecommendStore.builder()
|
||||
.storeId(6L)
|
||||
.storeName("근처 매장 2")
|
||||
.address("서울시 강남구 역삼동 678-90")
|
||||
.category("카페")
|
||||
.tags(List.of("커피", "디저트"))
|
||||
.rating(4.0)
|
||||
.reviewCount(67)
|
||||
.distance(450.0)
|
||||
.recommendScore(75.0)
|
||||
.recommendType(RecommendType.LOCATION_BASED)
|
||||
.recommendReason("현재 위치에서 450m 거리")
|
||||
.build()
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Double calculateDistance(Double lat1, Double lon1, Double lat2, Double lon2) {
|
||||
// Haversine 공식을 사용한 거리 계산
|
||||
final int R = 6371; // 지구의 반지름 (km)
|
||||
|
||||
double latDistance = Math.toRadians(lat2 - lat1);
|
||||
double lonDistance = Math.toRadians(lon2 - lon1);
|
||||
|
||||
double a = Math.sin(latDistance / 2) * Math.sin(latDistance / 2)
|
||||
+ Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2))
|
||||
* Math.sin(lonDistance / 2) * Math.sin(lonDistance / 2);
|
||||
|
||||
double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
double distance = R * c * 1000; // 미터로 변환
|
||||
|
||||
return distance;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Location geocodeAddress(String address) {
|
||||
log.info("주소 좌표 변환 요청: {}", address);
|
||||
|
||||
// Mock 구현 - 실제로는 Google Maps API 등 사용
|
||||
return Location.builder()
|
||||
.address(address)
|
||||
.latitude(37.5665)
|
||||
.longitude(126.9780)
|
||||
.city("서울시")
|
||||
.district("중구")
|
||||
.country("대한민국")
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,110 @@
|
||||
package com.ktds.hi.recommend.infra.gateway;
|
||||
|
||||
import com.ktds.hi.recommend.biz.usecase.out.RecommendRepository;
|
||||
import com.ktds.hi.recommend.biz.domain.RecommendHistory;
|
||||
import com.ktds.hi.recommend.biz.domain.TasteProfile;
|
||||
import com.ktds.hi.recommend.infra.gateway.repository.RecommendHistoryJpaRepository;
|
||||
import com.ktds.hi.recommend.infra.gateway.repository.TasteProfileJpaRepository;
|
||||
import com.ktds.hi.recommend.infra.gateway.entity.RecommendHistoryEntity;
|
||||
import com.ktds.hi.recommend.infra.gateway.entity.TasteProfileEntity;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 추천 리포지토리 어댑터 클래스
|
||||
* 도메인 리포지토리 인터페이스를 JPA 리포지토리에 연결
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class RecommendRepositoryAdapter implements RecommendRepository {
|
||||
|
||||
private final RecommendHistoryJpaRepository recommendHistoryJpaRepository;
|
||||
private final TasteProfileJpaRepository tasteProfileJpaRepository;
|
||||
|
||||
@Override
|
||||
public RecommendHistory saveRecommendHistory(RecommendHistory history) {
|
||||
RecommendHistoryEntity entity = toRecommendHistoryEntity(history);
|
||||
RecommendHistoryEntity savedEntity = recommendHistoryJpaRepository.save(entity);
|
||||
return toRecommendHistory(savedEntity);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<RecommendHistory> findRecommendHistoriesByMemberId(Long memberId) {
|
||||
List<RecommendHistoryEntity> entities = recommendHistoryJpaRepository.findByMemberIdOrderByCreatedAtDesc(memberId);
|
||||
return entities.stream()
|
||||
.map(this::toRecommendHistory)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public TasteProfile saveTasteProfile(TasteProfile profile) {
|
||||
TasteProfileEntity entity = toTasteProfileEntity(profile);
|
||||
TasteProfileEntity savedEntity = tasteProfileJpaRepository.save(entity);
|
||||
return toTasteProfile(savedEntity);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<TasteProfile> findTasteProfileByMemberId(Long memberId) {
|
||||
return tasteProfileJpaRepository.findByMemberId(memberId)
|
||||
.map(this::toTasteProfile);
|
||||
}
|
||||
|
||||
/**
|
||||
* 엔티티를 도메인으로 변환
|
||||
*/
|
||||
private RecommendHistory toRecommendHistory(RecommendHistoryEntity entity) {
|
||||
return RecommendHistory.builder()
|
||||
.id(entity.getId())
|
||||
.memberId(entity.getMemberId())
|
||||
.recommendedStoreIds(entity.getRecommendedStoreIdsList())
|
||||
.recommendType(entity.getRecommendType())
|
||||
.criteria(entity.getCriteria())
|
||||
.createdAt(entity.getCreatedAt())
|
||||
.build();
|
||||
}
|
||||
|
||||
private TasteProfile toTasteProfile(TasteProfileEntity entity) {
|
||||
return TasteProfile.builder()
|
||||
.id(entity.getId())
|
||||
.memberId(entity.getMemberId())
|
||||
.preferredCategories(entity.getPreferredCategoriesList())
|
||||
.categoryScores(entity.getCategoryScoresMap())
|
||||
.preferredTags(entity.getPreferredTagsList())
|
||||
.behaviorPatterns(entity.getBehaviorPatternsMap())
|
||||
.pricePreference(entity.getPricePreference())
|
||||
.distancePreference(entity.getDistancePreference())
|
||||
.createdAt(entity.getCreatedAt())
|
||||
.updatedAt(entity.getUpdatedAt())
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 도메인을 엔티티로 변환
|
||||
*/
|
||||
private RecommendHistoryEntity toRecommendHistoryEntity(RecommendHistory domain) {
|
||||
return RecommendHistoryEntity.builder()
|
||||
.id(domain.getId())
|
||||
.memberId(domain.getMemberId())
|
||||
.recommendedStoreIdsJson(domain.getRecommendedStoreIds().toString()) // JSON 변환 필요
|
||||
.recommendType(domain.getRecommendType())
|
||||
.criteria(domain.getCriteria())
|
||||
.build();
|
||||
}
|
||||
|
||||
private TasteProfileEntity toTasteProfileEntity(TasteProfile domain) {
|
||||
return TasteProfileEntity.builder()
|
||||
.id(domain.getId())
|
||||
.memberId(domain.getMemberId())
|
||||
.preferredCategoriesJson(domain.getPreferredCategories().toString()) // JSON 변환 필요
|
||||
.categoryScoresJson(domain.getCategoryScores().toString()) // JSON 변환 필요
|
||||
.preferredTagsJson(domain.getPreferredTags().toString()) // JSON 변환 필요
|
||||
.behaviorPatternsJson(domain.getBehaviorPatterns().toString()) // JSON 변환 필요
|
||||
.pricePreference(domain.getPricePreference())
|
||||
.distancePreference(domain.getDistancePreference())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,91 @@
|
||||
package com.ktds.hi.recommend.infra.gateway;
|
||||
|
||||
import com.ktds.hi.recommend.biz.usecase.out.UserPreferenceRepository;
|
||||
import com.ktds.hi.recommend.biz.domain.TasteProfile;
|
||||
import com.ktds.hi.recommend.biz.domain.TasteCategory;
|
||||
import com.ktds.hi.recommend.infra.gateway.repository.TasteProfileJpaRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 사용자 선호도 어댑터 클래스
|
||||
* 사용자 취향 데이터 처리 기능을 구현
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class UserPreferenceAdapter implements UserPreferenceRepository {
|
||||
|
||||
private final TasteProfileJpaRepository tasteProfileJpaRepository;
|
||||
private final RecommendRepositoryAdapter recommendRepositoryAdapter;
|
||||
|
||||
@Override
|
||||
public Optional<TasteProfile> getMemberPreferences(Long memberId) {
|
||||
return recommendRepositoryAdapter.findTasteProfileByMemberId(memberId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> analyzePreferencesFromReviews(Long memberId) {
|
||||
log.info("리뷰 기반 취향 분석 시작: memberId={}", memberId);
|
||||
|
||||
// Mock 구현 - 실제로는 리뷰 서비스 API 호출하여 분석
|
||||
return Map.of(
|
||||
"preferredCategories", List.of(TasteCategory.KOREAN, TasteCategory.JAPANESE),
|
||||
"categoryScores", Map.of(
|
||||
"한식", 85.0,
|
||||
"일식", 78.0,
|
||||
"양식", 65.0
|
||||
),
|
||||
"preferredTags", List.of("맛집", "깔끔", "친절"),
|
||||
"pricePreference", 60.0, // 0-100 점수
|
||||
"distancePreference", 70.0,
|
||||
"behaviorPatterns", Map.of(
|
||||
"weekendDining", true,
|
||||
"avgRating", 4.2,
|
||||
"reviewFrequency", "medium"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Long> findSimilarTasteMembers(Long memberId) {
|
||||
log.info("유사 취향 사용자 조회: memberId={}", memberId);
|
||||
|
||||
// Mock 구현 - 실제로는 ML 모델 또는 유사도 계산 알고리즘 사용
|
||||
return List.of(123L, 456L, 789L);
|
||||
}
|
||||
|
||||
@Override
|
||||
public TasteProfile updateTasteProfile(Long memberId, Map<String, Object> analysisData) {
|
||||
log.info("취향 프로필 업데이트: memberId={}", memberId);
|
||||
|
||||
// 기존 프로필 조회 또는 새로 생성
|
||||
Optional<TasteProfile> existingProfile = getMemberPreferences(memberId);
|
||||
|
||||
TasteProfile.TasteProfileBuilder builder = TasteProfile.builder()
|
||||
.memberId(memberId)
|
||||
.preferredCategories((List<TasteCategory>) analysisData.get("preferredCategories"))
|
||||
.categoryScores((Map<String, Double>) analysisData.get("categoryScores"))
|
||||
.preferredTags((List<String>) analysisData.get("preferredTags"))
|
||||
.behaviorPatterns((Map<String, Object>) analysisData.get("behaviorPatterns"))
|
||||
.pricePreference((Double) analysisData.get("pricePreference"))
|
||||
.distancePreference((Double) analysisData.get("distancePreference"))
|
||||
.updatedAt(LocalDateTime.now());
|
||||
|
||||
if (existingProfile.isPresent()) {
|
||||
builder.id(existingProfile.get().getId())
|
||||
.createdAt(existingProfile.get().getCreatedAt());
|
||||
} else {
|
||||
builder.createdAt(LocalDateTime.now());
|
||||
}
|
||||
|
||||
TasteProfile updatedProfile = builder.build();
|
||||
return recommendRepositoryAdapter.saveTasteProfile(updatedProfile);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,62 @@
|
||||
package com.ktds.hi.recommend.infra.gateway.entity;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.ktds.hi.recommend.biz.domain.RecommendType;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.springframework.data.annotation.CreatedDate;
|
||||
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 추천 히스토리 엔티티 클래스
|
||||
* 데이터베이스 recommend_history 테이블과 매핑되는 JPA 엔티티
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "recommend_history")
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@EntityListeners(AuditingEntityListener.class)
|
||||
public class RecommendHistoryEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "member_id", nullable = false)
|
||||
private Long memberId;
|
||||
|
||||
@Column(name = "recommended_store_ids_json", columnDefinition = "TEXT")
|
||||
private String recommendedStoreIdsJson;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "recommend_type", nullable = false)
|
||||
private RecommendType recommendType;
|
||||
|
||||
@Column(length = 500)
|
||||
private String criteria;
|
||||
|
||||
@CreatedDate
|
||||
@Column(name = "created_at", updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
/**
|
||||
* JSON 문자열을 List로 변환
|
||||
*/
|
||||
public List<Long> getRecommendedStoreIdsList() {
|
||||
try {
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
return mapper.readValue(recommendedStoreIdsJson, new TypeReference<List<Long>>() {});
|
||||
} catch (Exception e) {
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,103 @@
|
||||
package com.ktds.hi.recommend.infra.gateway.entity;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.ktds.hi.recommend.biz.domain.TasteCategory;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.springframework.data.annotation.CreatedDate;
|
||||
import org.springframework.data.annotation.LastModifiedDate;
|
||||
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 취향 프로필 엔티티 클래스
|
||||
* 데이터베이스 taste_profiles 테이블과 매핑되는 JPA 엔티티
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "taste_profiles")
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@EntityListeners(AuditingEntityListener.class)
|
||||
public class TasteProfileEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "member_id", nullable = false, unique = true)
|
||||
private Long memberId;
|
||||
|
||||
@Column(name = "preferred_categories_json", columnDefinition = "TEXT")
|
||||
private String preferredCategoriesJson;
|
||||
|
||||
@Column(name = "category_scores_json", columnDefinition = "TEXT")
|
||||
private String categoryScoresJson;
|
||||
|
||||
@Column(name = "preferred_tags_json", columnDefinition = "TEXT")
|
||||
private String preferredTagsJson;
|
||||
|
||||
@Column(name = "behavior_patterns_json", columnDefinition = "TEXT")
|
||||
private String behaviorPatternsJson;
|
||||
|
||||
@Column(name = "price_preference")
|
||||
private Double pricePreference;
|
||||
|
||||
@Column(name = "distance_preference")
|
||||
private Double distancePreference;
|
||||
|
||||
@CreatedDate
|
||||
@Column(name = "created_at", updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@LastModifiedDate
|
||||
@Column(name = "updated_at")
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
/**
|
||||
* JSON 문자열을 객체로 변환하는 메서드들
|
||||
*/
|
||||
public List<TasteCategory> getPreferredCategoriesList() {
|
||||
try {
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
return mapper.readValue(preferredCategoriesJson, new TypeReference<List<TasteCategory>>() {});
|
||||
} catch (Exception e) {
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
public Map<String, Double> getCategoryScoresMap() {
|
||||
try {
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
return mapper.readValue(categoryScoresJson, new TypeReference<Map<String, Double>>() {});
|
||||
} catch (Exception e) {
|
||||
return Map.of();
|
||||
}
|
||||
}
|
||||
|
||||
public List<String> getPreferredTagsList() {
|
||||
try {
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
return mapper.readValue(preferredTagsJson, new TypeReference<List<String>>() {});
|
||||
} catch (Exception e) {
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
public Map<String, Object> getBehaviorPatternsMap() {
|
||||
try {
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
return mapper.readValue(behaviorPatternsJson, new TypeReference<Map<String, Object>>() {});
|
||||
} catch (Exception e) {
|
||||
return Map.of();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
package com.ktds.hi.recommend.infra.gateway.repository;
|
||||
|
||||
import com.ktds.hi.recommend.infra.gateway.entity.RecommendHistoryEntity;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 추천 히스토리 JPA 리포지토리 인터페이스
|
||||
* 추천 히스토리 데이터의 CRUD 작업을 담당
|
||||
*/
|
||||
@Repository
|
||||
public interface RecommendHistoryJpaRepository extends JpaRepository<RecommendHistoryEntity, Long> {
|
||||
|
||||
/**
|
||||
* 회원 ID로 추천 히스토리 조회 (최신순)
|
||||
*/
|
||||
List<RecommendHistoryEntity> findByMemberIdOrderByCreatedAtDesc(Long memberId);
|
||||
|
||||
/**
|
||||
* 회원 ID로 최신 추천 히스토리 조회
|
||||
*/
|
||||
RecommendHistoryEntity findTopByMemberIdOrderByCreatedAtDesc(Long memberId);
|
||||
}
|
||||
@ -0,0 +1,30 @@
|
||||
package com.ktds.hi.recommend.infra.gateway.repository;
|
||||
|
||||
import com.ktds.hi.recommend.infra.gateway.entity.TasteProfileEntity;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 취향 프로필 JPA 리포지토리 인터페이스
|
||||
* 취향 프로필 데이터의 CRUD 작업을 담당
|
||||
*/
|
||||
@Repository
|
||||
public interface TasteProfileJpaRepository extends JpaRepository<TasteProfileEntity, Long> {
|
||||
|
||||
/**
|
||||
* 회원 ID로 취향 프로필 조회
|
||||
*/
|
||||
Optional<TasteProfileEntity> findByMemberId(Long memberId);
|
||||
|
||||
/**
|
||||
* 회원 ID로 취향 프로필 존재 여부 확인
|
||||
*/
|
||||
boolean existsByMemberId(Long memberId);
|
||||
|
||||
/**
|
||||
* 회원 ID로 취향 프로필 삭제
|
||||
*/
|
||||
void deleteByMemberId(Long memberId);
|
||||
}
|
||||
44
recommend/src/main/resources/application.yml
Normal file
44
recommend/src/main/resources/application.yml
Normal file
@ -0,0 +1,44 @@
|
||||
server:
|
||||
port: ${RECOMMEND_SERVICE_PORT:8085}
|
||||
|
||||
spring:
|
||||
application:
|
||||
name: recommend-service
|
||||
|
||||
datasource:
|
||||
url: ${RECOMMEND_DB_URL:jdbc:postgresql://localhost:5432/hiorder_recommend}
|
||||
username: ${RECOMMEND_DB_USERNAME:hiorder_user}
|
||||
password: ${RECOMMEND_DB_PASSWORD:hiorder_pass}
|
||||
driver-class-name: org.postgresql.Driver
|
||||
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: ${JPA_DDL_AUTO:validate}
|
||||
show-sql: ${JPA_SHOW_SQL:false}
|
||||
properties:
|
||||
hibernate:
|
||||
format_sql: true
|
||||
dialect: org.hibernate.dialect.PostgreSQLDialect
|
||||
|
||||
redis:
|
||||
host: ${REDIS_HOST:localhost}
|
||||
port: ${REDIS_PORT:6379}
|
||||
password: ${REDIS_PASSWORD:}
|
||||
|
||||
recommendation:
|
||||
cache-ttl: 3600 # 1시간
|
||||
max-recommendations: 20
|
||||
default-radius: 5000 # 5km
|
||||
|
||||
location:
|
||||
google-maps-api-key: ${GOOGLE_MAPS_API_KEY:}
|
||||
|
||||
hiorder-api:
|
||||
base-url: ${HIORDER_API_BASE_URL:https://api.hiorder.com}
|
||||
api-key: ${HIORDER_API_KEY:}
|
||||
|
||||
springdoc:
|
||||
api-docs:
|
||||
path: /api-docs
|
||||
swagger-ui:
|
||||
path: /swagger-ui.html
|
||||
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