From 569404a73dd935b4ad97bf96b90526e943a6d4e9 Mon Sep 17 00:00:00 2001 From: lsh9672 Date: Wed, 18 Jun 2025 09:29:35 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat=20:=20=EB=82=B4=20=EB=A7=A4=EC=9E=A5?= =?UTF-8?q?=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20=EB=B0=98=ED=99=98?= =?UTF-8?q?=EA=B0=92=20=EC=B6=94=EA=B0=80(imgurl)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/ktds/hi/store/biz/service/StoreService.java | 1 + .../java/com/ktds/hi/store/infra/dto/MyStoreListResponse.java | 2 ++ 2 files changed, 3 insertions(+) diff --git a/store/src/main/java/com/ktds/hi/store/biz/service/StoreService.java b/store/src/main/java/com/ktds/hi/store/biz/service/StoreService.java index da2def7..2f389f6 100644 --- a/store/src/main/java/com/ktds/hi/store/biz/service/StoreService.java +++ b/store/src/main/java/com/ktds/hi/store/biz/service/StoreService.java @@ -87,6 +87,7 @@ public class StoreService implements StoreUseCase { .rating(store.getRating()) .reviewCount(store.getReviewCount()) .status("운영중") + .imageUrl(store.getImageUrl()) .operatingHours(store.getOperatingHours()) .build()) .collect(Collectors.toList()); diff --git a/store/src/main/java/com/ktds/hi/store/infra/dto/MyStoreListResponse.java b/store/src/main/java/com/ktds/hi/store/infra/dto/MyStoreListResponse.java index 41efdc2..46f071c 100644 --- a/store/src/main/java/com/ktds/hi/store/infra/dto/MyStoreListResponse.java +++ b/store/src/main/java/com/ktds/hi/store/infra/dto/MyStoreListResponse.java @@ -39,4 +39,6 @@ public class MyStoreListResponse { @Schema(description = "운영시간", example = "월-금 09:00-21:00") private String operatingHours; + @Schema(description = "매장 이미지") + private String imageUrl; } \ No newline at end of file From 96bbc3d83c2989d6da03626e250c7032eb3ae7e7 Mon Sep 17 00:00:00 2001 From: youbeen Date: Wed, 18 Jun 2025 09:52:51 +0900 Subject: [PATCH 2/2] store update --- .../hi/analytics/biz/domain/AiFeedback.java | 70 +- .../infra/controller/AnalyticsController.java | 332 +++--- .../infra/dto/AiAnalysisResponse.java | 112 +- .../infra/dto/AiFeedbackDetailResponse.java | 94 +- .../infra/gateway/AIServiceAdapter.java | 1046 ++++++++--------- .../gateway/AnalyticsRepositoryAdapter.java | 336 +++--- .../gateway/entity/AiFeedbackEntity.java | 144 +-- .../review/infra/config/EventHubConfig.java | 66 +- .../ExternalReviewEventHubAdapter.java | 478 ++++---- .../infra/gateway/entity/ReviewEntity.java | 148 +-- review/src/main/resources/application.yml | 96 +- .../hi/store/biz/service/StoreService.java | 2 +- .../store/infra/dto/StoreUpdateRequest.java | 3 + .../infra/gateway/entity/StoreEntity.java | 3 +- store/src/main/resources/application.yml | 120 +- 15 files changed, 1527 insertions(+), 1523 deletions(-) diff --git a/analytics/src/main/java/com/ktds/hi/analytics/biz/domain/AiFeedback.java b/analytics/src/main/java/com/ktds/hi/analytics/biz/domain/AiFeedback.java index 3e19876..fd68587 100644 --- a/analytics/src/main/java/com/ktds/hi/analytics/biz/domain/AiFeedback.java +++ b/analytics/src/main/java/com/ktds/hi/analytics/biz/domain/AiFeedback.java @@ -1,35 +1,35 @@ -package com.ktds.hi.analytics.biz.domain; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.ToString; - -import java.time.LocalDateTime; -import java.util.List; - -/** - * AI 피드백 도메인 클래스 - * AI가 생성한 피드백 정보를 나타냄 - */ -@Getter -@Builder -@NoArgsConstructor -@AllArgsConstructor -@ToString -public class AiFeedback { - - private Long id; - private Long storeId; - private String summary; - private List positivePoints; - private List negativePoints; - private List improvementPoints; - private List recommendations; - private String sentimentAnalysis; - private Double confidenceScore; - private LocalDateTime generatedAt; - private LocalDateTime createdAt; - private LocalDateTime updatedAt; -} +package com.ktds.hi.analytics.biz.domain; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * AI 피드백 도메인 클래스 + * AI가 생성한 피드백 정보를 나타냄 + */ +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@ToString +public class AiFeedback { + + private Long id; + private Long storeId; + private String summary; + private List positivePoints; + private List negativePoints; + private List improvementPoints; + private List recommendations; + private String sentimentAnalysis; + private Double confidenceScore; + private LocalDateTime generatedAt; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/analytics/src/main/java/com/ktds/hi/analytics/infra/controller/AnalyticsController.java b/analytics/src/main/java/com/ktds/hi/analytics/infra/controller/AnalyticsController.java index f37aeaf..9d574a9 100644 --- a/analytics/src/main/java/com/ktds/hi/analytics/infra/controller/AnalyticsController.java +++ b/analytics/src/main/java/com/ktds/hi/analytics/infra/controller/AnalyticsController.java @@ -1,166 +1,166 @@ -package com.ktds.hi.analytics.infra.controller; - -import com.ktds.hi.analytics.biz.usecase.in.AnalyticsUseCase; -import com.ktds.hi.analytics.infra.dto.*; -import com.ktds.hi.common.dto.ErrorResponse; -import com.ktds.hi.common.dto.SuccessResponse; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.format.annotation.DateTimeFormat; -import org.springframework.http.ResponseEntity; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.*; - -import jakarta.validation.constraints.*; -import java.time.LocalDate; -import java.util.List; - -/** - * 분석 서비스 컨트롤러 클래스 - * 매장 분석, AI 피드백, 통계 조회 API를 제공 - */ -@Slf4j -@RestController -@RequestMapping("/api/analytics") -@RequiredArgsConstructor -@Tag(name = "Analytics API", description = "매장 분석 및 AI 피드백 API") -public class AnalyticsController { - - private final AnalyticsUseCase analyticsUseCase; - - /** - * 매장 분석 데이터 조회 - */ - @Operation(summary = "매장 분석 데이터 조회", description = "매장의 전반적인 분석 데이터를 조회합니다.") - @GetMapping("/stores/{storeId}") - public ResponseEntity> getStoreAnalytics( - @Parameter(description = "매장 ID", required = true) - @PathVariable @NotNull Long storeId) { - - log.info("매장 분석 데이터 조회 요청: storeId={}", storeId); - - StoreAnalyticsResponse response = analyticsUseCase.getStoreAnalytics(storeId); - - return ResponseEntity.ok(SuccessResponse.of(response, "매장 분석 데이터 조회 성공")); - } - - /** - * AI 피드백 상세 조회 - */ - @Operation(summary = "AI 피드백 상세 조회", description = "매장의 AI 피드백 상세 정보를 조회합니다.") - @GetMapping("/stores/{storeId}/ai-feedback") - public ResponseEntity> getAIFeedbackDetail( - @Parameter(description = "매장 ID", required = true) - @PathVariable @NotNull Long storeId) { - - log.info("AI 피드백 상세 조회 요청: storeId={}", storeId); - - AiFeedbackDetailResponse response = analyticsUseCase.getAIFeedbackDetail(storeId); - - return ResponseEntity.ok(SuccessResponse.of(response, "AI 피드백 상세 조회 성공")); - } - - /** - * 매장 통계 조회 - */ - @Operation(summary = "매장 통계 조회", description = "기간별 매장 주문 통계를 조회합니다.") - @GetMapping("/stores/{storeId}/statistics") - public ResponseEntity> getStoreStatistics( - @Parameter(description = "매장 ID", required = true) - @PathVariable @NotNull Long storeId, - - @Parameter(description = "시작 날짜 (YYYY-MM-DD)", required = true) - @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate, - - @Parameter(description = "종료 날짜 (YYYY-MM-DD)", required = true) - @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate) { - - log.info("매장 통계 조회 요청: storeId={}, period={} ~ {}", storeId, startDate, endDate); - - StoreStatisticsResponse response = analyticsUseCase.getStoreStatistics(storeId, startDate, endDate); - - return ResponseEntity.ok(SuccessResponse.of(response, "매장 통계 조회 성공")); - } - - /** - * AI 피드백 요약 조회 - */ - @Operation(summary = "AI 피드백 요약 조회", description = "매장의 AI 피드백 요약 정보를 조회합니다.") - @GetMapping("/stores/{storeId}/ai-feedback/summary") - public ResponseEntity> getAIFeedbackSummary( - @Parameter(description = "매장 ID", required = true) - @PathVariable @NotNull Long storeId) { - - log.info("AI 피드백 요약 조회 요청: storeId={}", storeId); - - AiFeedbackSummaryResponse response = analyticsUseCase.getAIFeedbackSummary(storeId); - - return ResponseEntity.ok(SuccessResponse.of(response, "AI 피드백 요약 조회 성공")); - } - - /** - * 리뷰 분석 조회 - */ - @Operation(summary = "리뷰 분석 조회", description = "매장의 리뷰 감정 분석 결과를 조회합니다.") - @GetMapping("/stores/{storeId}/review-analysis") - public ResponseEntity> getReviewAnalysis( - @Parameter(description = "매장 ID", required = true) - @PathVariable @NotNull Long storeId) { - - log.info("리뷰 분석 조회 요청: storeId={}", storeId); - - ReviewAnalysisResponse response = analyticsUseCase.getReviewAnalysis(storeId); - - return ResponseEntity.ok(SuccessResponse.of(response, "리뷰 분석 조회 성공")); - } - - - /** - * AI 리뷰 분석 및 실행계획 생성 - */ - @Operation(summary = "AI 리뷰 분석", description = "매장 리뷰를 AI로 분석하고 실행계획을 생성합니다.") - @PostMapping("/stores/{storeId}/ai-analysis") - public ResponseEntity> generateAIAnalysis( - @Parameter(description = "매장 ID", required = true) - @PathVariable @NotNull Long storeId, - - @Parameter(description = "분석 요청 정보") - @RequestBody(required = false) @Valid AiAnalysisRequest request) { - - log.info("AI 리뷰 분석 요청: storeId={}", storeId); - - if (request == null) { - request = AiAnalysisRequest.builder().build(); - } - - AiAnalysisResponse response = analyticsUseCase.generateAIAnalysis(storeId, request); - - return ResponseEntity.ok(SuccessResponse.of(response, "AI 분석 완료")); - } - - /** - * AI 피드백 기반 실행계획 생성 - */ - @Operation(summary = "실행계획 생성", description = "AI 피드백을 기반으로 실행계획을 생성합니다.") - @PostMapping("/ai-feedback/{feedbackId}/action-plans") - public ResponseEntity> generateActionPlans( - @Parameter(description = "AI 피드백 ID", required = true) - @PathVariable @NotNull Long feedbackId, - @RequestBody ActionPlanCreateRequest request) { - - - // validation 체크 - if (request.getActionPlanSelect() == null || request.getActionPlanSelect().isEmpty()) { - throw new IllegalArgumentException("실행계획을 생성하려면 개선포인트를 선택해주세요."); - } - - List actionPlans = analyticsUseCase.generateActionPlansFromFeedback(request,feedbackId); - - return ResponseEntity.ok(SuccessResponse.of("실행계획 생성 완료")); - } -} +package com.ktds.hi.analytics.infra.controller; + +import com.ktds.hi.analytics.biz.usecase.in.AnalyticsUseCase; +import com.ktds.hi.analytics.infra.dto.*; +import com.ktds.hi.common.dto.ErrorResponse; +import com.ktds.hi.common.dto.SuccessResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import jakarta.validation.constraints.*; +import java.time.LocalDate; +import java.util.List; + +/** + * 분석 서비스 컨트롤러 클래스 + * 매장 분석, AI 피드백, 통계 조회 API를 제공 + */ +@Slf4j +@RestController +@RequestMapping("/api/analytics") +@RequiredArgsConstructor +@Tag(name = "Analytics API", description = "매장 분석 및 AI 피드백 API") +public class AnalyticsController { + + private final AnalyticsUseCase analyticsUseCase; + + /** + * 매장 분석 데이터 조회 + */ + @Operation(summary = "매장 분석 데이터 조회", description = "매장의 전반적인 분석 데이터를 조회합니다.") + @GetMapping("/stores/{storeId}") + public ResponseEntity> getStoreAnalytics( + @Parameter(description = "매장 ID", required = true) + @PathVariable @NotNull Long storeId) { + + log.info("매장 분석 데이터 조회 요청: storeId={}", storeId); + + StoreAnalyticsResponse response = analyticsUseCase.getStoreAnalytics(storeId); + + return ResponseEntity.ok(SuccessResponse.of(response, "매장 분석 데이터 조회 성공")); + } + + /** + * AI 피드백 상세 조회 + */ + @Operation(summary = "AI 피드백 상세 조회", description = "매장의 AI 피드백 상세 정보를 조회합니다.") + @GetMapping("/stores/{storeId}/ai-feedback") + public ResponseEntity> getAIFeedbackDetail( + @Parameter(description = "매장 ID", required = true) + @PathVariable @NotNull Long storeId) { + + log.info("AI 피드백 상세 조회 요청: storeId={}", storeId); + + AiFeedbackDetailResponse response = analyticsUseCase.getAIFeedbackDetail(storeId); + + return ResponseEntity.ok(SuccessResponse.of(response, "AI 피드백 상세 조회 성공")); + } + + /** + * 매장 통계 조회 + */ + @Operation(summary = "매장 통계 조회", description = "기간별 매장 주문 통계를 조회합니다.") + @GetMapping("/stores/{storeId}/statistics") + public ResponseEntity> getStoreStatistics( + @Parameter(description = "매장 ID", required = true) + @PathVariable @NotNull Long storeId, + + @Parameter(description = "시작 날짜 (YYYY-MM-DD)", required = true) + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate, + + @Parameter(description = "종료 날짜 (YYYY-MM-DD)", required = true) + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate) { + + log.info("매장 통계 조회 요청: storeId={}, period={} ~ {}", storeId, startDate, endDate); + + StoreStatisticsResponse response = analyticsUseCase.getStoreStatistics(storeId, startDate, endDate); + + return ResponseEntity.ok(SuccessResponse.of(response, "매장 통계 조회 성공")); + } + + /** + * AI 피드백 요약 조회 + */ + @Operation(summary = "AI 피드백 요약 조회", description = "매장의 AI 피드백 요약 정보를 조회합니다.") + @GetMapping("/stores/{storeId}/ai-feedback/summary") + public ResponseEntity> getAIFeedbackSummary( + @Parameter(description = "매장 ID", required = true) + @PathVariable @NotNull Long storeId) { + + log.info("AI 피드백 요약 조회 요청: storeId={}", storeId); + + AiFeedbackSummaryResponse response = analyticsUseCase.getAIFeedbackSummary(storeId); + + return ResponseEntity.ok(SuccessResponse.of(response, "AI 피드백 요약 조회 성공")); + } + + /** + * 리뷰 분석 조회 + */ + @Operation(summary = "리뷰 분석 조회", description = "매장의 리뷰 감정 분석 결과를 조회합니다.") + @GetMapping("/stores/{storeId}/review-analysis") + public ResponseEntity> getReviewAnalysis( + @Parameter(description = "매장 ID", required = true) + @PathVariable @NotNull Long storeId) { + + log.info("리뷰 분석 조회 요청: storeId={}", storeId); + + ReviewAnalysisResponse response = analyticsUseCase.getReviewAnalysis(storeId); + + return ResponseEntity.ok(SuccessResponse.of(response, "리뷰 분석 조회 성공")); + } + + + /** + * AI 리뷰 분석 및 실행계획 생성 + */ + @Operation(summary = "AI 리뷰 분석", description = "매장 리뷰를 AI로 분석하고 실행계획을 생성합니다.") + @PostMapping("/stores/{storeId}/ai-analysis") + public ResponseEntity> generateAIAnalysis( + @Parameter(description = "매장 ID", required = true) + @PathVariable @NotNull Long storeId, + + @Parameter(description = "분석 요청 정보") + @RequestBody(required = false) @Valid AiAnalysisRequest request) { + + log.info("AI 리뷰 분석 요청: storeId={}", storeId); + + if (request == null) { + request = AiAnalysisRequest.builder().build(); + } + + AiAnalysisResponse response = analyticsUseCase.generateAIAnalysis(storeId, request); + + return ResponseEntity.ok(SuccessResponse.of(response, "AI 분석 완료")); + } + + /** + * AI 피드백 기반 실행계획 생성 + */ + @Operation(summary = "실행계획 생성", description = "AI 피드백을 기반으로 실행계획을 생성합니다.") + @PostMapping("/ai-feedback/{feedbackId}/action-plans") + public ResponseEntity> generateActionPlans( + @Parameter(description = "AI 피드백 ID", required = true) + @PathVariable @NotNull Long feedbackId, + @RequestBody ActionPlanCreateRequest request) { + + + // validation 체크 + if (request.getActionPlanSelect() == null || request.getActionPlanSelect().isEmpty()) { + throw new IllegalArgumentException("실행계획을 생성하려면 개선포인트를 선택해주세요."); + } + + List actionPlans = analyticsUseCase.generateActionPlansFromFeedback(request,feedbackId); + + return ResponseEntity.ok(SuccessResponse.of("실행계획 생성 완료")); + } +} diff --git a/analytics/src/main/java/com/ktds/hi/analytics/infra/dto/AiAnalysisResponse.java b/analytics/src/main/java/com/ktds/hi/analytics/infra/dto/AiAnalysisResponse.java index c224ae0..6387aac 100644 --- a/analytics/src/main/java/com/ktds/hi/analytics/infra/dto/AiAnalysisResponse.java +++ b/analytics/src/main/java/com/ktds/hi/analytics/infra/dto/AiAnalysisResponse.java @@ -1,57 +1,57 @@ -package com.ktds.hi.analytics.infra.dto; - -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -import java.time.LocalDateTime; -import java.util.List; - -/** - * AI 분석 응답 DTO - */ -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -@Schema(description = "AI 리뷰 분석 결과") -public class AiAnalysisResponse { - - @Schema(description = "매장 ID") - private Long storeId; - - @Schema(description = "AI 피드백 ID") - private Long feedbackId; - - @Schema(description = "분석 요약") - private String summary; - - @Schema(description = "긍정적 요소") - private List positivePoints; - - @Schema(description = "부정적 요소") - private List negativePoints; - - @Schema(description = "개선점") - private List improvementPoints; - - @Schema(description = "추천사항") - private List recommendations; - - @Schema(description = "감정 분석 결과") - private String sentimentAnalysis; - - @Schema(description = "신뢰도 점수") - private Double confidenceScore; - - @Schema(description = "분석된 리뷰 수") - private Integer totalReviewsAnalyzed; - - @Schema(description = "생성된 실행계획") - private List actionPlans; - - @Schema(description = "분석 완료 시간") - private LocalDateTime analyzedAt; +package com.ktds.hi.analytics.infra.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * AI 분석 응답 DTO + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "AI 리뷰 분석 결과") +public class AiAnalysisResponse { + + @Schema(description = "매장 ID") + private Long storeId; + + @Schema(description = "AI 피드백 ID") + private Long feedbackId; + + @Schema(description = "분석 요약") + private String summary; + + @Schema(description = "긍정적 요소") + private List positivePoints; + + @Schema(description = "부정적 요소") + private List negativePoints; + + @Schema(description = "개선점") + private List improvementPoints; + + @Schema(description = "추천사항") + private List recommendations; + + @Schema(description = "감정 분석 결과") + private String sentimentAnalysis; + + @Schema(description = "신뢰도 점수") + private Double confidenceScore; + + @Schema(description = "분석된 리뷰 수") + private Integer totalReviewsAnalyzed; + + @Schema(description = "생성된 실행계획") + private List actionPlans; + + @Schema(description = "분석 완료 시간") + private LocalDateTime analyzedAt; } \ No newline at end of file diff --git a/analytics/src/main/java/com/ktds/hi/analytics/infra/dto/AiFeedbackDetailResponse.java b/analytics/src/main/java/com/ktds/hi/analytics/infra/dto/AiFeedbackDetailResponse.java index 6bc0e0d..ba1a3e3 100644 --- a/analytics/src/main/java/com/ktds/hi/analytics/infra/dto/AiFeedbackDetailResponse.java +++ b/analytics/src/main/java/com/ktds/hi/analytics/infra/dto/AiFeedbackDetailResponse.java @@ -1,47 +1,47 @@ -package com.ktds.hi.analytics.infra.dto; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -import java.time.LocalDateTime; -import java.util.List; -import java.util.Set; -import java.util.stream.Collectors; - -/** - * AI 피드백 상세 응답 DTO - */ -@Getter -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class AiFeedbackDetailResponse { - - private Long feedbackId; - private Long storeId; - private String summary; - private List positivePoints; - private List negativePoints; - private List improvementPoints; - private List existActionPlan; // improvemnetPoints 중에서 처리 된것. - private List recommendations; - private String sentimentAnalysis; - private Double confidenceScore; - private LocalDateTime generatedAt; - - - public void updateImprovementCheck(List actionPlanTitle){ - Set trimmedTitles = actionPlanTitle.stream() - .map(String::trim) - .collect(Collectors.toSet()); - - this.existActionPlan = - improvementPoints.stream() - .map(String::trim) - .filter(point -> trimmedTitles.stream() - .anyMatch(title -> title.contains(point))) - .toList(); - } -} +package com.ktds.hi.analytics.infra.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * AI 피드백 상세 응답 DTO + */ +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AiFeedbackDetailResponse { + + private Long feedbackId; + private Long storeId; + private String summary; + private List positivePoints; + private List negativePoints; + private List improvementPoints; + private List existActionPlan; // improvemnetPoints 중에서 처리 된것. + private List recommendations; + private String sentimentAnalysis; + private Double confidenceScore; + private LocalDateTime generatedAt; + + + public void updateImprovementCheck(List actionPlanTitle){ + Set trimmedTitles = actionPlanTitle.stream() + .map(String::trim) + .collect(Collectors.toSet()); + + this.existActionPlan = + improvementPoints.stream() + .map(String::trim) + .filter(point -> trimmedTitles.stream() + .anyMatch(title -> title.contains(point))) + .toList(); + } +} diff --git a/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/AIServiceAdapter.java b/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/AIServiceAdapter.java index 87202e5..7030634 100644 --- a/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/AIServiceAdapter.java +++ b/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/AIServiceAdapter.java @@ -1,523 +1,523 @@ -package com.ktds.hi.analytics.infra.gateway; - -import static com.azure.ai.textanalytics.models.TextSentiment.*; - -import com.azure.ai.textanalytics.TextAnalyticsClient; -import com.azure.ai.textanalytics.TextAnalyticsClientBuilder; -import com.azure.ai.textanalytics.models.AnalyzeSentimentResult; -import com.azure.ai.textanalytics.models.DocumentSentiment; -import com.azure.ai.textanalytics.models.TextSentiment; -import com.azure.core.credential.AzureKeyCredential; -import com.fasterxml.jackson.annotation.JsonProperty; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -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 jakarta.annotation.PostConstruct; -import lombok.Data; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.HttpEntity; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpMethod; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.stereotype.Component; -import org.springframework.web.client.RestTemplate; - -import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import java.util.Map; - -/** - * AI 서비스 어댑터 클래스 - * OpenAI, Azure Cognitive Services 등 외부 AI API 연동 - */ -@Slf4j -@Component -public class AIServiceAdapter implements AIServicePort { - - - @Value("${ai-api.openai.base-url:https://api.openai.com/v1}") - private String openaiBaseUrl; - - @Value("${ai-api.openai.api-key}") - private String openaiApiKey; - - @Value("${ai-api.openai.model:gpt-4o-mini}") - private String openaiModel; - - private TextAnalyticsClient textAnalyticsClient; - - private RestTemplate restTemplate; - private ObjectMapper objectMapper; - - @PostConstruct - public void initializeClients() { - // Azure Cognitive Services 클라이언트 초기화 - // textAnalyticsClient = new TextAnalyticsClientBuilder() - // .credential(new AzureKeyCredential(cognitiveKey)) - // .endpoint(cognitiveEndpoint) - // .buildClient(); - // - // log.info("AI 서비스 클라이언트 초기화 완료"); - - // OpenAI API 클라이언트 초기화 - restTemplate = new RestTemplate(); - objectMapper = new ObjectMapper(); - - if (openaiApiKey == null || openaiApiKey.trim().isEmpty() || openaiApiKey.equals("your-openai-api-key")) { - log.warn("OpenAI API 키가 설정되지 않았습니다. AI 기능이 제한될 수 있습니다."); - } else { - log.info("OpenAI API 클라이언트 초기화 완료"); - } - } - - @Override - public AiFeedback generateFeedback(List reviewData) { - - log.info("OpenAI 피드백 생성 시작: 리뷰 수={}", reviewData.size()); - - try { - if (reviewData.isEmpty()) { - return createEmptyFeedback(); - } - - // OpenAI API 호출하여 전체 리뷰 분석 - String analysisResult = callOpenAIForAnalysis(reviewData); - - // 결과 파싱 및 AiFeedback 객체 생성 - return parseAnalysisResult(analysisResult, reviewData.size()); - - } catch (Exception e) { - log.error("OpenAI 피드백 생성 중 오류 발생", e); - return createFallbackFeedback(reviewData); - } - } - - - @Override - public SentimentType analyzeSentiment(String content) { - try { - String prompt = String.format( - "다음 리뷰의 감정을 분석해주세요. POSITIVE, NEGATIVE, NEUTRAL 중 하나로만 답변해주세요.\n\n리뷰: %s", - content - ); - - String result = callOpenAI(prompt); - - if (result.toUpperCase().contains("POSITIVE")) { - return SentimentType.POSITIVE; - } else if (result.toUpperCase().contains("NEGATIVE")) { - return SentimentType.NEGATIVE; - } else { - return SentimentType.NEUTRAL; - } - - } catch (Exception e) { - log.warn("OpenAI 감정 분석 실패, 중립으로 처리: content={}", content.substring(0, Math.min(50, content.length()))); - return SentimentType.NEUTRAL; - } - } - - @Override - public List generateActionPlan(List actionPlanSelect, AiFeedback feedback) { - log.info("OpenAI 실행 계획 생성 시작"); - try { - - StringBuffer planFormat = new StringBuffer(); - for(int i = 1; i <= actionPlanSelect.size(); i++) { - planFormat.append(i).append(" [구체적인 실행 계획 ").append(i).append("]\n"); - } - String prompt = String.format( - """ - 다음 AI 피드백을 바탕으로 구체적인 실행 계획 %s개를 생성해주세요. - 각 계획은 실행 가능하고 구체적이어야 합니다. - - 요약: %s - 개선점: %s - - 실행계획 내용은 점주가 반영할 수 있도록 구체적어야 합니다. - 실행 계획을 다음 형식으로 작성해주세요: - %s - """, - actionPlanSelect.size(), - feedback.getSummary(), - String.join(", ", actionPlanSelect), - planFormat - ); - - String result = callOpenAI(prompt); - return parseActionPlans(result); - - } catch (Exception e) { - log.error("OpenAI 실행 계획 생성 중 오류 발생", e); - //TODO : 시연을 위해서 우선은 아래과 같이 처리, 추후에는 실행계획이 실패했다면 Runtime계열의 예외를 던지는게 좋을듯. - return Arrays.asList( - "실행계획 생성 실패. 재생성 필요" - ); - } - } - - /** - * OpenAI API를 호출하여 전체 리뷰 분석 수행 - */ - private String callOpenAIForAnalysis(List reviewData) { - String reviewsText = String.join("\n- ", reviewData); - - String prompt = String.format( - """ - 다음은 한 매장의 고객 리뷰들입니다. 이를 분석하여 다음 JSON 형식으로 답변해주세요: - - { - "summary": "전체적인 분석 요약(2-3문장)", - "positivePoints": ["긍정적 요소1", "긍정적 요소2", "긍정적 요소3"], - "negativePoints": ["부정적 요소1", "부정적 요소2", "부정적 요소3"], - "improvementPoints": ["개선점1", "개선점2", "개선점3"], - "recommendations": ["추천사항1", "추천사항2", "추천사항3"], - "sentimentAnalysis": "전체적인 감정 분석 결과", - "confidenceScore": 0.85 - } - - 리뷰 목록: - - %s - - 분석 시 다음 사항을 고려해주세요: - 1. 긍정적 요소는 고객들이 자주 언급하는 좋은 점들 - 2. 부정적 요소는 고객들이 자주 언급하는 안좋은 점들(없는 경우에는 없음으로 표시) - 2. 개선점은 부정적 피드백이나 불만사항 - 3. 추천사항은 매장 운영에 도움이 될 구체적인 제안 - 4. 신뢰도 점수는 0.0-1.0 사이의 값 - 5. summary에는 전체적인 리뷰 분석에 대한 요약이 잘 담기게 작성. - """, - reviewsText - ); - - return callOpenAI(prompt); - } - - /** - * OpenAI API 호출 - */ - private String callOpenAI(String prompt) { - if (openaiApiKey == null || openaiApiKey.trim().isEmpty() || openaiApiKey.equals("your-openai-api-key")) { - throw new RuntimeException("OpenAI API 키가 설정되지 않았습니다."); - } - - try { - // 요청 헤더 설정 - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - headers.setBearerAuth(openaiApiKey); - - // 요청 바디 생성 - OpenAIRequest request = OpenAIRequest.builder() - .model(openaiModel) - .messages(List.of( - OpenAIMessage.builder() - .role("user") - .content(prompt) - .build() - )) - .maxTokens(1500) - .temperature(0.7) - .build(); - - String requestBody = objectMapper.writeValueAsString(request); - HttpEntity entity = new HttpEntity<>(requestBody, headers); - - // API 호출 - String url = openaiBaseUrl + "/chat/completions"; - ResponseEntity response = restTemplate.exchange( - url, - HttpMethod.POST, - entity, - String.class - ); - - // 응답 파싱 - return parseOpenAIResponse(response.getBody()); - - } catch (Exception e) { - log.error("OpenAI API 호출 실패", e); - throw new RuntimeException("OpenAI API 호출에 실패했습니다.", e); - } - } - - /** - * OpenAI 응답 파싱 - */ - private String parseOpenAIResponse(String responseBody) { - try { - Map response = objectMapper.readValue(responseBody, Map.class); - List> choices = (List>) response.get("choices"); - - if (choices != null && !choices.isEmpty()) { - Map message = (Map) choices.get(0).get("message"); - return (String) message.get("content"); - } - - throw new RuntimeException("OpenAI 응답에서 내용을 찾을 수 없습니다."); - - } catch (JsonProcessingException e) { - log.error("OpenAI 응답 파싱 실패", e); - throw new RuntimeException("OpenAI 응답 파싱에 실패했습니다.", e); - } - } - - /** - * 분석 결과를 AiFeedback 객체로 파싱 - */ - private AiFeedback parseAnalysisResult(String analysisResult, int totalReviews) { - try { - // JSON 형태로 응답이 왔다고 가정하고 파싱 - Map result = objectMapper.readValue(analysisResult, Map.class); - - return AiFeedback.builder() - .summary((String) result.get("summary")) - .positivePoints((List) result.get("positivePoints")) - .negativePoints((List) result.get("negativePoints")) - .improvementPoints((List) result.get("improvementPoints")) - .recommendations((List) result.get("recommendations")) - .sentimentAnalysis((String) result.get("sentimentAnalysis")) - .confidenceScore(((Number) result.get("confidenceScore")).doubleValue()) - .generatedAt(LocalDateTime.now()) - .build(); - - } catch (Exception e) { - log.warn("OpenAI 분석 결과 파싱 실패, 기본 분석 수행", e); - return performBasicAnalysis(analysisResult, totalReviews); - } - } - - /** - * 기본 분석 수행 (파싱 실패 시 fallback) - */ - private AiFeedback performBasicAnalysis(String analysisResult, int totalReviews) { - return AiFeedback.builder() - .summary(String.format("총 %d개의 리뷰를 AI로 분석했습니다.", totalReviews)) - .positivePoints(Arrays.asList("고객 서비스", "음식 품질", "매장 분위기")) - .improvementPoints(Arrays.asList("대기시간 단축", "메뉴 다양성", "가격 경쟁력")) - .recommendations(Arrays.asList("고객 피드백 적극 반영", "서비스 교육 강화", "매장 환경 개선")) - .sentimentAnalysis("전반적으로 긍정적인 평가") - .confidenceScore(0.75) - .generatedAt(LocalDateTime.now()) - .build(); - } - - /** - * 실행 계획 파싱 - */ - private List parseActionPlans(String result) { - // 숫자로 시작하는 라인들을 찾아서 실행 계획으로 추출 - String[] lines = result.split("\n"); - return Arrays.stream(lines) - .filter(line -> line.matches("^\\d+\\..*")) - .map(line -> line.replaceFirst("^\\d+\\.\\s*", "").trim()) - .filter(line -> !line.isEmpty()) - .limit(5) // 최대 5개까지 - .toList(); - } - - /** - * 빈 피드백 생성 - */ - private AiFeedback createEmptyFeedback() { - return AiFeedback.builder() - .summary("분석할 리뷰 데이터가 없습니다.") - .positivePoints(Arrays.asList("리뷰 데이터 부족으로 분석 불가")) - .improvementPoints(Arrays.asList("더 많은 고객 리뷰 수집 필요")) - .recommendations(Arrays.asList("고객들에게 리뷰 작성을 유도하는 이벤트 진행")) - .sentimentAnalysis("데이터 부족") - .confidenceScore(0.0) - .generatedAt(LocalDateTime.now()) - .build(); - } - - /** - * Fallback 피드백 생성 (OpenAI 호출 실패 시) - */ - private AiFeedback createFallbackFeedback(List reviewData) { - log.warn("OpenAI 호출 실패로 fallback 분석 수행"); - - // 간단한 키워드 기반 분석 - long positiveCount = reviewData.stream() - .mapToLong(review -> countPositiveKeywords(review)) - .sum(); - - long negativeCount = reviewData.stream() - .mapToLong(review -> countNegativeKeywords(review)) - .sum(); - - double positiveRate = positiveCount > 0 ? (double) positiveCount / (positiveCount + negativeCount) * 100 : 50.0; - - return AiFeedback.builder() - .summary(String.format("총 %d개의 리뷰를 분석했습니다. (간편 분석)", reviewData.size())) - .positivePoints(Arrays.asList("서비스", "맛", "분위기")) - .improvementPoints(Arrays.asList("대기시간", "가격", "청결도")) - .recommendations(Arrays.asList("고객 의견 수렴", "서비스 개선", "품질 향상")) - .sentimentAnalysis(String.format("긍정 비율: %.1f%%", positiveRate)) - .confidenceScore(0.6) - .generatedAt(LocalDateTime.now()) - .build(); - } - - private long countPositiveKeywords(String review) { - String[] positiveWords = {"좋", "맛있", "친절", "깨끗", "만족", "추천", "최고"}; - return Arrays.stream(positiveWords) - .mapToLong(word -> review.toLowerCase().contains(word) ? 1 : 0) - .sum(); - } - - private long countNegativeKeywords(String review) { - String[] negativeWords = {"나쁘", "맛없", "불친절", "더럽", "실망", "최악", "별로"}; - return Arrays.stream(negativeWords) - .mapToLong(word -> review.toLowerCase().contains(word) ? 1 : 0) - .sum(); - } - - // OpenAI API 요청/응답 DTO 클래스들 - @Data - @lombok.Builder - private static class OpenAIRequest { - private String model; - private List messages; - @JsonProperty("max_tokens") - private Integer maxTokens; - private Double temperature; - } - - @Data - @lombok.Builder - private static class OpenAIMessage { - private String role; - private String content; - } - - - /** - * 요약 생성 - */ - private String generateSummary(double positiveRate, double negativeRate, int totalReviews) { - if (positiveRate > 70) { - return String.format("총 %d개의 리뷰 중 %.1f%%가 긍정적입니다. 고객 만족도가 높은 수준입니다.", - totalReviews, positiveRate); - } else if (negativeRate > 30) { - return String.format("총 %d개의 리뷰 중 %.1f%%가 부정적입니다. 서비스 개선이 필요합니다.", - totalReviews, negativeRate); - } else { - return String.format("총 %d개의 리뷰로 분석한 결과, 전반적으로 평균적인 고객 만족도를 보입니다.", - totalReviews); - } - } - - /** - * 긍정적 요소 생성 - */ - private List generatePositivePoints(List reviewData, List sentiments) { - List positivePoints = new ArrayList<>(); - - long positiveCount = sentiments.stream().mapToLong(s -> s == SentimentType.POSITIVE ? 1 : 0).sum(); - double positiveRate = (double) positiveCount / reviewData.size() * 100; - - if (positiveRate > 70) { - positivePoints.add("고객 만족도가 매우 높습니다"); - positivePoints.add("전반적으로 긍정적인 평가를 받고 있습니다"); - positivePoints.add("재방문 의향이 높은 고객들이 많습니다"); - } else if (positiveRate > 50) { - positivePoints.add("평균 이상의 고객 만족도를 보입니다"); - positivePoints.add("많은 고객들이 만족하고 있습니다"); - } else { - positivePoints.add("일부 고객들이 긍정적으로 평가하고 있습니다"); - positivePoints.add("개선의 여지가 있습니다"); - } - - return positivePoints; - } - - /** - * 개선점 생성 - */ - private List generateImprovementPoints(List reviewData, List sentiments) { - List improvementPoints = new ArrayList<>(); - - long negativeCount = sentiments.stream().mapToLong(s -> s == SentimentType.NEGATIVE ? 1 : 0).sum(); - double negativeRate = (double) negativeCount / reviewData.size() * 100; - - if (negativeRate > 30) { - improvementPoints.add("고객 서비스 품질 개선이 시급합니다"); - improvementPoints.add("부정적 피드백에 대한 체계적 대응이 필요합니다"); - improvementPoints.add("근본적인 서비스 개선 방안을 마련해야 합니다"); - } else if (negativeRate > 15) { - improvementPoints.add("일부 서비스 영역에서 개선이 필요합니다"); - improvementPoints.add("고객 만족도 향상을 위한 노력이 필요합니다"); - } else { - improvementPoints.add("현재 서비스 수준을 유지하며 세부 개선점을 찾아보세요"); - improvementPoints.add("더 높은 고객 만족을 위한 차별화 요소를 개발하세요"); - } - - return improvementPoints; - } - - /** - * 추천사항 생성 - */ - private List generateRecommendations(double positiveRate, double negativeRate) { - List recommendations = new ArrayList<>(); - - if (positiveRate > 70) { - recommendations.add("현재의 우수한 서비스를 유지하면서 브랜드 가치를 높이세요"); - recommendations.add("긍정적 리뷰를 마케팅 자료로 활용하세요"); - recommendations.add("고객 충성도 프로그램을 도입하세요"); - } else if (negativeRate > 30) { - recommendations.add("고객 불만사항에 대한 즉각적인 대응 체계를 구축하세요"); - recommendations.add("직원 교육을 통한 서비스 품질 향상에 집중하세요"); - recommendations.add("고객 피드백 수집 및 분석 프로세스를 강화하세요"); - } else { - recommendations.add("지속적인 품질 관리와 고객 만족도 모니터링을 실시하세요"); - recommendations.add("차별화된 서비스 제공을 통해 경쟁력을 강화하세요"); - recommendations.add("고객과의 소통을 늘려 관계를 강화하세요"); - } - - return recommendations; - } - - /** - * 신뢰도 점수 계산 - */ - private double calculateConfidenceScore(int reviewCount) { - if (reviewCount >= 50) { - return 0.9; - } else if (reviewCount >= 20) { - return 0.75; - } else if (reviewCount >= 10) { - return 0.6; - } else if (reviewCount >= 5) { - return 0.4; - } else { - return 0.2; - } - } - - /** - * 개선점을 실행 계획으로 변환 - */ - private String convertToActionPlan(String improvementPoint) { - // 개선점을 구체적인 실행계획으로 변환 - if (improvementPoint.contains("서비스 품질")) { - return "직원 서비스 교육 프로그램 실시 (월 1회, 2시간)"; - } else if (improvementPoint.contains("대기시간")) { - return "주문 처리 시스템 개선 및 대기열 관리 체계 도입"; - } else if (improvementPoint.contains("가격")) { - return "경쟁사 가격 분석 및 합리적 가격 정책 수립"; - } else if (improvementPoint.contains("메뉴")) { - return "고객 선호도 조사를 통한 메뉴 다양화 방안 검토"; - } else { - return "고객 피드백 기반 서비스 개선 계획 수립"; - } - } -} +package com.ktds.hi.analytics.infra.gateway; + +import static com.azure.ai.textanalytics.models.TextSentiment.*; + +import com.azure.ai.textanalytics.TextAnalyticsClient; +import com.azure.ai.textanalytics.TextAnalyticsClientBuilder; +import com.azure.ai.textanalytics.models.AnalyzeSentimentResult; +import com.azure.ai.textanalytics.models.DocumentSentiment; +import com.azure.ai.textanalytics.models.TextSentiment; +import com.azure.core.credential.AzureKeyCredential; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +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 jakarta.annotation.PostConstruct; +import lombok.Data; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +/** + * AI 서비스 어댑터 클래스 + * OpenAI, Azure Cognitive Services 등 외부 AI API 연동 + */ +@Slf4j +@Component +public class AIServiceAdapter implements AIServicePort { + + + @Value("${ai-api.openai.base-url:https://api.openai.com/v1}") + private String openaiBaseUrl; + + @Value("${ai-api.openai.api-key}") + private String openaiApiKey; + + @Value("${ai-api.openai.model:gpt-4o-mini}") + private String openaiModel; + + private TextAnalyticsClient textAnalyticsClient; + + private RestTemplate restTemplate; + private ObjectMapper objectMapper; + + @PostConstruct + public void initializeClients() { + // Azure Cognitive Services 클라이언트 초기화 + // textAnalyticsClient = new TextAnalyticsClientBuilder() + // .credential(new AzureKeyCredential(cognitiveKey)) + // .endpoint(cognitiveEndpoint) + // .buildClient(); + // + // log.info("AI 서비스 클라이언트 초기화 완료"); + + // OpenAI API 클라이언트 초기화 + restTemplate = new RestTemplate(); + objectMapper = new ObjectMapper(); + + if (openaiApiKey == null || openaiApiKey.trim().isEmpty() || openaiApiKey.equals("your-openai-api-key")) { + log.warn("OpenAI API 키가 설정되지 않았습니다. AI 기능이 제한될 수 있습니다."); + } else { + log.info("OpenAI API 클라이언트 초기화 완료"); + } + } + + @Override + public AiFeedback generateFeedback(List reviewData) { + + log.info("OpenAI 피드백 생성 시작: 리뷰 수={}", reviewData.size()); + + try { + if (reviewData.isEmpty()) { + return createEmptyFeedback(); + } + + // OpenAI API 호출하여 전체 리뷰 분석 + String analysisResult = callOpenAIForAnalysis(reviewData); + + // 결과 파싱 및 AiFeedback 객체 생성 + return parseAnalysisResult(analysisResult, reviewData.size()); + + } catch (Exception e) { + log.error("OpenAI 피드백 생성 중 오류 발생", e); + return createFallbackFeedback(reviewData); + } + } + + + @Override + public SentimentType analyzeSentiment(String content) { + try { + String prompt = String.format( + "다음 리뷰의 감정을 분석해주세요. POSITIVE, NEGATIVE, NEUTRAL 중 하나로만 답변해주세요.\n\n리뷰: %s", + content + ); + + String result = callOpenAI(prompt); + + if (result.toUpperCase().contains("POSITIVE")) { + return SentimentType.POSITIVE; + } else if (result.toUpperCase().contains("NEGATIVE")) { + return SentimentType.NEGATIVE; + } else { + return SentimentType.NEUTRAL; + } + + } catch (Exception e) { + log.warn("OpenAI 감정 분석 실패, 중립으로 처리: content={}", content.substring(0, Math.min(50, content.length()))); + return SentimentType.NEUTRAL; + } + } + + @Override + public List generateActionPlan(List actionPlanSelect, AiFeedback feedback) { + log.info("OpenAI 실행 계획 생성 시작"); + try { + + StringBuffer planFormat = new StringBuffer(); + for(int i = 1; i <= actionPlanSelect.size(); i++) { + planFormat.append(i).append(" [구체적인 실행 계획 ").append(i).append("]\n"); + } + String prompt = String.format( + """ + 다음 AI 피드백을 바탕으로 구체적인 실행 계획 %s개를 생성해주세요. + 각 계획은 실행 가능하고 구체적이어야 합니다. + + 요약: %s + 개선점: %s + + 실행계획 내용은 점주가 반영할 수 있도록 구체적어야 합니다. + 실행 계획을 다음 형식으로 작성해주세요: + %s + """, + actionPlanSelect.size(), + feedback.getSummary(), + String.join(", ", actionPlanSelect), + planFormat + ); + + String result = callOpenAI(prompt); + return parseActionPlans(result); + + } catch (Exception e) { + log.error("OpenAI 실행 계획 생성 중 오류 발생", e); + //TODO : 시연을 위해서 우선은 아래과 같이 처리, 추후에는 실행계획이 실패했다면 Runtime계열의 예외를 던지는게 좋을듯. + return Arrays.asList( + "실행계획 생성 실패. 재생성 필요" + ); + } + } + + /** + * OpenAI API를 호출하여 전체 리뷰 분석 수행 + */ + private String callOpenAIForAnalysis(List reviewData) { + String reviewsText = String.join("\n- ", reviewData); + + String prompt = String.format( + """ + 다음은 한 매장의 고객 리뷰들입니다. 이를 분석하여 다음 JSON 형식으로 답변해주세요: + + { + "summary": "전체적인 분석 요약(2-3문장)", + "positivePoints": ["긍정적 요소1", "긍정적 요소2", "긍정적 요소3"], + "negativePoints": ["부정적 요소1", "부정적 요소2", "부정적 요소3"], + "improvementPoints": ["개선점1", "개선점2", "개선점3"], + "recommendations": ["추천사항1", "추천사항2", "추천사항3"], + "sentimentAnalysis": "전체적인 감정 분석 결과", + "confidenceScore": 0.85 + } + + 리뷰 목록: + - %s + + 분석 시 다음 사항을 고려해주세요: + 1. 긍정적 요소는 고객들이 자주 언급하는 좋은 점들 + 2. 부정적 요소는 고객들이 자주 언급하는 안좋은 점들(없는 경우에는 없음으로 표시) + 2. 개선점은 부정적 피드백이나 불만사항 + 3. 추천사항은 매장 운영에 도움이 될 구체적인 제안 + 4. 신뢰도 점수는 0.0-1.0 사이의 값 + 5. summary에는 전체적인 리뷰 분석에 대한 요약이 잘 담기게 작성. + """, + reviewsText + ); + + return callOpenAI(prompt); + } + + /** + * OpenAI API 호출 + */ + private String callOpenAI(String prompt) { + if (openaiApiKey == null || openaiApiKey.trim().isEmpty() || openaiApiKey.equals("your-openai-api-key")) { + throw new RuntimeException("OpenAI API 키가 설정되지 않았습니다."); + } + + try { + // 요청 헤더 설정 + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.setBearerAuth(openaiApiKey); + + // 요청 바디 생성 + OpenAIRequest request = OpenAIRequest.builder() + .model(openaiModel) + .messages(List.of( + OpenAIMessage.builder() + .role("user") + .content(prompt) + .build() + )) + .maxTokens(1500) + .temperature(0.7) + .build(); + + String requestBody = objectMapper.writeValueAsString(request); + HttpEntity entity = new HttpEntity<>(requestBody, headers); + + // API 호출 + String url = openaiBaseUrl + "/chat/completions"; + ResponseEntity response = restTemplate.exchange( + url, + HttpMethod.POST, + entity, + String.class + ); + + // 응답 파싱 + return parseOpenAIResponse(response.getBody()); + + } catch (Exception e) { + log.error("OpenAI API 호출 실패", e); + throw new RuntimeException("OpenAI API 호출에 실패했습니다.", e); + } + } + + /** + * OpenAI 응답 파싱 + */ + private String parseOpenAIResponse(String responseBody) { + try { + Map response = objectMapper.readValue(responseBody, Map.class); + List> choices = (List>) response.get("choices"); + + if (choices != null && !choices.isEmpty()) { + Map message = (Map) choices.get(0).get("message"); + return (String) message.get("content"); + } + + throw new RuntimeException("OpenAI 응답에서 내용을 찾을 수 없습니다."); + + } catch (JsonProcessingException e) { + log.error("OpenAI 응답 파싱 실패", e); + throw new RuntimeException("OpenAI 응답 파싱에 실패했습니다.", e); + } + } + + /** + * 분석 결과를 AiFeedback 객체로 파싱 + */ + private AiFeedback parseAnalysisResult(String analysisResult, int totalReviews) { + try { + // JSON 형태로 응답이 왔다고 가정하고 파싱 + Map result = objectMapper.readValue(analysisResult, Map.class); + + return AiFeedback.builder() + .summary((String) result.get("summary")) + .positivePoints((List) result.get("positivePoints")) + .negativePoints((List) result.get("negativePoints")) + .improvementPoints((List) result.get("improvementPoints")) + .recommendations((List) result.get("recommendations")) + .sentimentAnalysis((String) result.get("sentimentAnalysis")) + .confidenceScore(((Number) result.get("confidenceScore")).doubleValue()) + .generatedAt(LocalDateTime.now()) + .build(); + + } catch (Exception e) { + log.warn("OpenAI 분석 결과 파싱 실패, 기본 분석 수행", e); + return performBasicAnalysis(analysisResult, totalReviews); + } + } + + /** + * 기본 분석 수행 (파싱 실패 시 fallback) + */ + private AiFeedback performBasicAnalysis(String analysisResult, int totalReviews) { + return AiFeedback.builder() + .summary(String.format("총 %d개의 리뷰를 AI로 분석했습니다.", totalReviews)) + .positivePoints(Arrays.asList("고객 서비스", "음식 품질", "매장 분위기")) + .improvementPoints(Arrays.asList("대기시간 단축", "메뉴 다양성", "가격 경쟁력")) + .recommendations(Arrays.asList("고객 피드백 적극 반영", "서비스 교육 강화", "매장 환경 개선")) + .sentimentAnalysis("전반적으로 긍정적인 평가") + .confidenceScore(0.75) + .generatedAt(LocalDateTime.now()) + .build(); + } + + /** + * 실행 계획 파싱 + */ + private List parseActionPlans(String result) { + // 숫자로 시작하는 라인들을 찾아서 실행 계획으로 추출 + String[] lines = result.split("\n"); + return Arrays.stream(lines) + .filter(line -> line.matches("^\\d+\\..*")) + .map(line -> line.replaceFirst("^\\d+\\.\\s*", "").trim()) + .filter(line -> !line.isEmpty()) + .limit(5) // 최대 5개까지 + .toList(); + } + + /** + * 빈 피드백 생성 + */ + private AiFeedback createEmptyFeedback() { + return AiFeedback.builder() + .summary("분석할 리뷰 데이터가 없습니다.") + .positivePoints(Arrays.asList("리뷰 데이터 부족으로 분석 불가")) + .improvementPoints(Arrays.asList("더 많은 고객 리뷰 수집 필요")) + .recommendations(Arrays.asList("고객들에게 리뷰 작성을 유도하는 이벤트 진행")) + .sentimentAnalysis("데이터 부족") + .confidenceScore(0.0) + .generatedAt(LocalDateTime.now()) + .build(); + } + + /** + * Fallback 피드백 생성 (OpenAI 호출 실패 시) + */ + private AiFeedback createFallbackFeedback(List reviewData) { + log.warn("OpenAI 호출 실패로 fallback 분석 수행"); + + // 간단한 키워드 기반 분석 + long positiveCount = reviewData.stream() + .mapToLong(review -> countPositiveKeywords(review)) + .sum(); + + long negativeCount = reviewData.stream() + .mapToLong(review -> countNegativeKeywords(review)) + .sum(); + + double positiveRate = positiveCount > 0 ? (double) positiveCount / (positiveCount + negativeCount) * 100 : 50.0; + + return AiFeedback.builder() + .summary(String.format("총 %d개의 리뷰를 분석했습니다. (간편 분석)", reviewData.size())) + .positivePoints(Arrays.asList("서비스", "맛", "분위기")) + .improvementPoints(Arrays.asList("대기시간", "가격", "청결도")) + .recommendations(Arrays.asList("고객 의견 수렴", "서비스 개선", "품질 향상")) + .sentimentAnalysis(String.format("긍정 비율: %.1f%%", positiveRate)) + .confidenceScore(0.6) + .generatedAt(LocalDateTime.now()) + .build(); + } + + private long countPositiveKeywords(String review) { + String[] positiveWords = {"좋", "맛있", "친절", "깨끗", "만족", "추천", "최고"}; + return Arrays.stream(positiveWords) + .mapToLong(word -> review.toLowerCase().contains(word) ? 1 : 0) + .sum(); + } + + private long countNegativeKeywords(String review) { + String[] negativeWords = {"나쁘", "맛없", "불친절", "더럽", "실망", "최악", "별로"}; + return Arrays.stream(negativeWords) + .mapToLong(word -> review.toLowerCase().contains(word) ? 1 : 0) + .sum(); + } + + // OpenAI API 요청/응답 DTO 클래스들 + @Data + @lombok.Builder + private static class OpenAIRequest { + private String model; + private List messages; + @JsonProperty("max_tokens") + private Integer maxTokens; + private Double temperature; + } + + @Data + @lombok.Builder + private static class OpenAIMessage { + private String role; + private String content; + } + + + /** + * 요약 생성 + */ + private String generateSummary(double positiveRate, double negativeRate, int totalReviews) { + if (positiveRate > 70) { + return String.format("총 %d개의 리뷰 중 %.1f%%가 긍정적입니다. 고객 만족도가 높은 수준입니다.", + totalReviews, positiveRate); + } else if (negativeRate > 30) { + return String.format("총 %d개의 리뷰 중 %.1f%%가 부정적입니다. 서비스 개선이 필요합니다.", + totalReviews, negativeRate); + } else { + return String.format("총 %d개의 리뷰로 분석한 결과, 전반적으로 평균적인 고객 만족도를 보입니다.", + totalReviews); + } + } + + /** + * 긍정적 요소 생성 + */ + private List generatePositivePoints(List reviewData, List sentiments) { + List positivePoints = new ArrayList<>(); + + long positiveCount = sentiments.stream().mapToLong(s -> s == SentimentType.POSITIVE ? 1 : 0).sum(); + double positiveRate = (double) positiveCount / reviewData.size() * 100; + + if (positiveRate > 70) { + positivePoints.add("고객 만족도가 매우 높습니다"); + positivePoints.add("전반적으로 긍정적인 평가를 받고 있습니다"); + positivePoints.add("재방문 의향이 높은 고객들이 많습니다"); + } else if (positiveRate > 50) { + positivePoints.add("평균 이상의 고객 만족도를 보입니다"); + positivePoints.add("많은 고객들이 만족하고 있습니다"); + } else { + positivePoints.add("일부 고객들이 긍정적으로 평가하고 있습니다"); + positivePoints.add("개선의 여지가 있습니다"); + } + + return positivePoints; + } + + /** + * 개선점 생성 + */ + private List generateImprovementPoints(List reviewData, List sentiments) { + List improvementPoints = new ArrayList<>(); + + long negativeCount = sentiments.stream().mapToLong(s -> s == SentimentType.NEGATIVE ? 1 : 0).sum(); + double negativeRate = (double) negativeCount / reviewData.size() * 100; + + if (negativeRate > 30) { + improvementPoints.add("고객 서비스 품질 개선이 시급합니다"); + improvementPoints.add("부정적 피드백에 대한 체계적 대응이 필요합니다"); + improvementPoints.add("근본적인 서비스 개선 방안을 마련해야 합니다"); + } else if (negativeRate > 15) { + improvementPoints.add("일부 서비스 영역에서 개선이 필요합니다"); + improvementPoints.add("고객 만족도 향상을 위한 노력이 필요합니다"); + } else { + improvementPoints.add("현재 서비스 수준을 유지하며 세부 개선점을 찾아보세요"); + improvementPoints.add("더 높은 고객 만족을 위한 차별화 요소를 개발하세요"); + } + + return improvementPoints; + } + + /** + * 추천사항 생성 + */ + private List generateRecommendations(double positiveRate, double negativeRate) { + List recommendations = new ArrayList<>(); + + if (positiveRate > 70) { + recommendations.add("현재의 우수한 서비스를 유지하면서 브랜드 가치를 높이세요"); + recommendations.add("긍정적 리뷰를 마케팅 자료로 활용하세요"); + recommendations.add("고객 충성도 프로그램을 도입하세요"); + } else if (negativeRate > 30) { + recommendations.add("고객 불만사항에 대한 즉각적인 대응 체계를 구축하세요"); + recommendations.add("직원 교육을 통한 서비스 품질 향상에 집중하세요"); + recommendations.add("고객 피드백 수집 및 분석 프로세스를 강화하세요"); + } else { + recommendations.add("지속적인 품질 관리와 고객 만족도 모니터링을 실시하세요"); + recommendations.add("차별화된 서비스 제공을 통해 경쟁력을 강화하세요"); + recommendations.add("고객과의 소통을 늘려 관계를 강화하세요"); + } + + return recommendations; + } + + /** + * 신뢰도 점수 계산 + */ + private double calculateConfidenceScore(int reviewCount) { + if (reviewCount >= 50) { + return 0.9; + } else if (reviewCount >= 20) { + return 0.75; + } else if (reviewCount >= 10) { + return 0.6; + } else if (reviewCount >= 5) { + return 0.4; + } else { + return 0.2; + } + } + + /** + * 개선점을 실행 계획으로 변환 + */ + private String convertToActionPlan(String improvementPoint) { + // 개선점을 구체적인 실행계획으로 변환 + if (improvementPoint.contains("서비스 품질")) { + return "직원 서비스 교육 프로그램 실시 (월 1회, 2시간)"; + } else if (improvementPoint.contains("대기시간")) { + return "주문 처리 시스템 개선 및 대기열 관리 체계 도입"; + } else if (improvementPoint.contains("가격")) { + return "경쟁사 가격 분석 및 합리적 가격 정책 수립"; + } else if (improvementPoint.contains("메뉴")) { + return "고객 선호도 조사를 통한 메뉴 다양화 방안 검토"; + } else { + return "고객 피드백 기반 서비스 개선 계획 수립"; + } + } +} diff --git a/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/AnalyticsRepositoryAdapter.java b/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/AnalyticsRepositoryAdapter.java index c24b7a3..cdcac1c 100644 --- a/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/AnalyticsRepositoryAdapter.java +++ b/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/AnalyticsRepositoryAdapter.java @@ -1,168 +1,168 @@ -package com.ktds.hi.analytics.infra.gateway; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; -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 lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; - -import java.util.List; -import java.util.Optional; - -/** - * 분석 리포지토리 어댑터 클래스 (완성버전) - * Analytics Port를 구현하여 데이터 영속성 기능을 제공 - */ -@Slf4j -@Component -@RequiredArgsConstructor -public class AnalyticsRepositoryAdapter implements AnalyticsPort { - - private final AnalyticsJpaRepository analyticsJpaRepository; - private final AiFeedbackJpaRepository aiFeedbackJpaRepository; - private final ObjectMapper objectMapper; - - @Override - public Optional findAnalyticsByStoreId(Long storeId) { - return analyticsJpaRepository.findLatestByStoreId(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 findAIFeedbackByStoreId(Long storeId) { - return aiFeedbackJpaRepository.findLatestByStoreId(storeId) - .map(this::toAiFeedbackDomain); - } - - @Override - public AiFeedback saveAIFeedback(AiFeedback feedback) { - AiFeedbackEntity entity = toAiFeedbackEntity(feedback); - AiFeedbackEntity saved = aiFeedbackJpaRepository.save(entity); - return toAiFeedbackDomain(saved); - } - - @Override - public Optional findAIFeedbackById(Long feedbackId) { - return aiFeedbackJpaRepository.findById(feedbackId) - .map(this::toAiFeedbackDomain); - } - - /** - * Analytics 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()) - .positiveReviewRate(entity.getPositiveReviewRate()) - .negativeReviewRate(entity.getNegativeReviewRate()) - .lastAnalysisDate(entity.getLastAnalysisDate()) - .createdAt(entity.getCreatedAt()) - .updatedAt(entity.getUpdatedAt()) - .build(); - } - - /** - * Analytics 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()) - .positiveReviewRate(domain.getPositiveReviewRate()) - .negativeReviewRate(domain.getNegativeReviewRate()) - .lastAnalysisDate(domain.getLastAnalysisDate()) - .build(); - } - - /** - * AiFeedback Entity를 Domain으로 변환 - */ - private AiFeedback toAiFeedbackDomain(AiFeedbackEntity entity) { - return AiFeedback.builder() - .id(entity.getId()) - .storeId(entity.getStoreId()) - .summary(entity.getSummary()) - .positivePoints(parseJsonToList(entity.getPositivePointsJson())) - .negativePoints(parseJsonToList(entity.getNegativePointsJson())) - .improvementPoints(parseJsonToList(entity.getImprovementPointsJson())) - .recommendations(parseJsonToList(entity.getRecommendationsJson())) - .sentimentAnalysis(entity.getSentimentAnalysis()) - .confidenceScore(entity.getConfidenceScore()) - .generatedAt(entity.getGeneratedAt()) - .createdAt(entity.getCreatedAt()) - .updatedAt(entity.getUpdatedAt()) - .build(); - } - - /** - * AiFeedback Domain을 Entity로 변환 - */ - private AiFeedbackEntity toAiFeedbackEntity(AiFeedback domain) { - return AiFeedbackEntity.builder() - .id(domain.getId()) - .storeId(domain.getStoreId()) - .summary(domain.getSummary()) - .positivePointsJson(parseListToJson(domain.getPositivePoints())) - .negativePointsJson(parseListToJson(domain.getNegativePoints())) - .improvementPointsJson(parseListToJson(domain.getImprovementPoints())) - .recommendationsJson(parseListToJson(domain.getRecommendations())) - .sentimentAnalysis(domain.getSentimentAnalysis()) - .confidenceScore(domain.getConfidenceScore()) - .generatedAt(domain.getGeneratedAt()) - .build(); - } - - /** - * JSON 문자열을 List로 변환 - */ - private List parseJsonToList(String json) { - if (json == null || json.trim().isEmpty()) { - return List.of(); - } - - try { - return objectMapper.readValue(json, new TypeReference>() {}); - } catch (JsonProcessingException e) { - log.warn("JSON 파싱 실패: {}", json, e); - return List.of(); - } - } - - /** - * List를 JSON 문자열로 변환 - */ - private String parseListToJson(List list) { - if (list == null || list.isEmpty()) { - return "[]"; - } - - try { - return objectMapper.writeValueAsString(list); - } catch (JsonProcessingException e) { - log.warn("JSON 직렬화 실패: {}", list, e); - return "[]"; - } - } -} +package com.ktds.hi.analytics.infra.gateway; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +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 lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Optional; + +/** + * 분석 리포지토리 어댑터 클래스 (완성버전) + * Analytics Port를 구현하여 데이터 영속성 기능을 제공 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class AnalyticsRepositoryAdapter implements AnalyticsPort { + + private final AnalyticsJpaRepository analyticsJpaRepository; + private final AiFeedbackJpaRepository aiFeedbackJpaRepository; + private final ObjectMapper objectMapper; + + @Override + public Optional findAnalyticsByStoreId(Long storeId) { + return analyticsJpaRepository.findLatestByStoreId(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 findAIFeedbackByStoreId(Long storeId) { + return aiFeedbackJpaRepository.findLatestByStoreId(storeId) + .map(this::toAiFeedbackDomain); + } + + @Override + public AiFeedback saveAIFeedback(AiFeedback feedback) { + AiFeedbackEntity entity = toAiFeedbackEntity(feedback); + AiFeedbackEntity saved = aiFeedbackJpaRepository.save(entity); + return toAiFeedbackDomain(saved); + } + + @Override + public Optional findAIFeedbackById(Long feedbackId) { + return aiFeedbackJpaRepository.findById(feedbackId) + .map(this::toAiFeedbackDomain); + } + + /** + * Analytics 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()) + .positiveReviewRate(entity.getPositiveReviewRate()) + .negativeReviewRate(entity.getNegativeReviewRate()) + .lastAnalysisDate(entity.getLastAnalysisDate()) + .createdAt(entity.getCreatedAt()) + .updatedAt(entity.getUpdatedAt()) + .build(); + } + + /** + * Analytics 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()) + .positiveReviewRate(domain.getPositiveReviewRate()) + .negativeReviewRate(domain.getNegativeReviewRate()) + .lastAnalysisDate(domain.getLastAnalysisDate()) + .build(); + } + + /** + * AiFeedback Entity를 Domain으로 변환 + */ + private AiFeedback toAiFeedbackDomain(AiFeedbackEntity entity) { + return AiFeedback.builder() + .id(entity.getId()) + .storeId(entity.getStoreId()) + .summary(entity.getSummary()) + .positivePoints(parseJsonToList(entity.getPositivePointsJson())) + .negativePoints(parseJsonToList(entity.getNegativePointsJson())) + .improvementPoints(parseJsonToList(entity.getImprovementPointsJson())) + .recommendations(parseJsonToList(entity.getRecommendationsJson())) + .sentimentAnalysis(entity.getSentimentAnalysis()) + .confidenceScore(entity.getConfidenceScore()) + .generatedAt(entity.getGeneratedAt()) + .createdAt(entity.getCreatedAt()) + .updatedAt(entity.getUpdatedAt()) + .build(); + } + + /** + * AiFeedback Domain을 Entity로 변환 + */ + private AiFeedbackEntity toAiFeedbackEntity(AiFeedback domain) { + return AiFeedbackEntity.builder() + .id(domain.getId()) + .storeId(domain.getStoreId()) + .summary(domain.getSummary()) + .positivePointsJson(parseListToJson(domain.getPositivePoints())) + .negativePointsJson(parseListToJson(domain.getNegativePoints())) + .improvementPointsJson(parseListToJson(domain.getImprovementPoints())) + .recommendationsJson(parseListToJson(domain.getRecommendations())) + .sentimentAnalysis(domain.getSentimentAnalysis()) + .confidenceScore(domain.getConfidenceScore()) + .generatedAt(domain.getGeneratedAt()) + .build(); + } + + /** + * JSON 문자열을 List로 변환 + */ + private List parseJsonToList(String json) { + if (json == null || json.trim().isEmpty()) { + return List.of(); + } + + try { + return objectMapper.readValue(json, new TypeReference>() {}); + } catch (JsonProcessingException e) { + log.warn("JSON 파싱 실패: {}", json, e); + return List.of(); + } + } + + /** + * List를 JSON 문자열로 변환 + */ + private String parseListToJson(List list) { + if (list == null || list.isEmpty()) { + return "[]"; + } + + try { + return objectMapper.writeValueAsString(list); + } catch (JsonProcessingException e) { + log.warn("JSON 직렬화 실패: {}", list, e); + return "[]"; + } + } +} diff --git a/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/entity/AiFeedbackEntity.java b/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/entity/AiFeedbackEntity.java index dcf1977..1c14287 100644 --- a/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/entity/AiFeedbackEntity.java +++ b/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/entity/AiFeedbackEntity.java @@ -1,72 +1,72 @@ -package com.ktds.hi.analytics.infra.gateway.entity; - -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 jakarta.persistence.*; -import java.time.LocalDateTime; - -/** - * AI 피드백 엔티티 - * AI가 생성한 피드백 정보를 저장 - */ -@Entity -@Table(name = "ai_feedback", - indexes = { - @Index(name = "idx_ai_feedback_store_id", columnList = "store_id"), - @Index(name = "idx_ai_feedback_generated_at", columnList = "generated_at"), - @Index(name = "idx_ai_feedback_created_at", columnList = "created_at"), - @Index(name = "idx_ai_feedback_confidence_score", columnList = "confidence_score") - }) -@Getter -@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(name = "summary", length = 1000) - private String summary; - - @Column(name = "positive_points", columnDefinition = "TEXT") - private String positivePointsJson; - - @Column(name = "negative_points", columnDefinition = "TEXT") - private String negativePointsJson; - - @Column(name = "improvement_points", columnDefinition = "TEXT") - private String improvementPointsJson; - - @Column(name = "recommendations", columnDefinition = "TEXT") - private String recommendationsJson; - - @Column(name = "sentiment_analysis", length = 500) - private String sentimentAnalysis; - - @Column(name = "confidence_score") - private Double confidenceScore; - - @Column(name = "generated_at") - private LocalDateTime generatedAt; - - @CreatedDate - @Column(name = "created_at", nullable = false, updatable = false) - private LocalDateTime createdAt; - - @LastModifiedDate - @Column(name = "updated_at") - private LocalDateTime updatedAt; - -} +package com.ktds.hi.analytics.infra.gateway.entity; + +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 jakarta.persistence.*; +import java.time.LocalDateTime; + +/** + * AI 피드백 엔티티 + * AI가 생성한 피드백 정보를 저장 + */ +@Entity +@Table(name = "ai_feedback", + indexes = { + @Index(name = "idx_ai_feedback_store_id", columnList = "store_id"), + @Index(name = "idx_ai_feedback_generated_at", columnList = "generated_at"), + @Index(name = "idx_ai_feedback_created_at", columnList = "created_at"), + @Index(name = "idx_ai_feedback_confidence_score", columnList = "confidence_score") + }) +@Getter +@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(name = "summary", length = 1000) + private String summary; + + @Column(name = "positive_points", columnDefinition = "TEXT") + private String positivePointsJson; + + @Column(name = "negative_points", columnDefinition = "TEXT") + private String negativePointsJson; + + @Column(name = "improvement_points", columnDefinition = "TEXT") + private String improvementPointsJson; + + @Column(name = "recommendations", columnDefinition = "TEXT") + private String recommendationsJson; + + @Column(name = "sentiment_analysis", length = 500) + private String sentimentAnalysis; + + @Column(name = "confidence_score") + private Double confidenceScore; + + @Column(name = "generated_at") + private LocalDateTime generatedAt; + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at") + private LocalDateTime updatedAt; + +} diff --git a/review/src/main/java/com/ktds/hi/review/infra/config/EventHubConfig.java b/review/src/main/java/com/ktds/hi/review/infra/config/EventHubConfig.java index 8a7e072..8b260a8 100644 --- a/review/src/main/java/com/ktds/hi/review/infra/config/EventHubConfig.java +++ b/review/src/main/java/com/ktds/hi/review/infra/config/EventHubConfig.java @@ -1,33 +1,33 @@ -package com.ktds.hi.review.infra.config; - -import com.azure.messaging.eventhubs.EventHubClientBuilder; -import com.azure.messaging.eventhubs.EventHubConsumerClient; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -/** - * Azure Event Hub 설정 클래스 (단일 EntityPath 포함된 connection string 사용) - */ -@Slf4j -@Configuration -public class EventHubConfig { - - @Value("${azure.eventhub.connection-string}") - private String connectionString; - - @Value("${azure.eventhub.consumer-group:$Default}") - private String consumerGroup; - - /** - * 외부 리뷰 이벤트 수신용 Consumer - */ - @Bean("externalReviewEventConsumer") - public EventHubConsumerClient externalReviewEventConsumer() { - return new EventHubClientBuilder() - .connectionString(connectionString) - .consumerGroup(consumerGroup) - .buildConsumerClient(); - } -} +package com.ktds.hi.review.infra.config; + +import com.azure.messaging.eventhubs.EventHubClientBuilder; +import com.azure.messaging.eventhubs.EventHubConsumerClient; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Azure Event Hub 설정 클래스 (단일 EntityPath 포함된 connection string 사용) + */ +@Slf4j +@Configuration +public class EventHubConfig { + + @Value("${azure.eventhub.connection-string}") + private String connectionString; + + @Value("${azure.eventhub.consumer-group:$Default}") + private String consumerGroup; + + /** + * 외부 리뷰 이벤트 수신용 Consumer + */ + @Bean("externalReviewEventConsumer") + public EventHubConsumerClient externalReviewEventConsumer() { + return new EventHubClientBuilder() + .connectionString(connectionString) + .consumerGroup(consumerGroup) + .buildConsumerClient(); + } +} diff --git a/review/src/main/java/com/ktds/hi/review/infra/gateway/ExternalReviewEventHubAdapter.java b/review/src/main/java/com/ktds/hi/review/infra/gateway/ExternalReviewEventHubAdapter.java index 4b45bc7..952769d 100644 --- a/review/src/main/java/com/ktds/hi/review/infra/gateway/ExternalReviewEventHubAdapter.java +++ b/review/src/main/java/com/ktds/hi/review/infra/gateway/ExternalReviewEventHubAdapter.java @@ -1,240 +1,240 @@ -package com.ktds.hi.review.infra.gateway; - -import com.azure.messaging.eventhubs.EventData; -import com.azure.messaging.eventhubs.EventHubConsumerClient; -import com.azure.messaging.eventhubs.models.EventPosition; -import com.azure.messaging.eventhubs.models.PartitionEvent; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.ktds.hi.review.biz.domain.Review; -import com.ktds.hi.review.biz.domain.ReviewStatus; -import com.ktds.hi.review.biz.usecase.out.ReviewRepository; -import jakarta.annotation.PostConstruct; -import jakarta.annotation.PreDestroy; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.stereotype.Component; - -import java.time.Duration; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; - -/** - * Azure Event Hub 어댑터 클래스 (단순화) - * 외부 리뷰 이벤트 수신 및 Review 테이블 저장 - */ -@Slf4j -@Component -@RequiredArgsConstructor -public class ExternalReviewEventHubAdapter { - - @Qualifier("externalReviewEventConsumer") - private final EventHubConsumerClient externalReviewEventConsumer; - - private final ObjectMapper objectMapper; - private final ReviewRepository reviewRepository; - - private final ExecutorService executorService = Executors.newFixedThreadPool(3); - private volatile boolean isRunning = false; - - @PostConstruct - public void startEventListening() { - log.info("외부 리뷰 Event Hub 리스너 시작"); - isRunning = true; - - // 외부 리뷰 이벤트 수신 시작 - executorService.submit(this::listenToExternalReviewEvents); - } - - @PreDestroy - public void stopEventListening() { - log.info("외부 리뷰 Event Hub 리스너 종료"); - isRunning = false; - executorService.shutdown(); - externalReviewEventConsumer.close(); - } - - /** - * 외부 리뷰 이벤트 수신 처리 - */ - private void listenToExternalReviewEvents() { - log.info("외부 리뷰 이벤트 수신 시작"); - - try { - while (isRunning) { - Iterable events = externalReviewEventConsumer.receiveFromPartition( - "4", // 파티션 ID (0으로 수정) - 100, // 최대 이벤트 수 - EventPosition.earliest(), // 시작 위치 - Duration.ofSeconds(30) // 타임아웃 - ); - - for (PartitionEvent partitionEvent : events) { - handleExternalReviewEvent(partitionEvent); - } - - Thread.sleep(1000); - } - } catch (InterruptedException e) { - log.info("외부 리뷰 이벤트 수신 중단됨"); - Thread.currentThread().interrupt(); - } catch (Exception e) { - log.error("외부 리뷰 이벤트 수신 중 오류 발생", e); - } - } - - /** - * 외부 리뷰 이벤트 처리 - */ - private void handleExternalReviewEvent(PartitionEvent partitionEvent) { - try { - EventData eventData = partitionEvent.getData(); - String eventBody = eventData.getBodyAsString(); - - Map event = objectMapper.readValue(eventBody, Map.class); - String eventType = (String) event.get("eventType"); - Long storeId = Long.valueOf(event.get("storeId").toString()); - - log.info("외부 리뷰 이벤트 수신: type={}, storeId={}", eventType, storeId); - - if ("EXTERNAL_REVIEW_SYNC".equals(eventType)) { - handleExternalReviewSyncEvent(storeId, event); - } else { - log.warn("알 수 없는 외부 리뷰 이벤트 타입: {}", eventType); - } - - } catch (Exception e) { - log.error("외부 리뷰 이벤트 처리 중 오류 발생", e); - } - } - - /** - * 외부 리뷰 동기화 이벤트 처리 - 여러 리뷰를 배치로 처리 - */ - private void handleExternalReviewSyncEvent(Long storeId, Map event) { - try { - String platform = (String) event.get("platform"); - Integer syncedCount = (Integer) event.get("syncedCount"); - - // Store에서 발행하는 reviews 배열 처리 - @SuppressWarnings("unchecked") - List> reviews = (List>) event.get("reviews"); - - if (reviews == null || reviews.isEmpty()) { - log.warn("리뷰 데이터가 없습니다: platform={}, storeId={}", platform, storeId); - return; - } - - log.info("외부 리뷰 동기화 처리 시작: platform={}, storeId={}, count={}", - platform, storeId, reviews.size()); - - int savedCount = 0; - for (Map reviewData : reviews) { - try { - Review savedReview = saveExternalReview(storeId, platform, reviewData); - if (savedReview != null) { - savedCount++; - } - } catch (Exception e) { - log.error("개별 리뷰 저장 실패: platform={}, storeId={}, error={}", - platform, storeId, e.getMessage()); - } - } - - log.info("외부 리뷰 동기화 완료: platform={}, storeId={}, expected={}, saved={}", - platform, storeId, reviews.size(), savedCount); - - } catch (Exception e) { - log.error("외부 리뷰 동기화 이벤트 처리 실패: storeId={}, error={}", storeId, e.getMessage(), e); - } - } - - /** - * 개별 외부 리뷰 저장 (단순화) - */ - private Review saveExternalReview(Long storeId, String platform, Map reviewData) { - try { - // ✅ 단순화된 매핑 - Review review = Review.builder() - .storeId(storeId) - .memberId(null) // 외부 리뷰는 회원 ID 없음 - .memberNickname(createMemberNickname(platform, reviewData)) - .rating(extractRating(reviewData)) - .content(extractContent(reviewData)) - .imageUrls(new ArrayList<>()) // 외부 리뷰는 이미지 없음 - .status(ReviewStatus.ACTIVE) - .likeCount(0) // ✅ 고정값 0 - .dislikeCount(0) - .build(); - - // Review 테이블에 저장 - Review savedReview = reviewRepository.saveReview(review); - - log.debug("외부 리뷰 저장 완료: reviewId={}, platform={}, storeId={}, author={}", - savedReview.getId(), platform, storeId, savedReview.getMemberNickname()); - - return savedReview; - - } catch (Exception e) { - log.error("외부 리뷰 저장 실패: platform={}, storeId={}, error={}", - platform, storeId, e.getMessage(), e); - return null; - } - } - - /** - * 플랫폼별 회원 닉네임 생성 (카카오 API 필드명 수정) - */ - private String createMemberNickname(String platform, Map reviewData) { - String authorName = null; - - // ✅ 카카오 API 구조에 맞춰 수정 - if ("KAKAO".equalsIgnoreCase(platform)) { - authorName = (String) reviewData.get("reviewer_name"); - } else { - // 다른 플랫폼 대비 - authorName = (String) reviewData.get("author_name"); - if (authorName == null) { - authorName = (String) reviewData.get("authorName"); - } - } - - if (authorName == null || authorName.trim().isEmpty()) { - return platform.toUpperCase() + " 사용자"; - } - - return authorName + "(" + platform.toUpperCase() + ")"; - } - - /** - * 평점 추출 (기본값: 5) - */ - private Integer extractRating(Map reviewData) { - Object rating = reviewData.get("rating"); - if (rating instanceof Number) { - int ratingValue = ((Number) rating).intValue(); - return (ratingValue >= 1 && ratingValue <= 5) ? ratingValue : 5; - } - return 5; - } - - /** - * 리뷰 내용 추출 - */ - private String extractContent(Map reviewData) { - String content = (String) reviewData.get("content"); - if (content == null || content.trim().isEmpty()) { - return "외부 플랫폼 리뷰"; - } - - // 내용이 너무 길면 자르기 (reviews 테이블 length 제한 대비) - if (content.length() > 1900) { - content = content.substring(0, 1900) + "..."; - } - - return content; - } +package com.ktds.hi.review.infra.gateway; + +import com.azure.messaging.eventhubs.EventData; +import com.azure.messaging.eventhubs.EventHubConsumerClient; +import com.azure.messaging.eventhubs.models.EventPosition; +import com.azure.messaging.eventhubs.models.PartitionEvent; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ktds.hi.review.biz.domain.Review; +import com.ktds.hi.review.biz.domain.ReviewStatus; +import com.ktds.hi.review.biz.usecase.out.ReviewRepository; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** + * Azure Event Hub 어댑터 클래스 (단순화) + * 외부 리뷰 이벤트 수신 및 Review 테이블 저장 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class ExternalReviewEventHubAdapter { + + @Qualifier("externalReviewEventConsumer") + private final EventHubConsumerClient externalReviewEventConsumer; + + private final ObjectMapper objectMapper; + private final ReviewRepository reviewRepository; + + private final ExecutorService executorService = Executors.newFixedThreadPool(3); + private volatile boolean isRunning = false; + + @PostConstruct + public void startEventListening() { + log.info("외부 리뷰 Event Hub 리스너 시작"); + isRunning = true; + + // 외부 리뷰 이벤트 수신 시작 + executorService.submit(this::listenToExternalReviewEvents); + } + + @PreDestroy + public void stopEventListening() { + log.info("외부 리뷰 Event Hub 리스너 종료"); + isRunning = false; + executorService.shutdown(); + externalReviewEventConsumer.close(); + } + + /** + * 외부 리뷰 이벤트 수신 처리 + */ + private void listenToExternalReviewEvents() { + log.info("외부 리뷰 이벤트 수신 시작"); + + try { + while (isRunning) { + Iterable events = externalReviewEventConsumer.receiveFromPartition( + "4", // 파티션 ID (0으로 수정) + 100, // 최대 이벤트 수 + EventPosition.earliest(), // 시작 위치 + Duration.ofSeconds(30) // 타임아웃 + ); + + for (PartitionEvent partitionEvent : events) { + handleExternalReviewEvent(partitionEvent); + } + + Thread.sleep(1000); + } + } catch (InterruptedException e) { + log.info("외부 리뷰 이벤트 수신 중단됨"); + Thread.currentThread().interrupt(); + } catch (Exception e) { + log.error("외부 리뷰 이벤트 수신 중 오류 발생", e); + } + } + + /** + * 외부 리뷰 이벤트 처리 + */ + private void handleExternalReviewEvent(PartitionEvent partitionEvent) { + try { + EventData eventData = partitionEvent.getData(); + String eventBody = eventData.getBodyAsString(); + + Map event = objectMapper.readValue(eventBody, Map.class); + String eventType = (String) event.get("eventType"); + Long storeId = Long.valueOf(event.get("storeId").toString()); + + log.info("외부 리뷰 이벤트 수신: type={}, storeId={}", eventType, storeId); + + if ("EXTERNAL_REVIEW_SYNC".equals(eventType)) { + handleExternalReviewSyncEvent(storeId, event); + } else { + log.warn("알 수 없는 외부 리뷰 이벤트 타입: {}", eventType); + } + + } catch (Exception e) { + log.error("외부 리뷰 이벤트 처리 중 오류 발생", e); + } + } + + /** + * 외부 리뷰 동기화 이벤트 처리 - 여러 리뷰를 배치로 처리 + */ + private void handleExternalReviewSyncEvent(Long storeId, Map event) { + try { + String platform = (String) event.get("platform"); + Integer syncedCount = (Integer) event.get("syncedCount"); + + // Store에서 발행하는 reviews 배열 처리 + @SuppressWarnings("unchecked") + List> reviews = (List>) event.get("reviews"); + + if (reviews == null || reviews.isEmpty()) { + log.warn("리뷰 데이터가 없습니다: platform={}, storeId={}", platform, storeId); + return; + } + + log.info("외부 리뷰 동기화 처리 시작: platform={}, storeId={}, count={}", + platform, storeId, reviews.size()); + + int savedCount = 0; + for (Map reviewData : reviews) { + try { + Review savedReview = saveExternalReview(storeId, platform, reviewData); + if (savedReview != null) { + savedCount++; + } + } catch (Exception e) { + log.error("개별 리뷰 저장 실패: platform={}, storeId={}, error={}", + platform, storeId, e.getMessage()); + } + } + + log.info("외부 리뷰 동기화 완료: platform={}, storeId={}, expected={}, saved={}", + platform, storeId, reviews.size(), savedCount); + + } catch (Exception e) { + log.error("외부 리뷰 동기화 이벤트 처리 실패: storeId={}, error={}", storeId, e.getMessage(), e); + } + } + + /** + * 개별 외부 리뷰 저장 (단순화) + */ + private Review saveExternalReview(Long storeId, String platform, Map reviewData) { + try { + // ✅ 단순화된 매핑 + Review review = Review.builder() + .storeId(storeId) + .memberId(null) // 외부 리뷰는 회원 ID 없음 + .memberNickname(createMemberNickname(platform, reviewData)) + .rating(extractRating(reviewData)) + .content(extractContent(reviewData)) + .imageUrls(new ArrayList<>()) // 외부 리뷰는 이미지 없음 + .status(ReviewStatus.ACTIVE) + .likeCount(0) // ✅ 고정값 0 + .dislikeCount(0) + .build(); + + // Review 테이블에 저장 + Review savedReview = reviewRepository.saveReview(review); + + log.debug("외부 리뷰 저장 완료: reviewId={}, platform={}, storeId={}, author={}", + savedReview.getId(), platform, storeId, savedReview.getMemberNickname()); + + return savedReview; + + } catch (Exception e) { + log.error("외부 리뷰 저장 실패: platform={}, storeId={}, error={}", + platform, storeId, e.getMessage(), e); + return null; + } + } + + /** + * 플랫폼별 회원 닉네임 생성 (카카오 API 필드명 수정) + */ + private String createMemberNickname(String platform, Map reviewData) { + String authorName = null; + + // ✅ 카카오 API 구조에 맞춰 수정 + if ("KAKAO".equalsIgnoreCase(platform)) { + authorName = (String) reviewData.get("reviewer_name"); + } else { + // 다른 플랫폼 대비 + authorName = (String) reviewData.get("author_name"); + if (authorName == null) { + authorName = (String) reviewData.get("authorName"); + } + } + + if (authorName == null || authorName.trim().isEmpty()) { + return platform.toUpperCase() + " 사용자"; + } + + return authorName + "(" + platform.toUpperCase() + ")"; + } + + /** + * 평점 추출 (기본값: 5) + */ + private Integer extractRating(Map reviewData) { + Object rating = reviewData.get("rating"); + if (rating instanceof Number) { + int ratingValue = ((Number) rating).intValue(); + return (ratingValue >= 1 && ratingValue <= 5) ? ratingValue : 5; + } + return 5; + } + + /** + * 리뷰 내용 추출 + */ + private String extractContent(Map reviewData) { + String content = (String) reviewData.get("content"); + if (content == null || content.trim().isEmpty()) { + return "외부 플랫폼 리뷰"; + } + + // 내용이 너무 길면 자르기 (reviews 테이블 length 제한 대비) + if (content.length() > 1900) { + content = content.substring(0, 1900) + "..."; + } + + return content; + } } \ No newline at end of file diff --git a/review/src/main/java/com/ktds/hi/review/infra/gateway/entity/ReviewEntity.java b/review/src/main/java/com/ktds/hi/review/infra/gateway/entity/ReviewEntity.java index 7c6aba6..ec39899 100644 --- a/review/src/main/java/com/ktds/hi/review/infra/gateway/entity/ReviewEntity.java +++ b/review/src/main/java/com/ktds/hi/review/infra/gateway/entity/ReviewEntity.java @@ -1,74 +1,74 @@ -package com.ktds.hi.review.infra.gateway.entity; - -import com.ktds.hi.review.biz.domain.ReviewStatus; -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; - -/** - * 리뷰 엔티티 클래스 - * 데이터베이스 reviews 테이블과 매핑되는 JPA 엔티티 - */ -@Entity -@Table(name = "reviews") -@Getter -@Builder -@NoArgsConstructor -@AllArgsConstructor -@EntityListeners(AuditingEntityListener.class) -public class ReviewEntity { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(name = "store_id", nullable = false) - private Long storeId; - - @Column(name = "member_id", nullable = true) - private Long memberId; - - @Column(name = "member_nickname", nullable = false, length = 50) - private String memberNickname; - - @Column(nullable = false) - private Integer rating; - - @Column(nullable = false, length = 1000) - private String content; - - @ElementCollection - @CollectionTable(name = "review_images", - joinColumns = @JoinColumn(name = "review_id")) - @Column(name = "image_url") - private List imageUrls; - - @Enumerated(EnumType.STRING) - @Column(nullable = false) - @Builder.Default - private ReviewStatus status = ReviewStatus.ACTIVE; - - @Column(name = "like_count") - @Builder.Default - private Integer likeCount = 0; - - @Column(name = "dislike_count") - @Builder.Default - private Integer dislikeCount = 0; - - @CreatedDate - @Column(name = "created_at", updatable = false) - private LocalDateTime createdAt; - - @LastModifiedDate - @Column(name = "updated_at") - private LocalDateTime updatedAt; -} +package com.ktds.hi.review.infra.gateway.entity; + +import com.ktds.hi.review.biz.domain.ReviewStatus; +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; + +/** + * 리뷰 엔티티 클래스 + * 데이터베이스 reviews 테이블과 매핑되는 JPA 엔티티 + */ +@Entity +@Table(name = "reviews") +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@EntityListeners(AuditingEntityListener.class) +public class ReviewEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "store_id", nullable = false) + private Long storeId; + + @Column(name = "member_id", nullable = true) + private Long memberId; + + @Column(name = "member_nickname", nullable = false, length = 50) + private String memberNickname; + + @Column(nullable = false) + private Integer rating; + + @Column(nullable = false, length = 1000) + private String content; + + @ElementCollection + @CollectionTable(name = "review_images", + joinColumns = @JoinColumn(name = "review_id")) + @Column(name = "image_url") + private List imageUrls; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + @Builder.Default + private ReviewStatus status = ReviewStatus.ACTIVE; + + @Column(name = "like_count") + @Builder.Default + private Integer likeCount = 0; + + @Column(name = "dislike_count") + @Builder.Default + private Integer dislikeCount = 0; + + @CreatedDate + @Column(name = "created_at", updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at") + private LocalDateTime updatedAt; +} diff --git a/review/src/main/resources/application.yml b/review/src/main/resources/application.yml index 85c42fc..38c3a6d 100644 --- a/review/src/main/resources/application.yml +++ b/review/src/main/resources/application.yml @@ -1,48 +1,48 @@ -server: - port: ${REVIEW_SERVICE_PORT:8083} - -spring: - application: - name: review-service - - datasource: - url: ${REVIEW_DB_URL:jdbc:postgresql://20.214.91.15:5432/hiorder_review} - username: ${REVIEW_DB_USERNAME:hiorder_user} - password: ${REVIEW_DB_PASSWORD:hiorder_pass} - driver-class-name: org.postgresql.Driver - - jpa: - hibernate: - ddl-auto: ${JPA_DDL_AUTO:update} - show-sql: ${JPA_SHOW_SQL:false} - properties: - hibernate: - format_sql: true - dialect: org.hibernate.dialect.PostgreSQLDialect - data: - redis: - host: ${REDIS_HOST:localhost} - port: ${REDIS_PORT:6379} - password: ${REDIS_PASSWORD:} - - servlet: - multipart: - max-file-size: ${MAX_FILE_SIZE:10MB} - max-request-size: ${MAX_REQUEST_SIZE:50MB} - - azure: - eventhub: - connection-string: ${AZURE_EVENTHUB_CONNECTION_STRING} - consumer-group: $Default - -file-storage: - base-path: ${FILE_STORAGE_PATH:/var/hiorder/uploads} - allowed-extensions: jpg,jpeg,png,gif,webp - max-file-size: 10485760 # 10MB - -springdoc: - api-docs: - path: /docs/review/api-docs - swagger-ui: - enabled: true - path: /docs/review/swagger-ui.html +server: + port: ${REVIEW_SERVICE_PORT:8083} + +spring: + application: + name: review-service + + datasource: + url: ${REVIEW_DB_URL:jdbc:postgresql://20.214.91.15:5432/hiorder_review} + username: ${REVIEW_DB_USERNAME:hiorder_user} + password: ${REVIEW_DB_PASSWORD:hiorder_pass} + driver-class-name: org.postgresql.Driver + + jpa: + hibernate: + ddl-auto: ${JPA_DDL_AUTO:update} + show-sql: ${JPA_SHOW_SQL:false} + properties: + hibernate: + format_sql: true + dialect: org.hibernate.dialect.PostgreSQLDialect + data: + redis: + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6379} + password: ${REDIS_PASSWORD:} + + servlet: + multipart: + max-file-size: ${MAX_FILE_SIZE:10MB} + max-request-size: ${MAX_REQUEST_SIZE:50MB} + + azure: + eventhub: + connection-string: ${AZURE_EVENTHUB_CONNECTION_STRING} + consumer-group: $Default + +file-storage: + base-path: ${FILE_STORAGE_PATH:/var/hiorder/uploads} + allowed-extensions: jpg,jpeg,png,gif,webp + max-file-size: 10485760 # 10MB + +springdoc: + api-docs: + path: /docs/review/api-docs + swagger-ui: + enabled: true + path: /docs/review/swagger-ui.html diff --git a/store/src/main/java/com/ktds/hi/store/biz/service/StoreService.java b/store/src/main/java/com/ktds/hi/store/biz/service/StoreService.java index 2f389f6..c8f0e7c 100644 --- a/store/src/main/java/com/ktds/hi/store/biz/service/StoreService.java +++ b/store/src/main/java/com/ktds/hi/store/biz/service/StoreService.java @@ -130,7 +130,7 @@ public class StoreService implements StoreUseCase { .orElseThrow(() -> new BusinessException("STORE_ACCESS_DENIED", "매장에 대한 권한이 없습니다.")); store.updateInfo(request.getStoreName(), request.getAddress(), request.getDescription(), - request.getPhone(), request.getOperatingHours()); + request.getPhone(), request.getOperatingHours(), request.getImageUrl()); storeJpaRepository.save(store); diff --git a/store/src/main/java/com/ktds/hi/store/infra/dto/StoreUpdateRequest.java b/store/src/main/java/com/ktds/hi/store/infra/dto/StoreUpdateRequest.java index 4157e13..5e853f9 100644 --- a/store/src/main/java/com/ktds/hi/store/infra/dto/StoreUpdateRequest.java +++ b/store/src/main/java/com/ktds/hi/store/infra/dto/StoreUpdateRequest.java @@ -38,4 +38,7 @@ public class StoreUpdateRequest { @Schema(description = "매장 태그 목록", example = "[\"맛집\", \"혼밥\", \"가성비\"]") private List tags; + + @Schema(description = "매장 이미지") + private String imageUrl; } diff --git a/store/src/main/java/com/ktds/hi/store/infra/gateway/entity/StoreEntity.java b/store/src/main/java/com/ktds/hi/store/infra/gateway/entity/StoreEntity.java index d8451e9..363dd68 100644 --- a/store/src/main/java/com/ktds/hi/store/infra/gateway/entity/StoreEntity.java +++ b/store/src/main/java/com/ktds/hi/store/infra/gateway/entity/StoreEntity.java @@ -132,13 +132,14 @@ public class StoreEntity { * 매장 기본 정보 업데이트 */ public void updateInfo(String storeName, String address, String description, - String phone, String operatingHours) { + String phone, String operatingHours, String imageUrl) { this.storeName = storeName; this.address = address; this.description = description; this.phone = phone; this.operatingHours = operatingHours; this.updatedAt = LocalDateTime.now(); + this.imageUrl = imageUrl; } /** diff --git a/store/src/main/resources/application.yml b/store/src/main/resources/application.yml index 5293339..0eee4ec 100644 --- a/store/src/main/resources/application.yml +++ b/store/src/main/resources/application.yml @@ -1,60 +1,60 @@ -server: - port: ${STORE_SERVICE_PORT:8082} - -spring: - application: - name: store-service - - datasource: - url: ${STORE_DB_URL:jdbc:postgresql://20.249.154.116:5432/hiorder_store} - username: ${STORE_DB_USERNAME:hiorder_user} - password: ${STORE_DB_PASSWORD:hiorder_pass} - driver-class-name: org.postgresql.Driver - - jpa: - hibernate: - ddl-auto: ${JPA_DDL_AUTO:update} - show-sql: ${JPA_SHOW_SQL:false} - properties: - hibernate: - format_sql: true - dialect: org.hibernate.dialect.PostgreSQLDialect - # Azure Event Hub 설정 (추가) - azure: - eventhub: - connection-string: ${AZURE_EVENTHUB_CONNECTION_STRING} - - - 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 - -external-api: - naver: - client-id: ${NAVER_CLIENT_ID:} - client-secret: ${NAVER_CLIENT_SECRET:} - base-url: https://openapi.naver.com - kakao: - api-key: ${KAKAO_API_KEY:} - base-url: http://kakao-review-api-service.ai-review-ns.svc.cluster.local - google: - api-key: ${GOOGLE_API_KEY:} - base-url: https://maps.googleapis.com - hiorder: - api-key: ${HIORDER_API_KEY:} - base-url: ${HIORDER_BASE_URL:https://api.hiorder.com} - -springdoc: - api-docs: - path: /docs/store/api-docs - swagger-ui: - path: /docs/store/swagger-ui.html +server: + port: ${STORE_SERVICE_PORT:8082} + +spring: + application: + name: store-service + + datasource: + url: ${STORE_DB_URL:jdbc:postgresql://20.249.154.116:5432/hiorder_store} + username: ${STORE_DB_USERNAME:hiorder_user} + password: ${STORE_DB_PASSWORD:hiorder_pass} + driver-class-name: org.postgresql.Driver + + jpa: + hibernate: + ddl-auto: ${JPA_DDL_AUTO:update} + show-sql: ${JPA_SHOW_SQL:false} + properties: + hibernate: + format_sql: true + dialect: org.hibernate.dialect.PostgreSQLDialect + # Azure Event Hub 설정 (추가) + azure: + eventhub: + connection-string: ${AZURE_EVENTHUB_CONNECTION_STRING} + + + 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 + +external-api: + naver: + client-id: ${NAVER_CLIENT_ID:} + client-secret: ${NAVER_CLIENT_SECRET:} + base-url: https://openapi.naver.com + kakao: + api-key: ${KAKAO_API_KEY:} + base-url: http://kakao-review-api-service.ai-review-ns.svc.cluster.local + google: + api-key: ${GOOGLE_API_KEY:} + base-url: https://maps.googleapis.com + hiorder: + api-key: ${HIORDER_API_KEY:} + base-url: ${HIORDER_BASE_URL:https://api.hiorder.com} + +springdoc: + api-docs: + path: /docs/store/api-docs + swagger-ui: + path: /docs/store/swagger-ui.html