mirror of
https://github.com/ktds-dg0501/kt-event-marketing.git
synced 2025-12-06 13:26:23 +00:00
Merge pull request #18 from ktds-dg0501/feature/analytics
Feature/analytics
This commit is contained in:
commit
d511140ecb
@ -12,7 +12,7 @@
|
|||||||
<entry key="DB_PASSWORD" value="Hi5Jessica!" />
|
<entry key="DB_PASSWORD" value="Hi5Jessica!" />
|
||||||
|
|
||||||
<!-- JPA Configuration -->
|
<!-- JPA Configuration -->
|
||||||
<entry key="DDL_AUTO" value="update" />
|
<entry key="DDL_AUTO" value="create" />
|
||||||
<entry key="SHOW_SQL" value="true" />
|
<entry key="SHOW_SQL" value="true" />
|
||||||
|
|
||||||
<!-- Redis Configuration -->
|
<!-- Redis Configuration -->
|
||||||
|
|||||||
108
analytics-service/frontend-backend-validation.md
Normal file
108
analytics-service/frontend-backend-validation.md
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
# 백엔드-프론트엔드 API 연동 검증 및 수정 결과
|
||||||
|
|
||||||
|
**작업일시**: 2025-10-28
|
||||||
|
**브랜치**: feature/analytics
|
||||||
|
**작업 범위**: Analytics Service 백엔드 DTO 및 Service 수정
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 수정 요약
|
||||||
|
|
||||||
|
### 1️⃣ 필드명 통일 (프론트엔드 호환)
|
||||||
|
|
||||||
|
**목적**: 프론트엔드 Mock 데이터 필드명과 백엔드 Response DTO 필드명 일치
|
||||||
|
|
||||||
|
| 수정 전 (백엔드) | 수정 후 (백엔드) | 프론트엔드 |
|
||||||
|
|-----------------|----------------|-----------|
|
||||||
|
| `summary.totalParticipants` | `summary.participants` | `summary.participants` ✅ |
|
||||||
|
| `channelPerformance[].channelName` | `channelPerformance[].channel` | `channelPerformance[].channel` ✅ |
|
||||||
|
| `roi.totalInvestment` | `roi.totalCost` | `roiDetail.totalCost` ✅ |
|
||||||
|
|
||||||
|
### 2️⃣ 증감 데이터 추가
|
||||||
|
|
||||||
|
**목적**: 프론트엔드에서 요구하는 증감 표시 및 목표값 제공
|
||||||
|
|
||||||
|
| 필드 | 타입 | 설명 | 현재 값 |
|
||||||
|
|-----|------|------|---------|
|
||||||
|
| `summary.participantsDelta` | `Integer` | 참여자 증감 (이전 기간 대비) | `0` (TODO: 계산 로직 필요) |
|
||||||
|
| `summary.targetRoi` | `Double` | 목표 ROI (%) | EventStats에서 가져옴 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 수정 파일 목록
|
||||||
|
|
||||||
|
### DTO (Response 구조 변경)
|
||||||
|
|
||||||
|
1. **AnalyticsSummary.java**
|
||||||
|
- ✅ `totalParticipants` → `participants`
|
||||||
|
- ✅ `participantsDelta` 필드 추가
|
||||||
|
- ✅ `targetRoi` 필드 추가
|
||||||
|
|
||||||
|
2. **ChannelSummary.java**
|
||||||
|
- ✅ `channelName` → `channel`
|
||||||
|
|
||||||
|
3. **RoiSummary.java**
|
||||||
|
- ✅ `totalInvestment` → `totalCost`
|
||||||
|
|
||||||
|
### Entity (데이터베이스 스키마 변경)
|
||||||
|
|
||||||
|
4. **EventStats.java**
|
||||||
|
- ✅ `targetRoi` 필드 추가 (`BigDecimal`, default: 0)
|
||||||
|
|
||||||
|
### Service (비즈니스 로직 수정)
|
||||||
|
|
||||||
|
5. **AnalyticsService.java**
|
||||||
|
- ✅ `.participants()` 사용
|
||||||
|
- ✅ `.participantsDelta(0)` 추가 (TODO 마킹)
|
||||||
|
- ✅ `.targetRoi()` 추가
|
||||||
|
- ✅ `.channel()` 사용
|
||||||
|
|
||||||
|
6. **ROICalculator.java**
|
||||||
|
- ✅ `.totalCost()` 사용
|
||||||
|
|
||||||
|
7. **UserAnalyticsService.java**
|
||||||
|
- ✅ `.participants()` 사용
|
||||||
|
- ✅ `.participantsDelta(0)` 추가
|
||||||
|
- ✅ `.channel()` 사용
|
||||||
|
- ✅ `.totalCost()` 사용
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 검증 결과
|
||||||
|
|
||||||
|
### 컴파일 성공
|
||||||
|
\`\`\`bash
|
||||||
|
$ ./gradlew analytics-service:compileJava
|
||||||
|
|
||||||
|
BUILD SUCCESSFUL in 8s
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 데이터베이스 스키마 변경
|
||||||
|
|
||||||
|
### EventStats 테이블
|
||||||
|
|
||||||
|
\`\`\`sql
|
||||||
|
ALTER TABLE event_stats
|
||||||
|
ADD COLUMN target_roi DECIMAL(10,2) DEFAULT 0.00;
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
**⚠️ 주의사항**
|
||||||
|
- Spring Boot JPA `ddl-auto` 설정에 따라 자동 적용됨
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📌 다음 단계
|
||||||
|
|
||||||
|
### 우선순위 HIGH
|
||||||
|
|
||||||
|
1. **프론트엔드 API 연동 테스트**
|
||||||
|
2. **participantsDelta 계산 로직 구현**
|
||||||
|
3. **targetRoi 데이터 입력** (Event Service 연동)
|
||||||
|
|
||||||
|
### 우선순위 MEDIUM
|
||||||
|
|
||||||
|
4. 시간대별 분석 구현
|
||||||
|
5. 참여자 프로필 구현
|
||||||
|
6. ROI 세분화 구현
|
||||||
@ -0,0 +1,71 @@
|
|||||||
|
package com.kt.event.analytics.controller;
|
||||||
|
|
||||||
|
import com.kt.event.analytics.dto.response.UserAnalyticsDashboardResponse;
|
||||||
|
import com.kt.event.analytics.service.UserAnalyticsService;
|
||||||
|
import com.kt.event.common.dto.ApiResponse;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.format.annotation.DateTimeFormat;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User Analytics Dashboard Controller
|
||||||
|
*
|
||||||
|
* 사용자 전체 이벤트 통합 성과 대시보드 API
|
||||||
|
*/
|
||||||
|
@Tag(name = "User Analytics", description = "사용자 전체 이벤트 통합 성과 분석 API")
|
||||||
|
@Slf4j
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/users")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class UserAnalyticsDashboardController {
|
||||||
|
|
||||||
|
private final UserAnalyticsService userAnalyticsService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자 전체 성과 대시보드 조회
|
||||||
|
*
|
||||||
|
* @param userId 사용자 ID
|
||||||
|
* @param startDate 조회 시작 날짜
|
||||||
|
* @param endDate 조회 종료 날짜
|
||||||
|
* @param refresh 캐시 갱신 여부
|
||||||
|
* @return 전체 통합 성과 대시보드
|
||||||
|
*/
|
||||||
|
@Operation(
|
||||||
|
summary = "사용자 전체 성과 대시보드 조회",
|
||||||
|
description = "사용자의 모든 이벤트 성과를 통합하여 조회합니다."
|
||||||
|
)
|
||||||
|
@GetMapping("/{userId}/analytics")
|
||||||
|
public ResponseEntity<ApiResponse<UserAnalyticsDashboardResponse>> getUserAnalytics(
|
||||||
|
@Parameter(description = "사용자 ID", required = true)
|
||||||
|
@PathVariable String userId,
|
||||||
|
|
||||||
|
@Parameter(description = "조회 시작 날짜 (ISO 8601 format)")
|
||||||
|
@RequestParam(required = false)
|
||||||
|
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
|
||||||
|
LocalDateTime startDate,
|
||||||
|
|
||||||
|
@Parameter(description = "조회 종료 날짜 (ISO 8601 format)")
|
||||||
|
@RequestParam(required = false)
|
||||||
|
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
|
||||||
|
LocalDateTime endDate,
|
||||||
|
|
||||||
|
@Parameter(description = "캐시 갱신 여부")
|
||||||
|
@RequestParam(required = false, defaultValue = "false")
|
||||||
|
Boolean refresh
|
||||||
|
) {
|
||||||
|
log.info("사용자 전체 성과 대시보드 조회 API 호출: userId={}, refresh={}", userId, refresh);
|
||||||
|
|
||||||
|
UserAnalyticsDashboardResponse response = userAnalyticsService.getUserDashboardData(
|
||||||
|
userId, startDate, endDate, refresh
|
||||||
|
);
|
||||||
|
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(response));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,78 @@
|
|||||||
|
package com.kt.event.analytics.controller;
|
||||||
|
|
||||||
|
import com.kt.event.analytics.dto.response.UserChannelAnalyticsResponse;
|
||||||
|
import com.kt.event.analytics.service.UserChannelAnalyticsService;
|
||||||
|
import com.kt.event.common.dto.ApiResponse;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.format.annotation.DateTimeFormat;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User Channel Analytics Controller
|
||||||
|
*/
|
||||||
|
@Tag(name = "User Channels", description = "사용자 전체 이벤트 채널별 성과 분석 API")
|
||||||
|
@Slf4j
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/users")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class UserChannelAnalyticsController {
|
||||||
|
|
||||||
|
private final UserChannelAnalyticsService userChannelAnalyticsService;
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "사용자 전체 채널별 성과 분석",
|
||||||
|
description = "사용자의 모든 이벤트 채널 성과를 통합하여 분석합니다."
|
||||||
|
)
|
||||||
|
@GetMapping("/{userId}/analytics/channels")
|
||||||
|
public ResponseEntity<ApiResponse<UserChannelAnalyticsResponse>> getUserChannelAnalytics(
|
||||||
|
@Parameter(description = "사용자 ID", required = true)
|
||||||
|
@PathVariable String userId,
|
||||||
|
|
||||||
|
@Parameter(description = "조회할 채널 목록 (쉼표로 구분)")
|
||||||
|
@RequestParam(required = false)
|
||||||
|
String channels,
|
||||||
|
|
||||||
|
@Parameter(description = "정렬 기준")
|
||||||
|
@RequestParam(required = false, defaultValue = "participants")
|
||||||
|
String sortBy,
|
||||||
|
|
||||||
|
@Parameter(description = "정렬 순서")
|
||||||
|
@RequestParam(required = false, defaultValue = "desc")
|
||||||
|
String order,
|
||||||
|
|
||||||
|
@Parameter(description = "조회 시작 날짜")
|
||||||
|
@RequestParam(required = false)
|
||||||
|
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
|
||||||
|
LocalDateTime startDate,
|
||||||
|
|
||||||
|
@Parameter(description = "조회 종료 날짜")
|
||||||
|
@RequestParam(required = false)
|
||||||
|
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
|
||||||
|
LocalDateTime endDate,
|
||||||
|
|
||||||
|
@Parameter(description = "캐시 갱신 여부")
|
||||||
|
@RequestParam(required = false, defaultValue = "false")
|
||||||
|
Boolean refresh
|
||||||
|
) {
|
||||||
|
log.info("사용자 채널 분석 API 호출: userId={}, sortBy={}", userId, sortBy);
|
||||||
|
|
||||||
|
List<String> channelList = channels != null && !channels.isBlank()
|
||||||
|
? Arrays.asList(channels.split(","))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
UserChannelAnalyticsResponse response = userChannelAnalyticsService.getUserChannelAnalytics(
|
||||||
|
userId, channelList, sortBy, order, startDate, endDate, refresh
|
||||||
|
);
|
||||||
|
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(response));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,64 @@
|
|||||||
|
package com.kt.event.analytics.controller;
|
||||||
|
|
||||||
|
import com.kt.event.analytics.dto.response.UserRoiAnalyticsResponse;
|
||||||
|
import com.kt.event.analytics.service.UserRoiAnalyticsService;
|
||||||
|
import com.kt.event.common.dto.ApiResponse;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.format.annotation.DateTimeFormat;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User ROI Analytics Controller
|
||||||
|
*/
|
||||||
|
@Tag(name = "User ROI", description = "사용자 전체 이벤트 ROI 분석 API")
|
||||||
|
@Slf4j
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/users")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class UserRoiAnalyticsController {
|
||||||
|
|
||||||
|
private final UserRoiAnalyticsService userRoiAnalyticsService;
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "사용자 전체 ROI 상세 분석",
|
||||||
|
description = "사용자의 모든 이벤트 ROI를 통합하여 분석합니다."
|
||||||
|
)
|
||||||
|
@GetMapping("/{userId}/analytics/roi")
|
||||||
|
public ResponseEntity<ApiResponse<UserRoiAnalyticsResponse>> getUserRoiAnalytics(
|
||||||
|
@Parameter(description = "사용자 ID", required = true)
|
||||||
|
@PathVariable String userId,
|
||||||
|
|
||||||
|
@Parameter(description = "예상 수익 포함 여부")
|
||||||
|
@RequestParam(required = false, defaultValue = "true")
|
||||||
|
Boolean includeProjection,
|
||||||
|
|
||||||
|
@Parameter(description = "조회 시작 날짜")
|
||||||
|
@RequestParam(required = false)
|
||||||
|
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
|
||||||
|
LocalDateTime startDate,
|
||||||
|
|
||||||
|
@Parameter(description = "조회 종료 날짜")
|
||||||
|
@RequestParam(required = false)
|
||||||
|
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
|
||||||
|
LocalDateTime endDate,
|
||||||
|
|
||||||
|
@Parameter(description = "캐시 갱신 여부")
|
||||||
|
@RequestParam(required = false, defaultValue = "false")
|
||||||
|
Boolean refresh
|
||||||
|
) {
|
||||||
|
log.info("사용자 ROI 분석 API 호출: userId={}, includeProjection={}", userId, includeProjection);
|
||||||
|
|
||||||
|
UserRoiAnalyticsResponse response = userRoiAnalyticsService.getUserRoiAnalytics(
|
||||||
|
userId, includeProjection, startDate, endDate, refresh
|
||||||
|
);
|
||||||
|
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(response));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,74 @@
|
|||||||
|
package com.kt.event.analytics.controller;
|
||||||
|
|
||||||
|
import com.kt.event.analytics.dto.response.UserTimelineAnalyticsResponse;
|
||||||
|
import com.kt.event.analytics.service.UserTimelineAnalyticsService;
|
||||||
|
import com.kt.event.common.dto.ApiResponse;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.format.annotation.DateTimeFormat;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User Timeline Analytics Controller
|
||||||
|
*/
|
||||||
|
@Tag(name = "User Timeline", description = "사용자 전체 이벤트 시간대별 분석 API")
|
||||||
|
@Slf4j
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/users")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class UserTimelineAnalyticsController {
|
||||||
|
|
||||||
|
private final UserTimelineAnalyticsService userTimelineAnalyticsService;
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "사용자 전체 시간대별 참여 추이",
|
||||||
|
description = "사용자의 모든 이벤트 시간대별 데이터를 통합하여 분석합니다."
|
||||||
|
)
|
||||||
|
@GetMapping("/{userId}/analytics/timeline")
|
||||||
|
public ResponseEntity<ApiResponse<UserTimelineAnalyticsResponse>> getUserTimelineAnalytics(
|
||||||
|
@Parameter(description = "사용자 ID", required = true)
|
||||||
|
@PathVariable String userId,
|
||||||
|
|
||||||
|
@Parameter(description = "시간 간격 단위 (hourly, daily, weekly, monthly)")
|
||||||
|
@RequestParam(required = false, defaultValue = "daily")
|
||||||
|
String interval,
|
||||||
|
|
||||||
|
@Parameter(description = "조회 시작 날짜")
|
||||||
|
@RequestParam(required = false)
|
||||||
|
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
|
||||||
|
LocalDateTime startDate,
|
||||||
|
|
||||||
|
@Parameter(description = "조회 종료 날짜")
|
||||||
|
@RequestParam(required = false)
|
||||||
|
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
|
||||||
|
LocalDateTime endDate,
|
||||||
|
|
||||||
|
@Parameter(description = "조회할 지표 목록 (쉼표로 구분)")
|
||||||
|
@RequestParam(required = false)
|
||||||
|
String metrics,
|
||||||
|
|
||||||
|
@Parameter(description = "캐시 갱신 여부")
|
||||||
|
@RequestParam(required = false, defaultValue = "false")
|
||||||
|
Boolean refresh
|
||||||
|
) {
|
||||||
|
log.info("사용자 타임라인 분석 API 호출: userId={}, interval={}", userId, interval);
|
||||||
|
|
||||||
|
List<String> metricList = metrics != null && !metrics.isBlank()
|
||||||
|
? Arrays.asList(metrics.split(","))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
UserTimelineAnalyticsResponse response = userTimelineAnalyticsService.getUserTimelineAnalytics(
|
||||||
|
userId, interval, startDate, endDate, metricList, refresh
|
||||||
|
);
|
||||||
|
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(response));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -17,7 +17,12 @@ public class AnalyticsSummary {
|
|||||||
/**
|
/**
|
||||||
* 총 참여자 수
|
* 총 참여자 수
|
||||||
*/
|
*/
|
||||||
private Integer totalParticipants;
|
private Integer participants;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 참여자 증감 (이전 기간 대비)
|
||||||
|
*/
|
||||||
|
private Integer participantsDelta;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 총 조회수
|
* 총 조회수
|
||||||
@ -44,6 +49,11 @@ public class AnalyticsSummary {
|
|||||||
*/
|
*/
|
||||||
private Integer averageEngagementTime;
|
private Integer averageEngagementTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 목표 ROI (%)
|
||||||
|
*/
|
||||||
|
private Double targetRoi;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SNS 반응 통계
|
* SNS 반응 통계
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -17,7 +17,7 @@ public class ChannelSummary {
|
|||||||
/**
|
/**
|
||||||
* 채널명
|
* 채널명
|
||||||
*/
|
*/
|
||||||
private String channelName;
|
private String channel;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 조회수
|
* 조회수
|
||||||
|
|||||||
@ -19,7 +19,7 @@ public class RoiSummary {
|
|||||||
/**
|
/**
|
||||||
* 총 투자 비용 (원)
|
* 총 투자 비용 (원)
|
||||||
*/
|
*/
|
||||||
private BigDecimal totalInvestment;
|
private BigDecimal totalCost;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 예상 매출 증대 (원)
|
* 예상 매출 증대 (원)
|
||||||
|
|||||||
@ -0,0 +1,87 @@
|
|||||||
|
package com.kt.event.analytics.dto.response;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자 전체 이벤트 통합 대시보드 응답
|
||||||
|
*
|
||||||
|
* 사용자 ID 기반으로 모든 이벤트의 성과를 통합하여 제공
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class UserAnalyticsDashboardResponse {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자 ID
|
||||||
|
*/
|
||||||
|
private String userId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 조회 기간 정보
|
||||||
|
*/
|
||||||
|
private PeriodInfo period;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전체 이벤트 수
|
||||||
|
*/
|
||||||
|
private Integer totalEvents;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 활성 이벤트 수
|
||||||
|
*/
|
||||||
|
private Integer activeEvents;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전체 성과 요약 (모든 이벤트 통합)
|
||||||
|
*/
|
||||||
|
private AnalyticsSummary overallSummary;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 채널별 성과 요약 (모든 이벤트 통합)
|
||||||
|
*/
|
||||||
|
private List<ChannelSummary> channelPerformance;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전체 ROI 요약
|
||||||
|
*/
|
||||||
|
private RoiSummary overallRoi;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이벤트별 성과 목록 (간략)
|
||||||
|
*/
|
||||||
|
private List<EventPerformanceSummary> eventPerformances;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 마지막 업데이트 시간
|
||||||
|
*/
|
||||||
|
private LocalDateTime lastUpdatedAt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 데이터 출처 (real-time, cached, fallback)
|
||||||
|
*/
|
||||||
|
private String dataSource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이벤트별 성과 요약
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public static class EventPerformanceSummary {
|
||||||
|
private String eventId;
|
||||||
|
private String eventTitle;
|
||||||
|
private Integer participants;
|
||||||
|
private Integer views;
|
||||||
|
private Double roi;
|
||||||
|
private String status;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,56 @@
|
|||||||
|
package com.kt.event.analytics.dto.response;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자 전체 이벤트의 채널별 성과 분석 응답
|
||||||
|
*
|
||||||
|
* 사용자 ID 기반으로 모든 이벤트의 채널 성과를 통합하여 제공
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class UserChannelAnalyticsResponse {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자 ID
|
||||||
|
*/
|
||||||
|
private String userId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 조회 기간 정보
|
||||||
|
*/
|
||||||
|
private PeriodInfo period;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전체 이벤트 수
|
||||||
|
*/
|
||||||
|
private Integer totalEvents;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 채널별 통합 성과 목록
|
||||||
|
*/
|
||||||
|
private List<ChannelAnalytics> channels;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 채널 간 비교 분석
|
||||||
|
*/
|
||||||
|
private ChannelComparison comparison;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 마지막 업데이트 시간
|
||||||
|
*/
|
||||||
|
private LocalDateTime lastUpdatedAt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 데이터 출처
|
||||||
|
*/
|
||||||
|
private String dataSource;
|
||||||
|
}
|
||||||
@ -0,0 +1,92 @@
|
|||||||
|
package com.kt.event.analytics.dto.response;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자 전체 이벤트의 ROI 분석 응답
|
||||||
|
*
|
||||||
|
* 사용자 ID 기반으로 모든 이벤트의 ROI를 통합하여 제공
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class UserRoiAnalyticsResponse {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자 ID
|
||||||
|
*/
|
||||||
|
private String userId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 조회 기간 정보
|
||||||
|
*/
|
||||||
|
private PeriodInfo period;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전체 이벤트 수
|
||||||
|
*/
|
||||||
|
private Integer totalEvents;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전체 투자 정보 (모든 이벤트 합계)
|
||||||
|
*/
|
||||||
|
private InvestmentDetails overallInvestment;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전체 수익 정보 (모든 이벤트 합계)
|
||||||
|
*/
|
||||||
|
private RevenueDetails overallRevenue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전체 ROI 계산 결과
|
||||||
|
*/
|
||||||
|
private RoiCalculation overallRoi;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비용 효율성 분석
|
||||||
|
*/
|
||||||
|
private CostEfficiency costEfficiency;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 수익 예측 (포함 여부에 따라 nullable)
|
||||||
|
*/
|
||||||
|
private RevenueProjection projection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이벤트별 ROI 목록
|
||||||
|
*/
|
||||||
|
private List<EventRoiSummary> eventRois;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 마지막 업데이트 시간
|
||||||
|
*/
|
||||||
|
private LocalDateTime lastUpdatedAt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 데이터 출처
|
||||||
|
*/
|
||||||
|
private String dataSource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이벤트별 ROI 요약
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public static class EventRoiSummary {
|
||||||
|
private String eventId;
|
||||||
|
private String eventTitle;
|
||||||
|
private Double totalInvestment;
|
||||||
|
private Double expectedRevenue;
|
||||||
|
private Double roi;
|
||||||
|
private String status;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,66 @@
|
|||||||
|
package com.kt.event.analytics.dto.response;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자 전체 이벤트의 시간대별 분석 응답
|
||||||
|
*
|
||||||
|
* 사용자 ID 기반으로 모든 이벤트의 시간대별 데이터를 통합하여 제공
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class UserTimelineAnalyticsResponse {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자 ID
|
||||||
|
*/
|
||||||
|
private String userId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 조회 기간 정보
|
||||||
|
*/
|
||||||
|
private PeriodInfo period;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전체 이벤트 수
|
||||||
|
*/
|
||||||
|
private Integer totalEvents;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 시간 간격 (hourly, daily, weekly, monthly)
|
||||||
|
*/
|
||||||
|
private String interval;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 시간대별 데이터 포인트 (모든 이벤트 통합)
|
||||||
|
*/
|
||||||
|
private List<TimelineDataPoint> dataPoints;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 트렌드 분석
|
||||||
|
*/
|
||||||
|
private TrendAnalysis trend;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 피크 시간 정보
|
||||||
|
*/
|
||||||
|
private PeakTimeInfo peakTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 마지막 업데이트 시간
|
||||||
|
*/
|
||||||
|
private LocalDateTime lastUpdatedAt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 데이터 출처
|
||||||
|
*/
|
||||||
|
private String dataSource;
|
||||||
|
}
|
||||||
@ -37,10 +37,10 @@ public class EventStats extends BaseTimeEntity {
|
|||||||
private String eventTitle;
|
private String eventTitle;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 매장 ID (소유자)
|
* 사용자 ID (소유자)
|
||||||
*/
|
*/
|
||||||
@Column(nullable = false, length = 50)
|
@Column(nullable = false, length = 50)
|
||||||
private String storeId;
|
private String userId;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 총 참여자 수
|
* 총 참여자 수
|
||||||
@ -63,6 +63,13 @@ public class EventStats extends BaseTimeEntity {
|
|||||||
@Builder.Default
|
@Builder.Default
|
||||||
private BigDecimal estimatedRoi = BigDecimal.ZERO;
|
private BigDecimal estimatedRoi = BigDecimal.ZERO;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 목표 ROI (%)
|
||||||
|
*/
|
||||||
|
@Column(precision = 10, scale = 2)
|
||||||
|
@Builder.Default
|
||||||
|
private BigDecimal targetRoi = BigDecimal.ZERO;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 매출 증가율 (%)
|
* 매출 증가율 (%)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -54,11 +54,11 @@ public class EventCreatedConsumer {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 이벤트 통계 초기화
|
// 2. 이벤트 통계 초기화 (1:1 관계: storeId → userId 매핑)
|
||||||
EventStats eventStats = EventStats.builder()
|
EventStats eventStats = EventStats.builder()
|
||||||
.eventId(eventId)
|
.eventId(eventId)
|
||||||
.eventTitle(event.getEventTitle())
|
.eventTitle(event.getEventTitle())
|
||||||
.storeId(event.getStoreId())
|
.userId(event.getStoreId()) // MVP: 1 user = 1 store, storeId를 userId로 매핑
|
||||||
.totalParticipants(0)
|
.totalParticipants(0)
|
||||||
.totalInvestment(event.getTotalInvestment())
|
.totalInvestment(event.getTotalInvestment())
|
||||||
.status(event.getStatus())
|
.status(event.getStatus())
|
||||||
|
|||||||
@ -29,4 +29,12 @@ public interface ChannelStatsRepository extends JpaRepository<ChannelStats, Long
|
|||||||
* @return 채널 통계
|
* @return 채널 통계
|
||||||
*/
|
*/
|
||||||
Optional<ChannelStats> findByEventIdAndChannelName(String eventId, String channelName);
|
Optional<ChannelStats> findByEventIdAndChannelName(String eventId, String channelName);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 여러 이벤트 ID로 모든 채널 통계 조회
|
||||||
|
*
|
||||||
|
* @param eventIds 이벤트 ID 목록
|
||||||
|
* @return 채널 통계 목록
|
||||||
|
*/
|
||||||
|
List<ChannelStats> findByEventIdIn(List<String> eventIds);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -39,11 +39,19 @@ public interface EventStatsRepository extends JpaRepository<EventStats, Long> {
|
|||||||
Optional<EventStats> findByEventIdWithLock(@Param("eventId") String eventId);
|
Optional<EventStats> findByEventIdWithLock(@Param("eventId") String eventId);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 매장 ID와 이벤트 ID로 통계 조회
|
* 사용자 ID와 이벤트 ID로 통계 조회
|
||||||
*
|
*
|
||||||
* @param storeId 매장 ID
|
* @param userId 사용자 ID
|
||||||
* @param eventId 이벤트 ID
|
* @param eventId 이벤트 ID
|
||||||
* @return 이벤트 통계
|
* @return 이벤트 통계
|
||||||
*/
|
*/
|
||||||
Optional<EventStats> findByStoreIdAndEventId(String storeId, String eventId);
|
Optional<EventStats> findByUserIdAndEventId(String userId, String eventId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자 ID로 모든 이벤트 통계 조회
|
||||||
|
*
|
||||||
|
* @param userId 사용자 ID
|
||||||
|
* @return 이벤트 통계 목록
|
||||||
|
*/
|
||||||
|
java.util.List<EventStats> findAllByUserId(String userId);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -37,4 +37,27 @@ public interface TimelineDataRepository extends JpaRepository<TimelineData, Long
|
|||||||
@Param("startDate") LocalDateTime startDate,
|
@Param("startDate") LocalDateTime startDate,
|
||||||
@Param("endDate") LocalDateTime endDate
|
@Param("endDate") LocalDateTime endDate
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 여러 이벤트 ID로 시간대별 데이터 조회 (시간 순 정렬)
|
||||||
|
*
|
||||||
|
* @param eventIds 이벤트 ID 목록
|
||||||
|
* @return 시간대별 데이터 목록
|
||||||
|
*/
|
||||||
|
List<TimelineData> findByEventIdInOrderByTimestampAsc(List<String> eventIds);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 여러 이벤트 ID와 기간으로 시간대별 데이터 조회
|
||||||
|
*
|
||||||
|
* @param eventIds 이벤트 ID 목록
|
||||||
|
* @param startDate 시작 날짜
|
||||||
|
* @param endDate 종료 날짜
|
||||||
|
* @return 시간대별 데이터 목록
|
||||||
|
*/
|
||||||
|
@Query("SELECT t FROM TimelineData t WHERE t.eventId IN :eventIds AND t.timestamp BETWEEN :startDate AND :endDate ORDER BY t.timestamp ASC")
|
||||||
|
List<TimelineData> findByEventIdInAndTimestampBetween(
|
||||||
|
@Param("eventIds") List<String> eventIds,
|
||||||
|
@Param("startDate") LocalDateTime startDate,
|
||||||
|
@Param("endDate") LocalDateTime endDate
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -179,12 +179,14 @@ public class AnalyticsService {
|
|||||||
.build();
|
.build();
|
||||||
|
|
||||||
return AnalyticsSummary.builder()
|
return AnalyticsSummary.builder()
|
||||||
.totalParticipants(eventStats.getTotalParticipants())
|
.participants(eventStats.getTotalParticipants())
|
||||||
|
.participantsDelta(0) // TODO: 이전 기간 데이터와 비교하여 계산
|
||||||
.totalViews(totalViews)
|
.totalViews(totalViews)
|
||||||
.totalReach(totalReach)
|
.totalReach(totalReach)
|
||||||
.engagementRate(Math.round(engagementRate * 10.0) / 10.0)
|
.engagementRate(Math.round(engagementRate * 10.0) / 10.0)
|
||||||
.conversionRate(Math.round(conversionRate * 10.0) / 10.0)
|
.conversionRate(Math.round(conversionRate * 10.0) / 10.0)
|
||||||
.averageEngagementTime(145) // 고정값 (실제로는 외부 API에서 가져와야 함)
|
.averageEngagementTime(145) // 고정값 (실제로는 외부 API에서 가져와야 함)
|
||||||
|
.targetRoi(eventStats.getTargetRoi() != null ? eventStats.getTargetRoi().doubleValue() : null)
|
||||||
.socialInteractions(socialStats)
|
.socialInteractions(socialStats)
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
@ -202,7 +204,7 @@ public class AnalyticsService {
|
|||||||
(stats.getParticipants() * 100.0 / stats.getDistributionCost().doubleValue()) : 0.0;
|
(stats.getParticipants() * 100.0 / stats.getDistributionCost().doubleValue()) : 0.0;
|
||||||
|
|
||||||
summaries.add(ChannelSummary.builder()
|
summaries.add(ChannelSummary.builder()
|
||||||
.channelName(stats.getChannelName())
|
.channel(stats.getChannelName())
|
||||||
.views(stats.getViews())
|
.views(stats.getViews())
|
||||||
.participants(stats.getParticipants())
|
.participants(stats.getParticipants())
|
||||||
.engagementRate(Math.round(engagementRate * 10.0) / 10.0)
|
.engagementRate(Math.round(engagementRate * 10.0) / 10.0)
|
||||||
|
|||||||
@ -192,7 +192,7 @@ public class ROICalculator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return RoiSummary.builder()
|
return RoiSummary.builder()
|
||||||
.totalInvestment(eventStats.getTotalInvestment())
|
.totalCost(eventStats.getTotalInvestment())
|
||||||
.expectedRevenue(eventStats.getExpectedRevenue())
|
.expectedRevenue(eventStats.getExpectedRevenue())
|
||||||
.netProfit(netProfit)
|
.netProfit(netProfit)
|
||||||
.roi(roi)
|
.roi(roi)
|
||||||
|
|||||||
@ -0,0 +1,339 @@
|
|||||||
|
package com.kt.event.analytics.service;
|
||||||
|
|
||||||
|
import com.kt.event.analytics.dto.response.*;
|
||||||
|
import com.kt.event.analytics.entity.ChannelStats;
|
||||||
|
import com.kt.event.analytics.entity.EventStats;
|
||||||
|
import com.kt.event.analytics.repository.ChannelStatsRepository;
|
||||||
|
import com.kt.event.analytics.repository.EventStatsRepository;
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.data.redis.core.RedisTemplate;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.math.RoundingMode;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User Analytics Service
|
||||||
|
*
|
||||||
|
* 매장(사용자) 전체 이벤트의 통합 성과 대시보드를 제공하는 서비스
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public class UserAnalyticsService {
|
||||||
|
|
||||||
|
private final EventStatsRepository eventStatsRepository;
|
||||||
|
private final ChannelStatsRepository channelStatsRepository;
|
||||||
|
private final ROICalculator roiCalculator;
|
||||||
|
private final RedisTemplate<String, String> redisTemplate;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
private static final String CACHE_KEY_PREFIX = "analytics:user:dashboard:";
|
||||||
|
private static final long CACHE_TTL = 1800; // 30분 (여러 이벤트 통합이므로 짧게)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자 전체 대시보드 데이터 조회
|
||||||
|
*
|
||||||
|
* @param userId 사용자 ID
|
||||||
|
* @param startDate 조회 시작 날짜 (선택)
|
||||||
|
* @param endDate 조회 종료 날짜 (선택)
|
||||||
|
* @param refresh 캐시 갱신 여부
|
||||||
|
* @return 사용자 통합 대시보드 응답
|
||||||
|
*/
|
||||||
|
public UserAnalyticsDashboardResponse getUserDashboardData(String userId, LocalDateTime startDate, LocalDateTime endDate, boolean refresh) {
|
||||||
|
log.info("사용자 전체 대시보드 데이터 조회 시작: userId={}, refresh={}", userId, refresh);
|
||||||
|
|
||||||
|
String cacheKey = CACHE_KEY_PREFIX + userId;
|
||||||
|
|
||||||
|
// 1. Redis 캐시 조회 (refresh가 false일 때만)
|
||||||
|
if (!refresh) {
|
||||||
|
String cachedData = redisTemplate.opsForValue().get(cacheKey);
|
||||||
|
if (cachedData != null) {
|
||||||
|
try {
|
||||||
|
log.info("✅ 캐시 HIT: {}", cacheKey);
|
||||||
|
return objectMapper.readValue(cachedData, UserAnalyticsDashboardResponse.class);
|
||||||
|
} catch (JsonProcessingException e) {
|
||||||
|
log.warn("캐시 데이터 역직렬화 실패: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 캐시 MISS: 데이터 조회 및 통합
|
||||||
|
log.info("캐시 MISS 또는 refresh=true: PostgreSQL 조회");
|
||||||
|
|
||||||
|
// 2-1. 사용자의 모든 이벤트 조회
|
||||||
|
List<EventStats> allEvents = eventStatsRepository.findAllByUserId(userId);
|
||||||
|
if (allEvents.isEmpty()) {
|
||||||
|
log.warn("사용자에 이벤트가 없음: userId={}", userId);
|
||||||
|
return buildEmptyResponse(userId, startDate, endDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug("사용자 이벤트 조회 완료: userId={}, 이벤트 수={}", userId, allEvents.size());
|
||||||
|
|
||||||
|
// 2-2. 모든 이벤트의 채널 통계 조회
|
||||||
|
List<String> eventIds = allEvents.stream()
|
||||||
|
.map(EventStats::getEventId)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
List<ChannelStats> allChannelStats = channelStatsRepository.findByEventIdIn(eventIds);
|
||||||
|
|
||||||
|
// 3. 통합 대시보드 데이터 구성
|
||||||
|
UserAnalyticsDashboardResponse response = buildUserDashboardData(userId, allEvents, allChannelStats, startDate, endDate);
|
||||||
|
|
||||||
|
// 4. Redis 캐싱 (30분 TTL)
|
||||||
|
try {
|
||||||
|
String jsonData = objectMapper.writeValueAsString(response);
|
||||||
|
redisTemplate.opsForValue().set(cacheKey, jsonData, CACHE_TTL, TimeUnit.SECONDS);
|
||||||
|
log.info("✅ Redis 캐시 저장 완료: {} (TTL: 30분)", cacheKey);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("캐시 저장 실패 (무시하고 계속 진행): {}", e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 빈 응답 생성 (이벤트가 없는 경우)
|
||||||
|
*/
|
||||||
|
private UserAnalyticsDashboardResponse buildEmptyResponse(String userId, LocalDateTime startDate, LocalDateTime endDate) {
|
||||||
|
return UserAnalyticsDashboardResponse.builder()
|
||||||
|
.userId(userId)
|
||||||
|
.period(buildPeriodInfo(startDate, endDate))
|
||||||
|
.totalEvents(0)
|
||||||
|
.activeEvents(0)
|
||||||
|
.overallSummary(buildEmptyAnalyticsSummary())
|
||||||
|
.channelPerformance(new ArrayList<>())
|
||||||
|
.overallRoi(buildEmptyRoiSummary())
|
||||||
|
.eventPerformances(new ArrayList<>())
|
||||||
|
.lastUpdatedAt(LocalDateTime.now())
|
||||||
|
.dataSource("empty")
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자 통합 대시보드 데이터 구성
|
||||||
|
*/
|
||||||
|
private UserAnalyticsDashboardResponse buildUserDashboardData(String userId, List<EventStats> allEvents,
|
||||||
|
List<ChannelStats> allChannelStats,
|
||||||
|
LocalDateTime startDate, LocalDateTime endDate) {
|
||||||
|
// 기간 정보
|
||||||
|
PeriodInfo period = buildPeriodInfo(startDate, endDate);
|
||||||
|
|
||||||
|
// 전체 이벤트 수 및 활성 이벤트 수
|
||||||
|
int totalEvents = allEvents.size();
|
||||||
|
long activeEvents = allEvents.stream()
|
||||||
|
.filter(e -> "ACTIVE".equalsIgnoreCase(e.getStatus()) || "RUNNING".equalsIgnoreCase(e.getStatus()))
|
||||||
|
.count();
|
||||||
|
|
||||||
|
// 전체 성과 요약 (모든 이벤트 통합)
|
||||||
|
AnalyticsSummary overallSummary = buildOverallSummary(allEvents, allChannelStats);
|
||||||
|
|
||||||
|
// 채널별 성과 요약 (모든 이벤트 통합)
|
||||||
|
List<ChannelSummary> channelPerformance = buildAggregatedChannelPerformance(allChannelStats, allEvents);
|
||||||
|
|
||||||
|
// 전체 ROI 요약
|
||||||
|
RoiSummary overallRoi = calculateOverallRoi(allEvents);
|
||||||
|
|
||||||
|
// 이벤트별 성과 목록
|
||||||
|
List<UserAnalyticsDashboardResponse.EventPerformanceSummary> eventPerformances = buildEventPerformances(allEvents);
|
||||||
|
|
||||||
|
return UserAnalyticsDashboardResponse.builder()
|
||||||
|
.userId(userId)
|
||||||
|
.period(period)
|
||||||
|
.totalEvents(totalEvents)
|
||||||
|
.activeEvents((int) activeEvents)
|
||||||
|
.overallSummary(overallSummary)
|
||||||
|
.channelPerformance(channelPerformance)
|
||||||
|
.overallRoi(overallRoi)
|
||||||
|
.eventPerformances(eventPerformances)
|
||||||
|
.lastUpdatedAt(LocalDateTime.now())
|
||||||
|
.dataSource("cached")
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전체 성과 요약 계산 (모든 이벤트 통합)
|
||||||
|
*/
|
||||||
|
private AnalyticsSummary buildOverallSummary(List<EventStats> allEvents, List<ChannelStats> allChannelStats) {
|
||||||
|
int totalParticipants = allEvents.stream()
|
||||||
|
.mapToInt(EventStats::getTotalParticipants)
|
||||||
|
.sum();
|
||||||
|
|
||||||
|
int totalViews = allEvents.stream()
|
||||||
|
.mapToInt(EventStats::getTotalViews)
|
||||||
|
.sum();
|
||||||
|
|
||||||
|
BigDecimal totalInvestment = allEvents.stream()
|
||||||
|
.map(EventStats::getTotalInvestment)
|
||||||
|
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||||
|
|
||||||
|
BigDecimal totalExpectedRevenue = allEvents.stream()
|
||||||
|
.map(EventStats::getExpectedRevenue)
|
||||||
|
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||||
|
|
||||||
|
// 평균 참여율 계산
|
||||||
|
double avgEngagementRate = totalViews > 0 ? (double) totalParticipants / totalViews * 100 : 0.0;
|
||||||
|
|
||||||
|
// 평균 전환율 계산 (채널 통계 기반)
|
||||||
|
int totalConversions = allChannelStats.stream()
|
||||||
|
.mapToInt(ChannelStats::getConversions)
|
||||||
|
.sum();
|
||||||
|
double avgConversionRate = totalParticipants > 0 ? (double) totalConversions / totalParticipants * 100 : 0.0;
|
||||||
|
|
||||||
|
return AnalyticsSummary.builder()
|
||||||
|
.participants(totalParticipants)
|
||||||
|
.participantsDelta(0) // TODO: 이전 기간 데이터와 비교하여 계산
|
||||||
|
.totalViews(totalViews)
|
||||||
|
.engagementRate(Math.round(avgEngagementRate * 10) / 10.0)
|
||||||
|
.conversionRate(Math.round(avgConversionRate * 10) / 10.0)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 채널별 성과 통합 (모든 이벤트의 채널 데이터 집계)
|
||||||
|
*/
|
||||||
|
private List<ChannelSummary> buildAggregatedChannelPerformance(List<ChannelStats> allChannelStats, List<EventStats> allEvents) {
|
||||||
|
if (allChannelStats.isEmpty()) {
|
||||||
|
return new ArrayList<>();
|
||||||
|
}
|
||||||
|
|
||||||
|
BigDecimal totalInvestment = allEvents.stream()
|
||||||
|
.map(EventStats::getTotalInvestment)
|
||||||
|
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||||
|
|
||||||
|
// 채널명별로 그룹화하여 집계
|
||||||
|
Map<String, List<ChannelStats>> channelGroups = allChannelStats.stream()
|
||||||
|
.collect(Collectors.groupingBy(ChannelStats::getChannelName));
|
||||||
|
|
||||||
|
return channelGroups.entrySet().stream()
|
||||||
|
.map(entry -> {
|
||||||
|
String channelName = entry.getKey();
|
||||||
|
List<ChannelStats> channelList = entry.getValue();
|
||||||
|
|
||||||
|
int participants = channelList.stream().mapToInt(ChannelStats::getParticipants).sum();
|
||||||
|
int views = channelList.stream().mapToInt(ChannelStats::getViews).sum();
|
||||||
|
double engagementRate = views > 0 ? (double) participants / views * 100 : 0.0;
|
||||||
|
|
||||||
|
BigDecimal channelCost = channelList.stream()
|
||||||
|
.map(ChannelStats::getDistributionCost)
|
||||||
|
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||||
|
|
||||||
|
double channelRoi = channelCost.compareTo(BigDecimal.ZERO) > 0
|
||||||
|
? (participants - channelCost.doubleValue()) / channelCost.doubleValue() * 100
|
||||||
|
: 0.0;
|
||||||
|
|
||||||
|
return ChannelSummary.builder()
|
||||||
|
.channel(channelName)
|
||||||
|
.participants(participants)
|
||||||
|
.views(views)
|
||||||
|
.engagementRate(Math.round(engagementRate * 10) / 10.0)
|
||||||
|
.roi(Math.round(channelRoi * 10) / 10.0)
|
||||||
|
.build();
|
||||||
|
})
|
||||||
|
.sorted(Comparator.comparingInt(ChannelSummary::getParticipants).reversed())
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전체 ROI 계산
|
||||||
|
*/
|
||||||
|
private RoiSummary calculateOverallRoi(List<EventStats> allEvents) {
|
||||||
|
BigDecimal totalInvestment = allEvents.stream()
|
||||||
|
.map(EventStats::getTotalInvestment)
|
||||||
|
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||||
|
|
||||||
|
BigDecimal totalExpectedRevenue = allEvents.stream()
|
||||||
|
.map(EventStats::getExpectedRevenue)
|
||||||
|
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||||
|
|
||||||
|
BigDecimal totalProfit = totalExpectedRevenue.subtract(totalInvestment);
|
||||||
|
|
||||||
|
Double roi = totalInvestment.compareTo(BigDecimal.ZERO) > 0
|
||||||
|
? totalProfit.divide(totalInvestment, 4, RoundingMode.HALF_UP)
|
||||||
|
.multiply(BigDecimal.valueOf(100))
|
||||||
|
.doubleValue()
|
||||||
|
: 0.0;
|
||||||
|
|
||||||
|
return RoiSummary.builder()
|
||||||
|
.totalCost(totalInvestment)
|
||||||
|
.expectedRevenue(totalExpectedRevenue)
|
||||||
|
.netProfit(totalProfit)
|
||||||
|
.roi(Math.round(roi * 10) / 10.0)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이벤트별 성과 목록 생성
|
||||||
|
*/
|
||||||
|
private List<UserAnalyticsDashboardResponse.EventPerformanceSummary> buildEventPerformances(List<EventStats> allEvents) {
|
||||||
|
return allEvents.stream()
|
||||||
|
.map(event -> {
|
||||||
|
Double roi = event.getTotalInvestment().compareTo(BigDecimal.ZERO) > 0
|
||||||
|
? event.getExpectedRevenue().subtract(event.getTotalInvestment())
|
||||||
|
.divide(event.getTotalInvestment(), 4, RoundingMode.HALF_UP)
|
||||||
|
.multiply(BigDecimal.valueOf(100))
|
||||||
|
.doubleValue()
|
||||||
|
: 0.0;
|
||||||
|
|
||||||
|
return UserAnalyticsDashboardResponse.EventPerformanceSummary.builder()
|
||||||
|
.eventId(event.getEventId())
|
||||||
|
.eventTitle(event.getEventTitle())
|
||||||
|
.participants(event.getTotalParticipants())
|
||||||
|
.views(event.getTotalViews())
|
||||||
|
.roi(Math.round(roi * 10) / 10.0)
|
||||||
|
.status(event.getStatus())
|
||||||
|
.build();
|
||||||
|
})
|
||||||
|
.sorted(Comparator.comparingInt(UserAnalyticsDashboardResponse.EventPerformanceSummary::getParticipants).reversed())
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기간 정보 구성
|
||||||
|
*/
|
||||||
|
private PeriodInfo buildPeriodInfo(LocalDateTime startDate, LocalDateTime endDate) {
|
||||||
|
LocalDateTime start = startDate != null ? startDate : LocalDateTime.now().minusDays(30);
|
||||||
|
LocalDateTime end = endDate != null ? endDate : LocalDateTime.now();
|
||||||
|
long durationDays = ChronoUnit.DAYS.between(start, end);
|
||||||
|
|
||||||
|
return PeriodInfo.builder()
|
||||||
|
.startDate(start)
|
||||||
|
.endDate(end)
|
||||||
|
.durationDays((int) durationDays)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 빈 성과 요약
|
||||||
|
*/
|
||||||
|
private AnalyticsSummary buildEmptyAnalyticsSummary() {
|
||||||
|
return AnalyticsSummary.builder()
|
||||||
|
.participants(0)
|
||||||
|
.participantsDelta(0)
|
||||||
|
.totalViews(0)
|
||||||
|
.engagementRate(0.0)
|
||||||
|
.conversionRate(0.0)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 빈 ROI 요약
|
||||||
|
*/
|
||||||
|
private RoiSummary buildEmptyRoiSummary() {
|
||||||
|
return RoiSummary.builder()
|
||||||
|
.totalCost(BigDecimal.ZERO)
|
||||||
|
.expectedRevenue(BigDecimal.ZERO)
|
||||||
|
.netProfit(BigDecimal.ZERO)
|
||||||
|
.roi(0.0)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,260 @@
|
|||||||
|
package com.kt.event.analytics.service;
|
||||||
|
|
||||||
|
import com.kt.event.analytics.dto.response.*;
|
||||||
|
import com.kt.event.analytics.entity.ChannelStats;
|
||||||
|
import com.kt.event.analytics.entity.EventStats;
|
||||||
|
import com.kt.event.analytics.repository.ChannelStatsRepository;
|
||||||
|
import com.kt.event.analytics.repository.EventStatsRepository;
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.data.redis.core.RedisTemplate;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.math.RoundingMode;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
import java.util.HashMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User Channel Analytics Service
|
||||||
|
*
|
||||||
|
* 매장(사용자) 전체 이벤트의 채널별 성과를 통합하여 제공하는 서비스
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public class UserChannelAnalyticsService {
|
||||||
|
|
||||||
|
private final EventStatsRepository eventStatsRepository;
|
||||||
|
private final ChannelStatsRepository channelStatsRepository;
|
||||||
|
private final RedisTemplate<String, String> redisTemplate;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
private static final String CACHE_KEY_PREFIX = "analytics:user:channels:";
|
||||||
|
private static final long CACHE_TTL = 1800; // 30분
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자 전체 채널 분석 데이터 조회
|
||||||
|
*/
|
||||||
|
public UserChannelAnalyticsResponse getUserChannelAnalytics(String userId, List<String> channels, String sortBy, String order,
|
||||||
|
LocalDateTime startDate, LocalDateTime endDate, boolean refresh) {
|
||||||
|
log.info("사용자 채널 분석 조회 시작: userId={}, refresh={}", userId, refresh);
|
||||||
|
|
||||||
|
String cacheKey = CACHE_KEY_PREFIX + userId;
|
||||||
|
|
||||||
|
// 1. 캐시 조회
|
||||||
|
if (!refresh) {
|
||||||
|
String cachedData = redisTemplate.opsForValue().get(cacheKey);
|
||||||
|
if (cachedData != null) {
|
||||||
|
try {
|
||||||
|
log.info("✅ 캐시 HIT: {}", cacheKey);
|
||||||
|
return objectMapper.readValue(cachedData, UserChannelAnalyticsResponse.class);
|
||||||
|
} catch (JsonProcessingException e) {
|
||||||
|
log.warn("캐시 역직렬화 실패: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 데이터 조회
|
||||||
|
List<EventStats> allEvents = eventStatsRepository.findAllByUserId(userId);
|
||||||
|
if (allEvents.isEmpty()) {
|
||||||
|
return buildEmptyResponse(userId, startDate, endDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> eventIds = allEvents.stream().map(EventStats::getEventId).collect(Collectors.toList());
|
||||||
|
List<ChannelStats> allChannelStats = channelStatsRepository.findByEventIdIn(eventIds);
|
||||||
|
|
||||||
|
// 3. 응답 구성
|
||||||
|
UserChannelAnalyticsResponse response = buildChannelAnalyticsResponse(userId, allEvents, allChannelStats, channels, sortBy, order, startDate, endDate);
|
||||||
|
|
||||||
|
// 4. 캐싱
|
||||||
|
try {
|
||||||
|
String jsonData = objectMapper.writeValueAsString(response);
|
||||||
|
redisTemplate.opsForValue().set(cacheKey, jsonData, CACHE_TTL, TimeUnit.SECONDS);
|
||||||
|
log.info("✅ 캐시 저장 완료: {}", cacheKey);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("캐시 저장 실패: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
private UserChannelAnalyticsResponse buildEmptyResponse(String userId, LocalDateTime startDate, LocalDateTime endDate) {
|
||||||
|
return UserChannelAnalyticsResponse.builder()
|
||||||
|
.userId(userId)
|
||||||
|
.period(buildPeriodInfo(startDate, endDate))
|
||||||
|
.totalEvents(0)
|
||||||
|
.channels(new ArrayList<>())
|
||||||
|
.comparison(ChannelComparison.builder().build())
|
||||||
|
.lastUpdatedAt(LocalDateTime.now())
|
||||||
|
.dataSource("empty")
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private UserChannelAnalyticsResponse buildChannelAnalyticsResponse(String userId, List<EventStats> allEvents,
|
||||||
|
List<ChannelStats> allChannelStats, List<String> channels,
|
||||||
|
String sortBy, String order, LocalDateTime startDate, LocalDateTime endDate) {
|
||||||
|
// 채널 필터링
|
||||||
|
List<ChannelStats> filteredChannels = channels != null && !channels.isEmpty()
|
||||||
|
? allChannelStats.stream().filter(c -> channels.contains(c.getChannelName())).collect(Collectors.toList())
|
||||||
|
: allChannelStats;
|
||||||
|
|
||||||
|
// 채널별 집계
|
||||||
|
List<ChannelAnalytics> channelAnalyticsList = aggregateChannelAnalytics(filteredChannels);
|
||||||
|
|
||||||
|
// 정렬
|
||||||
|
channelAnalyticsList = sortChannels(channelAnalyticsList, sortBy, order);
|
||||||
|
|
||||||
|
// 채널 비교
|
||||||
|
ChannelComparison comparison = buildChannelComparison(channelAnalyticsList);
|
||||||
|
|
||||||
|
return UserChannelAnalyticsResponse.builder()
|
||||||
|
.userId(userId)
|
||||||
|
.period(buildPeriodInfo(startDate, endDate))
|
||||||
|
.totalEvents(allEvents.size())
|
||||||
|
.channels(channelAnalyticsList)
|
||||||
|
.comparison(comparison)
|
||||||
|
.lastUpdatedAt(LocalDateTime.now())
|
||||||
|
.dataSource("cached")
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<ChannelAnalytics> aggregateChannelAnalytics(List<ChannelStats> allChannelStats) {
|
||||||
|
Map<String, List<ChannelStats>> channelGroups = allChannelStats.stream()
|
||||||
|
.collect(Collectors.groupingBy(ChannelStats::getChannelName));
|
||||||
|
|
||||||
|
return channelGroups.entrySet().stream()
|
||||||
|
.map(entry -> {
|
||||||
|
String channelName = entry.getKey();
|
||||||
|
List<ChannelStats> channelList = entry.getValue();
|
||||||
|
|
||||||
|
int views = channelList.stream().mapToInt(ChannelStats::getViews).sum();
|
||||||
|
int participants = channelList.stream().mapToInt(ChannelStats::getParticipants).sum();
|
||||||
|
int clicks = channelList.stream().mapToInt(ChannelStats::getClicks).sum();
|
||||||
|
int conversions = channelList.stream().mapToInt(ChannelStats::getConversions).sum();
|
||||||
|
|
||||||
|
double engagementRate = views > 0 ? (double) participants / views * 100 : 0.0;
|
||||||
|
double conversionRate = participants > 0 ? (double) conversions / participants * 100 : 0.0;
|
||||||
|
|
||||||
|
BigDecimal cost = channelList.stream()
|
||||||
|
.map(ChannelStats::getDistributionCost)
|
||||||
|
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||||
|
|
||||||
|
double roi = cost.compareTo(BigDecimal.ZERO) > 0
|
||||||
|
? (participants - cost.doubleValue()) / cost.doubleValue() * 100
|
||||||
|
: 0.0;
|
||||||
|
|
||||||
|
ChannelMetrics metrics = ChannelMetrics.builder()
|
||||||
|
.impressions(channelList.stream().mapToInt(ChannelStats::getImpressions).sum())
|
||||||
|
.views(views)
|
||||||
|
.clicks(clicks)
|
||||||
|
.participants(participants)
|
||||||
|
.conversions(conversions)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
ChannelPerformance performance = ChannelPerformance.builder()
|
||||||
|
.engagementRate(Math.round(engagementRate * 10) / 10.0)
|
||||||
|
.conversionRate(Math.round(conversionRate * 10) / 10.0)
|
||||||
|
.clickThroughRate(views > 0 ? Math.round((double) clicks / views * 1000) / 10.0 : 0.0)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
ChannelCosts costs = ChannelCosts.builder()
|
||||||
|
.distributionCost(cost)
|
||||||
|
.costPerView(views > 0 ? cost.doubleValue() / views : 0.0)
|
||||||
|
.costPerClick(clicks > 0 ? cost.doubleValue() / clicks : 0.0)
|
||||||
|
.costPerAcquisition(participants > 0 ? cost.doubleValue() / participants : 0.0)
|
||||||
|
.roi(Math.round(roi * 10) / 10.0)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
return ChannelAnalytics.builder()
|
||||||
|
.channelName(channelName)
|
||||||
|
.channelType(channelList.get(0).getChannelType())
|
||||||
|
.metrics(metrics)
|
||||||
|
.performance(performance)
|
||||||
|
.costs(costs)
|
||||||
|
.build();
|
||||||
|
})
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<ChannelAnalytics> sortChannels(List<ChannelAnalytics> channels, String sortBy, String order) {
|
||||||
|
Comparator<ChannelAnalytics> comparator;
|
||||||
|
|
||||||
|
switch (sortBy != null ? sortBy.toLowerCase() : "participants") {
|
||||||
|
case "views":
|
||||||
|
comparator = Comparator.comparingInt(c -> c.getMetrics().getViews());
|
||||||
|
break;
|
||||||
|
case "engagement_rate":
|
||||||
|
comparator = Comparator.comparingDouble(c -> c.getPerformance().getEngagementRate());
|
||||||
|
break;
|
||||||
|
case "conversion_rate":
|
||||||
|
comparator = Comparator.comparingDouble(c -> c.getPerformance().getConversionRate());
|
||||||
|
break;
|
||||||
|
case "roi":
|
||||||
|
comparator = Comparator.comparingDouble(c -> c.getCosts().getRoi());
|
||||||
|
break;
|
||||||
|
case "participants":
|
||||||
|
default:
|
||||||
|
comparator = Comparator.comparingInt(c -> c.getMetrics().getParticipants());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("desc".equalsIgnoreCase(order)) {
|
||||||
|
comparator = comparator.reversed();
|
||||||
|
}
|
||||||
|
|
||||||
|
return channels.stream().sorted(comparator).collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
private ChannelComparison buildChannelComparison(List<ChannelAnalytics> channels) {
|
||||||
|
if (channels.isEmpty()) {
|
||||||
|
return ChannelComparison.builder().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
String bestPerformingChannel = channels.stream()
|
||||||
|
.max(Comparator.comparingInt(c -> c.getMetrics().getParticipants()))
|
||||||
|
.map(ChannelAnalytics::getChannelName)
|
||||||
|
.orElse("N/A");
|
||||||
|
|
||||||
|
Map<String, String> bestPerforming = new HashMap<>();
|
||||||
|
bestPerforming.put("channel", bestPerformingChannel);
|
||||||
|
bestPerforming.put("metric", "participants");
|
||||||
|
|
||||||
|
Map<String, Double> averageMetrics = new HashMap<>();
|
||||||
|
int totalChannels = channels.size();
|
||||||
|
if (totalChannels > 0) {
|
||||||
|
double avgParticipants = channels.stream().mapToInt(c -> c.getMetrics().getParticipants()).average().orElse(0.0);
|
||||||
|
double avgEngagement = channels.stream().mapToDouble(c -> c.getPerformance().getEngagementRate()).average().orElse(0.0);
|
||||||
|
double avgRoi = channels.stream().mapToDouble(c -> c.getCosts().getRoi()).average().orElse(0.0);
|
||||||
|
|
||||||
|
averageMetrics.put("participants", avgParticipants);
|
||||||
|
averageMetrics.put("engagementRate", avgEngagement);
|
||||||
|
averageMetrics.put("roi", avgRoi);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ChannelComparison.builder()
|
||||||
|
.bestPerforming(bestPerforming)
|
||||||
|
.averageMetrics(averageMetrics)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private PeriodInfo buildPeriodInfo(LocalDateTime startDate, LocalDateTime endDate) {
|
||||||
|
LocalDateTime start = startDate != null ? startDate : LocalDateTime.now().minusDays(30);
|
||||||
|
LocalDateTime end = endDate != null ? endDate : LocalDateTime.now();
|
||||||
|
long durationDays = ChronoUnit.DAYS.between(start, end);
|
||||||
|
|
||||||
|
return PeriodInfo.builder()
|
||||||
|
.startDate(start)
|
||||||
|
.endDate(end)
|
||||||
|
.durationDays((int) durationDays)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,176 @@
|
|||||||
|
package com.kt.event.analytics.service;
|
||||||
|
|
||||||
|
import com.kt.event.analytics.dto.response.*;
|
||||||
|
import com.kt.event.analytics.entity.EventStats;
|
||||||
|
import com.kt.event.analytics.repository.EventStatsRepository;
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.data.redis.core.RedisTemplate;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.math.RoundingMode;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User ROI Analytics Service
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public class UserRoiAnalyticsService {
|
||||||
|
|
||||||
|
private final EventStatsRepository eventStatsRepository;
|
||||||
|
private final RedisTemplate<String, String> redisTemplate;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
private static final String CACHE_KEY_PREFIX = "analytics:user:roi:";
|
||||||
|
private static final long CACHE_TTL = 1800;
|
||||||
|
|
||||||
|
public UserRoiAnalyticsResponse getUserRoiAnalytics(String userId, boolean includeProjection,
|
||||||
|
LocalDateTime startDate, LocalDateTime endDate, boolean refresh) {
|
||||||
|
log.info("사용자 ROI 분석 조회 시작: userId={}, refresh={}", userId, refresh);
|
||||||
|
|
||||||
|
String cacheKey = CACHE_KEY_PREFIX + userId;
|
||||||
|
|
||||||
|
if (!refresh) {
|
||||||
|
String cachedData = redisTemplate.opsForValue().get(cacheKey);
|
||||||
|
if (cachedData != null) {
|
||||||
|
try {
|
||||||
|
return objectMapper.readValue(cachedData, UserRoiAnalyticsResponse.class);
|
||||||
|
} catch (JsonProcessingException e) {
|
||||||
|
log.warn("캐시 역직렬화 실패: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<EventStats> allEvents = eventStatsRepository.findAllByUserId(userId);
|
||||||
|
if (allEvents.isEmpty()) {
|
||||||
|
return buildEmptyResponse(userId, startDate, endDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
UserRoiAnalyticsResponse response = buildRoiResponse(userId, allEvents, includeProjection, startDate, endDate);
|
||||||
|
|
||||||
|
try {
|
||||||
|
String jsonData = objectMapper.writeValueAsString(response);
|
||||||
|
redisTemplate.opsForValue().set(cacheKey, jsonData, CACHE_TTL, TimeUnit.SECONDS);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("캐시 저장 실패: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
private UserRoiAnalyticsResponse buildEmptyResponse(String userId, LocalDateTime startDate, LocalDateTime endDate) {
|
||||||
|
return UserRoiAnalyticsResponse.builder()
|
||||||
|
.userId(userId)
|
||||||
|
.period(buildPeriodInfo(startDate, endDate))
|
||||||
|
.totalEvents(0)
|
||||||
|
.overallInvestment(InvestmentDetails.builder().total(BigDecimal.ZERO).build())
|
||||||
|
.overallRevenue(RevenueDetails.builder().total(BigDecimal.ZERO).build())
|
||||||
|
.overallRoi(RoiCalculation.builder()
|
||||||
|
.netProfit(BigDecimal.ZERO)
|
||||||
|
.roiPercentage(0.0)
|
||||||
|
.build())
|
||||||
|
.eventRois(new ArrayList<>())
|
||||||
|
.lastUpdatedAt(LocalDateTime.now())
|
||||||
|
.dataSource("empty")
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private UserRoiAnalyticsResponse buildRoiResponse(String userId, List<EventStats> allEvents, boolean includeProjection,
|
||||||
|
LocalDateTime startDate, LocalDateTime endDate) {
|
||||||
|
BigDecimal totalInvestment = allEvents.stream().map(EventStats::getTotalInvestment).reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||||
|
BigDecimal totalRevenue = allEvents.stream().map(EventStats::getExpectedRevenue).reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||||
|
BigDecimal totalProfit = totalRevenue.subtract(totalInvestment);
|
||||||
|
|
||||||
|
Double roiPercentage = totalInvestment.compareTo(BigDecimal.ZERO) > 0
|
||||||
|
? totalProfit.divide(totalInvestment, 4, RoundingMode.HALF_UP).multiply(BigDecimal.valueOf(100)).doubleValue()
|
||||||
|
: 0.0;
|
||||||
|
|
||||||
|
InvestmentDetails investment = InvestmentDetails.builder()
|
||||||
|
.total(totalInvestment)
|
||||||
|
.contentCreation(totalInvestment.multiply(BigDecimal.valueOf(0.6)))
|
||||||
|
.operation(totalInvestment.multiply(BigDecimal.valueOf(0.2)))
|
||||||
|
.distribution(totalInvestment.multiply(BigDecimal.valueOf(0.2)))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
RevenueDetails revenue = RevenueDetails.builder()
|
||||||
|
.total(totalRevenue)
|
||||||
|
.directSales(totalRevenue.multiply(BigDecimal.valueOf(0.7)))
|
||||||
|
.expectedSales(totalRevenue.multiply(BigDecimal.valueOf(0.3)))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
RoiCalculation roiCalc = RoiCalculation.builder()
|
||||||
|
.netProfit(totalProfit)
|
||||||
|
.roiPercentage(Math.round(roiPercentage * 10) / 10.0)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
int totalParticipants = allEvents.stream().mapToInt(EventStats::getTotalParticipants).sum();
|
||||||
|
CostEfficiency efficiency = CostEfficiency.builder()
|
||||||
|
.costPerParticipant(totalParticipants > 0 ? totalInvestment.doubleValue() / totalParticipants : 0.0)
|
||||||
|
.revenuePerParticipant(totalParticipants > 0 ? totalRevenue.doubleValue() / totalParticipants : 0.0)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
RevenueProjection projection = includeProjection ? RevenueProjection.builder()
|
||||||
|
.currentRevenue(totalRevenue)
|
||||||
|
.projectedFinalRevenue(totalRevenue.multiply(BigDecimal.valueOf(1.2)))
|
||||||
|
.confidenceLevel(85.0)
|
||||||
|
.basedOn("Historical trend analysis")
|
||||||
|
.build() : null;
|
||||||
|
|
||||||
|
List<UserRoiAnalyticsResponse.EventRoiSummary> eventRois = allEvents.stream()
|
||||||
|
.map(event -> {
|
||||||
|
Double eventRoi = event.getTotalInvestment().compareTo(BigDecimal.ZERO) > 0
|
||||||
|
? event.getExpectedRevenue().subtract(event.getTotalInvestment())
|
||||||
|
.divide(event.getTotalInvestment(), 4, RoundingMode.HALF_UP)
|
||||||
|
.multiply(BigDecimal.valueOf(100)).doubleValue()
|
||||||
|
: 0.0;
|
||||||
|
|
||||||
|
return UserRoiAnalyticsResponse.EventRoiSummary.builder()
|
||||||
|
.eventId(event.getEventId())
|
||||||
|
.eventTitle(event.getEventTitle())
|
||||||
|
.totalInvestment(event.getTotalInvestment().doubleValue())
|
||||||
|
.expectedRevenue(event.getExpectedRevenue().doubleValue())
|
||||||
|
.roi(Math.round(eventRoi * 10) / 10.0)
|
||||||
|
.status(event.getStatus())
|
||||||
|
.build();
|
||||||
|
})
|
||||||
|
.sorted(Comparator.comparingDouble(UserRoiAnalyticsResponse.EventRoiSummary::getRoi).reversed())
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
return UserRoiAnalyticsResponse.builder()
|
||||||
|
.userId(userId)
|
||||||
|
.period(buildPeriodInfo(startDate, endDate))
|
||||||
|
.totalEvents(allEvents.size())
|
||||||
|
.overallInvestment(investment)
|
||||||
|
.overallRevenue(revenue)
|
||||||
|
.overallRoi(roiCalc)
|
||||||
|
.costEfficiency(efficiency)
|
||||||
|
.projection(projection)
|
||||||
|
.eventRois(eventRois)
|
||||||
|
.lastUpdatedAt(LocalDateTime.now())
|
||||||
|
.dataSource("cached")
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private PeriodInfo buildPeriodInfo(LocalDateTime startDate, LocalDateTime endDate) {
|
||||||
|
LocalDateTime start = startDate != null ? startDate : LocalDateTime.now().minusDays(30);
|
||||||
|
LocalDateTime end = endDate != null ? endDate : LocalDateTime.now();
|
||||||
|
return PeriodInfo.builder()
|
||||||
|
.startDate(start)
|
||||||
|
.endDate(end)
|
||||||
|
.durationDays((int) ChronoUnit.DAYS.between(start, end))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,191 @@
|
|||||||
|
package com.kt.event.analytics.service;
|
||||||
|
|
||||||
|
import com.kt.event.analytics.dto.response.*;
|
||||||
|
import com.kt.event.analytics.entity.EventStats;
|
||||||
|
import com.kt.event.analytics.entity.TimelineData;
|
||||||
|
import com.kt.event.analytics.repository.EventStatsRepository;
|
||||||
|
import com.kt.event.analytics.repository.TimelineDataRepository;
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.data.redis.core.RedisTemplate;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User Timeline Analytics Service
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public class UserTimelineAnalyticsService {
|
||||||
|
|
||||||
|
private final EventStatsRepository eventStatsRepository;
|
||||||
|
private final TimelineDataRepository timelineDataRepository;
|
||||||
|
private final RedisTemplate<String, String> redisTemplate;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
private static final String CACHE_KEY_PREFIX = "analytics:user:timeline:";
|
||||||
|
private static final long CACHE_TTL = 1800;
|
||||||
|
|
||||||
|
public UserTimelineAnalyticsResponse getUserTimelineAnalytics(String userId, String interval,
|
||||||
|
LocalDateTime startDate, LocalDateTime endDate,
|
||||||
|
List<String> metrics, boolean refresh) {
|
||||||
|
log.info("사용자 타임라인 분석 조회 시작: userId={}, interval={}, refresh={}", userId, interval, refresh);
|
||||||
|
|
||||||
|
String cacheKey = CACHE_KEY_PREFIX + userId + ":" + interval;
|
||||||
|
|
||||||
|
if (!refresh) {
|
||||||
|
String cachedData = redisTemplate.opsForValue().get(cacheKey);
|
||||||
|
if (cachedData != null) {
|
||||||
|
try {
|
||||||
|
return objectMapper.readValue(cachedData, UserTimelineAnalyticsResponse.class);
|
||||||
|
} catch (JsonProcessingException e) {
|
||||||
|
log.warn("캐시 역직렬화 실패: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<EventStats> allEvents = eventStatsRepository.findAllByUserId(userId);
|
||||||
|
if (allEvents.isEmpty()) {
|
||||||
|
return buildEmptyResponse(userId, interval, startDate, endDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> eventIds = allEvents.stream().map(EventStats::getEventId).collect(Collectors.toList());
|
||||||
|
List<TimelineData> allTimelineData = startDate != null && endDate != null
|
||||||
|
? timelineDataRepository.findByEventIdInAndTimestampBetween(eventIds, startDate, endDate)
|
||||||
|
: timelineDataRepository.findByEventIdInOrderByTimestampAsc(eventIds);
|
||||||
|
|
||||||
|
UserTimelineAnalyticsResponse response = buildTimelineResponse(userId, allEvents, allTimelineData, interval, startDate, endDate);
|
||||||
|
|
||||||
|
try {
|
||||||
|
String jsonData = objectMapper.writeValueAsString(response);
|
||||||
|
redisTemplate.opsForValue().set(cacheKey, jsonData, CACHE_TTL, TimeUnit.SECONDS);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("캐시 저장 실패: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
private UserTimelineAnalyticsResponse buildEmptyResponse(String userId, String interval, LocalDateTime startDate, LocalDateTime endDate) {
|
||||||
|
return UserTimelineAnalyticsResponse.builder()
|
||||||
|
.userId(userId)
|
||||||
|
.period(buildPeriodInfo(startDate, endDate))
|
||||||
|
.totalEvents(0)
|
||||||
|
.interval(interval != null ? interval : "daily")
|
||||||
|
.dataPoints(new ArrayList<>())
|
||||||
|
.trend(TrendAnalysis.builder().overallTrend("stable").build())
|
||||||
|
.peakTime(PeakTimeInfo.builder().build())
|
||||||
|
.lastUpdatedAt(LocalDateTime.now())
|
||||||
|
.dataSource("empty")
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private UserTimelineAnalyticsResponse buildTimelineResponse(String userId, List<EventStats> allEvents,
|
||||||
|
List<TimelineData> allTimelineData, String interval,
|
||||||
|
LocalDateTime startDate, LocalDateTime endDate) {
|
||||||
|
Map<LocalDateTime, TimelineDataPoint> aggregatedData = new LinkedHashMap<>();
|
||||||
|
|
||||||
|
for (TimelineData data : allTimelineData) {
|
||||||
|
LocalDateTime key = normalizeTimestamp(data.getTimestamp(), interval);
|
||||||
|
aggregatedData.computeIfAbsent(key, k -> TimelineDataPoint.builder()
|
||||||
|
.timestamp(k)
|
||||||
|
.participants(0)
|
||||||
|
.views(0)
|
||||||
|
.engagement(0)
|
||||||
|
.conversions(0)
|
||||||
|
.build());
|
||||||
|
|
||||||
|
TimelineDataPoint point = aggregatedData.get(key);
|
||||||
|
point.setParticipants(point.getParticipants() + data.getParticipants());
|
||||||
|
point.setViews(point.getViews() + data.getViews());
|
||||||
|
point.setEngagement(point.getEngagement() + data.getEngagement());
|
||||||
|
point.setConversions(point.getConversions() + data.getConversions());
|
||||||
|
}
|
||||||
|
|
||||||
|
List<TimelineDataPoint> dataPoints = new ArrayList<>(aggregatedData.values());
|
||||||
|
|
||||||
|
TrendAnalysis trend = analyzeTrend(dataPoints);
|
||||||
|
PeakTimeInfo peakTime = findPeakTime(dataPoints);
|
||||||
|
|
||||||
|
return UserTimelineAnalyticsResponse.builder()
|
||||||
|
.userId(userId)
|
||||||
|
.period(buildPeriodInfo(startDate, endDate))
|
||||||
|
.totalEvents(allEvents.size())
|
||||||
|
.interval(interval != null ? interval : "daily")
|
||||||
|
.dataPoints(dataPoints)
|
||||||
|
.trend(trend)
|
||||||
|
.peakTime(peakTime)
|
||||||
|
.lastUpdatedAt(LocalDateTime.now())
|
||||||
|
.dataSource("cached")
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private LocalDateTime normalizeTimestamp(LocalDateTime timestamp, String interval) {
|
||||||
|
switch (interval != null ? interval.toLowerCase() : "daily") {
|
||||||
|
case "hourly":
|
||||||
|
return timestamp.truncatedTo(ChronoUnit.HOURS);
|
||||||
|
case "weekly":
|
||||||
|
return timestamp.truncatedTo(ChronoUnit.DAYS).minusDays(timestamp.getDayOfWeek().getValue() - 1);
|
||||||
|
case "monthly":
|
||||||
|
return timestamp.withDayOfMonth(1).truncatedTo(ChronoUnit.DAYS);
|
||||||
|
case "daily":
|
||||||
|
default:
|
||||||
|
return timestamp.truncatedTo(ChronoUnit.DAYS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private TrendAnalysis analyzeTrend(List<TimelineDataPoint> dataPoints) {
|
||||||
|
if (dataPoints.size() < 2) {
|
||||||
|
return TrendAnalysis.builder().overallTrend("stable").build();
|
||||||
|
}
|
||||||
|
|
||||||
|
int firstHalf = dataPoints.subList(0, dataPoints.size() / 2).stream()
|
||||||
|
.mapToInt(TimelineDataPoint::getParticipants).sum();
|
||||||
|
int secondHalf = dataPoints.subList(dataPoints.size() / 2, dataPoints.size()).stream()
|
||||||
|
.mapToInt(TimelineDataPoint::getParticipants).sum();
|
||||||
|
|
||||||
|
double growthRate = firstHalf > 0 ? ((double) (secondHalf - firstHalf) / firstHalf) * 100 : 0.0;
|
||||||
|
String trend = growthRate > 5 ? "increasing" : (growthRate < -5 ? "decreasing" : "stable");
|
||||||
|
|
||||||
|
return TrendAnalysis.builder()
|
||||||
|
.overallTrend(trend)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private PeakTimeInfo findPeakTime(List<TimelineDataPoint> dataPoints) {
|
||||||
|
if (dataPoints.isEmpty()) {
|
||||||
|
return PeakTimeInfo.builder().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
TimelineDataPoint peak = dataPoints.stream()
|
||||||
|
.max(Comparator.comparingInt(TimelineDataPoint::getParticipants))
|
||||||
|
.orElse(null);
|
||||||
|
|
||||||
|
return peak != null ? PeakTimeInfo.builder()
|
||||||
|
.timestamp(peak.getTimestamp())
|
||||||
|
.metric("participants")
|
||||||
|
.value(peak.getParticipants())
|
||||||
|
.description(peak.getViews() + " views at peak time")
|
||||||
|
.build() : PeakTimeInfo.builder().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private PeriodInfo buildPeriodInfo(LocalDateTime startDate, LocalDateTime endDate) {
|
||||||
|
LocalDateTime start = startDate != null ? startDate : LocalDateTime.now().minusDays(30);
|
||||||
|
LocalDateTime end = endDate != null ? endDate : LocalDateTime.now();
|
||||||
|
return PeriodInfo.builder()
|
||||||
|
.startDate(start)
|
||||||
|
.endDate(end)
|
||||||
|
.durationDays((int) ChronoUnit.DAYS.between(start, end))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
494
analytics-service/test-backend.md
Normal file
494
analytics-service/test-backend.md
Normal file
@ -0,0 +1,494 @@
|
|||||||
|
# Analytics Service 백엔드 테스트 결과서
|
||||||
|
|
||||||
|
## 1. 개요
|
||||||
|
|
||||||
|
### 1.1 테스트 목적
|
||||||
|
- **userId 기반 통합 성과 분석 API 개발 및 검증**
|
||||||
|
- 사용자 전체 이벤트를 통합하여 분석하는 4개 API 개발
|
||||||
|
- 기존 eventId 기반 API와 독립적으로 동작하는 구조 검증
|
||||||
|
- MVP 환경: 1:1 관계 (1 user = 1 store)
|
||||||
|
|
||||||
|
### 1.2 테스트 환경
|
||||||
|
- **프로젝트**: kt-event-marketing
|
||||||
|
- **서비스**: analytics-service
|
||||||
|
- **브랜치**: feature/analytics
|
||||||
|
- **빌드 도구**: Gradle 8.10
|
||||||
|
- **프레임워크**: Spring Boot 3.3.0
|
||||||
|
- **언어**: Java 21
|
||||||
|
|
||||||
|
### 1.3 테스트 일시
|
||||||
|
- **작성일**: 2025-10-28
|
||||||
|
- **컴파일 테스트**: 2025-10-28
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 개발 범위
|
||||||
|
|
||||||
|
### 2.1 Repository 수정
|
||||||
|
**파일**: 3개 Repository 인터페이스
|
||||||
|
|
||||||
|
#### EventStatsRepository
|
||||||
|
```java
|
||||||
|
// 추가된 메소드
|
||||||
|
List<EventStats> findAllByUserId(String userId);
|
||||||
|
```
|
||||||
|
- **목적**: 특정 사용자의 모든 이벤트 통계 조회
|
||||||
|
- **위치**: `analytics-service/src/main/java/com/kt/event/analytics/repository/EventStatsRepository.java`
|
||||||
|
|
||||||
|
#### ChannelStatsRepository
|
||||||
|
```java
|
||||||
|
// 추가된 메소드
|
||||||
|
List<ChannelStats> findByEventIdIn(List<String> eventIds);
|
||||||
|
```
|
||||||
|
- **목적**: 여러 이벤트의 채널 통계 일괄 조회
|
||||||
|
- **위치**: `analytics-service/src/main/java/com/kt/event/analytics/repository/ChannelStatsRepository.java`
|
||||||
|
|
||||||
|
#### TimelineDataRepository
|
||||||
|
```java
|
||||||
|
// 추가된 메소드
|
||||||
|
List<TimelineData> findByEventIdInOrderByTimestampAsc(List<String> eventIds);
|
||||||
|
|
||||||
|
@Query("SELECT t FROM TimelineData t WHERE t.eventId IN :eventIds " +
|
||||||
|
"AND t.timestamp BETWEEN :startDate AND :endDate " +
|
||||||
|
"ORDER BY t.timestamp ASC")
|
||||||
|
List<TimelineData> findByEventIdInAndTimestampBetween(
|
||||||
|
@Param("eventIds") List<String> eventIds,
|
||||||
|
@Param("startDate") LocalDateTime startDate,
|
||||||
|
@Param("endDate") LocalDateTime endDate
|
||||||
|
);
|
||||||
|
```
|
||||||
|
- **목적**: 여러 이벤트의 타임라인 데이터 조회
|
||||||
|
- **위치**: `analytics-service/src/main/java/com/kt/event/analytics/repository/TimelineDataRepository.java`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.2 Response DTO 작성
|
||||||
|
**파일**: 4개 Response DTO
|
||||||
|
|
||||||
|
#### UserAnalyticsDashboardResponse
|
||||||
|
- **경로**: `com.kt.event.analytics.dto.response.UserAnalyticsDashboardResponse`
|
||||||
|
- **역할**: 사용자 전체 통합 성과 대시보드 응답
|
||||||
|
- **주요 필드**:
|
||||||
|
- `userId`: 사용자 ID
|
||||||
|
- `totalEvents`: 총 이벤트 수
|
||||||
|
- `activeEvents`: 활성 이벤트 수
|
||||||
|
- `overallSummary`: 전체 성과 요약 (AnalyticsSummary)
|
||||||
|
- `channelPerformance`: 채널별 성과 (List<ChannelSummary>)
|
||||||
|
- `overallRoi`: 전체 ROI 요약 (RoiSummary)
|
||||||
|
- `eventPerformances`: 이벤트별 성과 목록 (EventPerformanceSummary)
|
||||||
|
- `period`: 조회 기간 (PeriodInfo)
|
||||||
|
|
||||||
|
#### UserChannelAnalyticsResponse
|
||||||
|
- **경로**: `com.kt.event.analytics.dto.response.UserChannelAnalyticsResponse`
|
||||||
|
- **역할**: 사용자 전체 채널별 성과 분석 응답
|
||||||
|
- **주요 필드**:
|
||||||
|
- `userId`: 사용자 ID
|
||||||
|
- `totalEvents`: 총 이벤트 수
|
||||||
|
- `channels`: 채널별 상세 분석 (List<ChannelAnalytics>)
|
||||||
|
- `comparison`: 채널 간 비교 (ChannelComparison)
|
||||||
|
- `period`: 조회 기간 (PeriodInfo)
|
||||||
|
|
||||||
|
#### UserRoiAnalyticsResponse
|
||||||
|
- **경로**: `com.kt.event.analytics.dto.response.UserRoiAnalyticsResponse`
|
||||||
|
- **역할**: 사용자 전체 ROI 상세 분석 응답
|
||||||
|
- **주요 필드**:
|
||||||
|
- `userId`: 사용자 ID
|
||||||
|
- `totalEvents`: 총 이벤트 수
|
||||||
|
- `overallInvestment`: 전체 투자 내역 (InvestmentDetails)
|
||||||
|
- `overallRevenue`: 전체 수익 내역 (RevenueDetails)
|
||||||
|
- `overallRoi`: ROI 계산 (RoiCalculation)
|
||||||
|
- `costEfficiency`: 비용 효율성 (CostEfficiency)
|
||||||
|
- `projection`: 수익 예측 (RevenueProjection)
|
||||||
|
- `eventRois`: 이벤트별 ROI (EventRoiSummary)
|
||||||
|
- `period`: 조회 기간 (PeriodInfo)
|
||||||
|
|
||||||
|
#### UserTimelineAnalyticsResponse
|
||||||
|
- **경로**: `com.kt.event.analytics.dto.response.UserTimelineAnalyticsResponse`
|
||||||
|
- **역할**: 사용자 전체 시간대별 참여 추이 분석 응답
|
||||||
|
- **주요 필드**:
|
||||||
|
- `userId`: 사용자 ID
|
||||||
|
- `totalEvents`: 총 이벤트 수
|
||||||
|
- `interval`: 시간 간격 단위 (hourly, daily, weekly, monthly)
|
||||||
|
- `dataPoints`: 시간대별 데이터 포인트 (List<TimelineDataPoint>)
|
||||||
|
- `trend`: 추세 분석 (TrendAnalysis)
|
||||||
|
- `peakTime`: 피크 시간대 정보 (PeakTimeInfo)
|
||||||
|
- `period`: 조회 기간 (PeriodInfo)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.3 Service 개발
|
||||||
|
**파일**: 4개 Service 클래스
|
||||||
|
|
||||||
|
#### UserAnalyticsService
|
||||||
|
- **경로**: `com.kt.event.analytics.service.UserAnalyticsService`
|
||||||
|
- **역할**: 사용자 전체 이벤트 통합 성과 대시보드 서비스
|
||||||
|
- **주요 기능**:
|
||||||
|
- `getUserDashboardData()`: 사용자 전체 대시보드 데이터 조회
|
||||||
|
- Redis 캐싱 (TTL: 30분)
|
||||||
|
- 전체 성과 요약 계산 (참여자, 조회수, 참여율, 전환율)
|
||||||
|
- 채널별 성과 통합 집계
|
||||||
|
- 전체 ROI 계산
|
||||||
|
- 이벤트별 성과 목록 생성
|
||||||
|
- **특징**:
|
||||||
|
- 모든 이벤트의 메트릭을 합산하여 통합 분석
|
||||||
|
- 채널명 기준으로 그룹화하여 채널 성과 집계
|
||||||
|
- BigDecimal 타입으로 금액 정확도 보장
|
||||||
|
|
||||||
|
#### UserChannelAnalyticsService
|
||||||
|
- **경로**: `com.kt.event.analytics.service.UserChannelAnalyticsService`
|
||||||
|
- **역할**: 사용자 전체 이벤트의 채널별 성과 통합 서비스
|
||||||
|
- **주요 기능**:
|
||||||
|
- `getUserChannelAnalytics()`: 사용자 전체 채널 분석 데이터 조회
|
||||||
|
- Redis 캐싱 (TTL: 30분)
|
||||||
|
- 채널별 메트릭 집계 (조회수, 참여자, 클릭, 전환)
|
||||||
|
- 채널 성과 지표 계산 (참여율, 전환율, CTR, ROI)
|
||||||
|
- 채널 비용 분석 (조회당/클릭당/획득당 비용)
|
||||||
|
- 채널 간 비교 분석 (최고 성과, 평균 지표)
|
||||||
|
- **특징**:
|
||||||
|
- 채널명 기준으로 그룹화하여 통합 집계
|
||||||
|
- 다양한 정렬 옵션 지원 (participants, views, engagement_rate, conversion_rate, roi)
|
||||||
|
- 채널 필터링 기능
|
||||||
|
|
||||||
|
#### UserRoiAnalyticsService
|
||||||
|
- **경로**: `com.kt.event.analytics.service.UserRoiAnalyticsService`
|
||||||
|
- **역할**: 사용자 전체 이벤트의 ROI 통합 분석 서비스
|
||||||
|
- **주요 기능**:
|
||||||
|
- `getUserRoiAnalytics()`: 사용자 전체 ROI 분석 데이터 조회
|
||||||
|
- Redis 캐싱 (TTL: 30분)
|
||||||
|
- 전체 투자 금액 집계 (콘텐츠 제작, 운영, 배포 비용)
|
||||||
|
- 전체 수익 집계 (직접 판매, 예상 판매)
|
||||||
|
- ROI 계산 (순이익, ROI %)
|
||||||
|
- 비용 효율성 분석 (참여자당 비용/수익)
|
||||||
|
- 수익 예측 (현재 수익 기반 최종 수익 예측)
|
||||||
|
- **특징**:
|
||||||
|
- BigDecimal로 금액 정밀 계산
|
||||||
|
- 이벤트별 ROI 순위 제공
|
||||||
|
- 선택적 수익 예측 기능
|
||||||
|
|
||||||
|
#### UserTimelineAnalyticsService
|
||||||
|
- **경로**: `com.kt.event.analytics.service.UserTimelineAnalyticsService`
|
||||||
|
- **역할**: 사용자 전체 이벤트의 시간대별 추이 통합 서비스
|
||||||
|
- **주요 기능**:
|
||||||
|
- `getUserTimelineAnalytics()`: 사용자 전체 타임라인 분석 데이터 조회
|
||||||
|
- Redis 캐싱 (TTL: 30분)
|
||||||
|
- 시간 간격별 데이터 집계 (hourly, daily, weekly, monthly)
|
||||||
|
- 추세 분석 (증가/감소/안정)
|
||||||
|
- 피크 시간대 식별 (최대 참여자 시점)
|
||||||
|
- **특징**:
|
||||||
|
- 시간대별로 정규화하여 데이터 집계
|
||||||
|
- 전반부/후반부 비교를 통한 성장률 계산
|
||||||
|
- 메트릭별 필터링 지원
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.4 Controller 개발
|
||||||
|
**파일**: 4개 Controller 클래스
|
||||||
|
|
||||||
|
#### UserAnalyticsDashboardController
|
||||||
|
- **경로**: `com.kt.event.analytics.controller.UserAnalyticsDashboardController`
|
||||||
|
- **엔드포인트**: `GET /api/v1/users/{userId}/analytics`
|
||||||
|
- **역할**: 사용자 전체 성과 대시보드 API
|
||||||
|
- **Request Parameters**:
|
||||||
|
- `userId` (Path): 사용자 ID (필수)
|
||||||
|
- `startDate` (Query): 조회 시작 날짜 (선택, ISO 8601 format)
|
||||||
|
- `endDate` (Query): 조회 종료 날짜 (선택, ISO 8601 format)
|
||||||
|
- `refresh` (Query): 캐시 갱신 여부 (선택, default: false)
|
||||||
|
- **Response**: `ApiResponse<UserAnalyticsDashboardResponse>`
|
||||||
|
|
||||||
|
#### UserChannelAnalyticsController
|
||||||
|
- **경로**: `com.kt.event.analytics.controller.UserChannelAnalyticsController`
|
||||||
|
- **엔드포인트**: `GET /api/v1/users/{userId}/analytics/channels`
|
||||||
|
- **역할**: 사용자 전체 채널별 성과 분석 API
|
||||||
|
- **Request Parameters**:
|
||||||
|
- `userId` (Path): 사용자 ID (필수)
|
||||||
|
- `channels` (Query): 조회할 채널 목록 (쉼표 구분, 선택)
|
||||||
|
- `sortBy` (Query): 정렬 기준 (선택, default: participants)
|
||||||
|
- `order` (Query): 정렬 순서 (선택, default: desc)
|
||||||
|
- `startDate` (Query): 조회 시작 날짜 (선택)
|
||||||
|
- `endDate` (Query): 조회 종료 날짜 (선택)
|
||||||
|
- `refresh` (Query): 캐시 갱신 여부 (선택, default: false)
|
||||||
|
- **Response**: `ApiResponse<UserChannelAnalyticsResponse>`
|
||||||
|
|
||||||
|
#### UserRoiAnalyticsController
|
||||||
|
- **경로**: `com.kt.event.analytics.controller.UserRoiAnalyticsController`
|
||||||
|
- **엔드포인트**: `GET /api/v1/users/{userId}/analytics/roi`
|
||||||
|
- **역할**: 사용자 전체 ROI 상세 분석 API
|
||||||
|
- **Request Parameters**:
|
||||||
|
- `userId` (Path): 사용자 ID (필수)
|
||||||
|
- `includeProjection` (Query): 예상 수익 포함 여부 (선택, default: true)
|
||||||
|
- `startDate` (Query): 조회 시작 날짜 (선택)
|
||||||
|
- `endDate` (Query): 조회 종료 날짜 (선택)
|
||||||
|
- `refresh` (Query): 캐시 갱신 여부 (선택, default: false)
|
||||||
|
- **Response**: `ApiResponse<UserRoiAnalyticsResponse>`
|
||||||
|
|
||||||
|
#### UserTimelineAnalyticsController
|
||||||
|
- **경로**: `com.kt.event.analytics.controller.UserTimelineAnalyticsController`
|
||||||
|
- **엔드포인트**: `GET /api/v1/users/{userId}/analytics/timeline`
|
||||||
|
- **역할**: 사용자 전체 시간대별 참여 추이 분석 API
|
||||||
|
- **Request Parameters**:
|
||||||
|
- `userId` (Path): 사용자 ID (필수)
|
||||||
|
- `interval` (Query): 시간 간격 단위 (선택, default: daily)
|
||||||
|
- 값: hourly, daily, weekly, monthly
|
||||||
|
- `startDate` (Query): 조회 시작 날짜 (선택)
|
||||||
|
- `endDate` (Query): 조회 종료 날짜 (선택)
|
||||||
|
- `metrics` (Query): 조회할 지표 목록 (쉼표 구분, 선택)
|
||||||
|
- `refresh` (Query): 캐시 갱신 여부 (선택, default: false)
|
||||||
|
- **Response**: `ApiResponse<UserTimelineAnalyticsResponse>`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 컴파일 테스트
|
||||||
|
|
||||||
|
### 3.1 테스트 명령
|
||||||
|
```bash
|
||||||
|
./gradlew.bat analytics-service:compileJava
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 테스트 결과
|
||||||
|
**상태**: ✅ **성공 (BUILD SUCCESSFUL)**
|
||||||
|
|
||||||
|
**출력**:
|
||||||
|
```
|
||||||
|
> Task :common:generateEffectiveLombokConfig UP-TO-DATE
|
||||||
|
> Task :common:compileJava UP-TO-DATE
|
||||||
|
> Task :analytics-service:generateEffectiveLombokConfig
|
||||||
|
> Task :analytics-service:compileJava
|
||||||
|
|
||||||
|
BUILD SUCCESSFUL in 8s
|
||||||
|
4 actionable tasks: 2 executed, 2 up-to-date
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 오류 해결 과정
|
||||||
|
|
||||||
|
#### 3.3.1 초기 컴파일 오류 (19개)
|
||||||
|
**문제**: 기존 DTO 구조와 Service 코드 간 필드명/타입 불일치
|
||||||
|
|
||||||
|
**해결**:
|
||||||
|
1. **AnalyticsSummary**: totalInvestment, expectedRevenue 필드 제거
|
||||||
|
2. **ChannelSummary**: cost 필드 제거
|
||||||
|
3. **RoiSummary**: BigDecimal 타입 사용
|
||||||
|
4. **InvestmentDetails**: totalAmount → total 변경, 필드명 수정 (contentCreation, operation, distribution)
|
||||||
|
5. **RevenueDetails**: totalRevenue → total 변경, 필드명 수정 (directSales, expectedSales)
|
||||||
|
6. **RoiCalculation**: totalInvestment, totalRevenue 필드 제거
|
||||||
|
7. **TrendAnalysis**: direction → overallTrend 변경
|
||||||
|
8. **PeakTimeInfo**: participants → value 변경, metric, description 추가
|
||||||
|
9. **ChannelPerformance**: participationRate 필드 제거
|
||||||
|
10. **ChannelCosts**: totalCost → distributionCost 변경, costPerParticipant → costPerAcquisition 변경
|
||||||
|
11. **ChannelComparison**: mostEfficient, highestEngagement → averageMetrics로 통합
|
||||||
|
12. **RevenueProjection**: projectedRevenue → projectedFinalRevenue 변경, basedOn 필드 추가
|
||||||
|
|
||||||
|
#### 3.3.2 수정된 파일
|
||||||
|
- `UserAnalyticsService.java`: DTO 필드명 수정 (5곳)
|
||||||
|
- `UserChannelAnalyticsService.java`: DTO 필드명 수정, HashMap import 추가 (3곳)
|
||||||
|
- `UserRoiAnalyticsService.java`: DTO 필드명 수정, BigDecimal 타입 사용 (4곳)
|
||||||
|
- `UserTimelineAnalyticsService.java`: DTO 필드명 수정 (3곳)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. API 설계 요약
|
||||||
|
|
||||||
|
### 4.1 API 엔드포인트 구조
|
||||||
|
```
|
||||||
|
/api/v1/users/{userId}/analytics
|
||||||
|
├─ GET / # 전체 통합 대시보드
|
||||||
|
├─ GET /channels # 채널별 성과 분석
|
||||||
|
├─ GET /roi # ROI 상세 분석
|
||||||
|
└─ GET /timeline # 시간대별 참여 추이
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 기존 API와의 비교
|
||||||
|
| 구분 | 기존 API | 신규 API |
|
||||||
|
|------|----------|----------|
|
||||||
|
| **기준** | eventId (개별 이벤트) | userId (사용자 전체) |
|
||||||
|
| **범위** | 단일 이벤트 | 사용자의 모든 이벤트 통합 |
|
||||||
|
| **엔드포인트** | `/api/v1/events/{eventId}/...` | `/api/v1/users/{userId}/...` |
|
||||||
|
| **캐시 TTL** | 3600초 (60분) | 1800초 (30분) |
|
||||||
|
| **데이터 집계** | 개별 이벤트 데이터 | 여러 이벤트 합산/평균 |
|
||||||
|
|
||||||
|
### 4.3 캐싱 전략
|
||||||
|
- **캐시 키 형식**: `analytics:user:{category}:{userId}`
|
||||||
|
- **TTL**: 30분 (1800초)
|
||||||
|
- 여러 이벤트 통합으로 데이터 변동성이 높아 기존보다 짧게 설정
|
||||||
|
- **갱신 방식**: `refresh=true` 파라미터로 강제 갱신 가능
|
||||||
|
- **구현**: RedisTemplate + Jackson ObjectMapper
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 주요 기능
|
||||||
|
|
||||||
|
### 5.1 데이터 집계 로직
|
||||||
|
#### 5.1.1 통합 성과 계산
|
||||||
|
- **참여자 수**: 모든 이벤트의 totalParticipants 합산
|
||||||
|
- **조회수**: 모든 이벤트의 totalViews 합산
|
||||||
|
- **참여율**: 전체 참여자 / 전체 조회수 * 100
|
||||||
|
- **전환율**: 전체 전환 / 전체 참여자 * 100
|
||||||
|
|
||||||
|
#### 5.1.2 채널 성과 집계
|
||||||
|
- **그룹화**: 채널명(channelName) 기준
|
||||||
|
- **메트릭 합산**: views, participants, clicks, conversions
|
||||||
|
- **비용 집계**: distributionCost 합산
|
||||||
|
- **ROI 계산**: (참여자 - 비용) / 비용 * 100
|
||||||
|
|
||||||
|
#### 5.1.3 ROI 계산
|
||||||
|
- **투자 금액**: 모든 이벤트의 totalInvestment 합산
|
||||||
|
- **수익**: 모든 이벤트의 expectedRevenue 합산
|
||||||
|
- **순이익**: 수익 - 투자
|
||||||
|
- **ROI**: (순이익 / 투자) * 100
|
||||||
|
|
||||||
|
#### 5.1.4 시간대별 집계
|
||||||
|
- **정규화**: interval에 따라 timestamp 정규화
|
||||||
|
- hourly: 시간 단위로 truncate
|
||||||
|
- daily: 일 단위로 truncate
|
||||||
|
- weekly: 주 시작일로 정규화
|
||||||
|
- monthly: 월 시작일로 정규화
|
||||||
|
- **데이터 포인트 합산**: 동일 시간대의 participants, views, engagement, conversions 합산
|
||||||
|
|
||||||
|
### 5.2 추세 분석
|
||||||
|
- **전반부/후반부 비교**: 데이터 포인트를 반으로 나누어 성장률 계산
|
||||||
|
- **추세 결정**:
|
||||||
|
- 성장률 > 5%: "increasing"
|
||||||
|
- 성장률 < -5%: "decreasing"
|
||||||
|
- -5% ≤ 성장률 ≤ 5%: "stable"
|
||||||
|
|
||||||
|
### 5.3 피크 시간 식별
|
||||||
|
- **기준**: 참여자 수(participants) 최대 시점
|
||||||
|
- **정보**: timestamp, metric, value, description
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 아키텍처 특징
|
||||||
|
|
||||||
|
### 6.1 계층 구조
|
||||||
|
```
|
||||||
|
Controller
|
||||||
|
↓
|
||||||
|
Service (비즈니스 로직)
|
||||||
|
↓
|
||||||
|
Repository (데이터 접근)
|
||||||
|
↓
|
||||||
|
Entity (JPA)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 독립성 보장
|
||||||
|
- **기존 eventId 기반 API와 독립적 구조**
|
||||||
|
- **별도의 Controller, Service 클래스**
|
||||||
|
- **공통 Repository 재사용**
|
||||||
|
- **기존 DTO 구조 준수**
|
||||||
|
|
||||||
|
### 6.3 확장성
|
||||||
|
- **새로운 메트릭 추가 용이**: Service 레이어에서 계산 로직 추가
|
||||||
|
- **캐싱 전략 개별 조정 가능**: 각 Service마다 독립적인 캐시 키
|
||||||
|
- **채널/이벤트 필터링 지원**: 동적 쿼리 지원
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 검증 결과
|
||||||
|
|
||||||
|
### 7.1 컴파일 검증
|
||||||
|
- ✅ **Service 계층**: 4개 클래스 컴파일 성공
|
||||||
|
- ✅ **Controller 계층**: 4개 클래스 컴파일 성공
|
||||||
|
- ✅ **Repository 계층**: 3개 인터페이스 컴파일 성공
|
||||||
|
- ✅ **DTO 계층**: 4개 Response 클래스 컴파일 성공
|
||||||
|
|
||||||
|
### 7.2 코드 품질
|
||||||
|
- ✅ **Lombok 활용**: Builder 패턴, Data 클래스
|
||||||
|
- ✅ **로깅**: Slf4j 적용
|
||||||
|
- ✅ **트랜잭션**: @Transactional(readOnly = true)
|
||||||
|
- ✅ **예외 처리**: try-catch로 캐시 오류 대응
|
||||||
|
- ✅ **타입 안정성**: BigDecimal로 금액 처리
|
||||||
|
|
||||||
|
### 7.3 Swagger 문서화
|
||||||
|
- ✅ **@Tag**: API 그룹 정의
|
||||||
|
- ✅ **@Operation**: 엔드포인트 설명
|
||||||
|
- ✅ **@Parameter**: 파라미터 설명
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 다음 단계
|
||||||
|
|
||||||
|
### 8.1 백엔드 개발 완료 항목
|
||||||
|
- ✅ Repository 쿼리 메소드 추가
|
||||||
|
- ✅ Response DTO 작성
|
||||||
|
- ✅ Service 로직 구현
|
||||||
|
- ✅ Controller API 개발
|
||||||
|
- ✅ 컴파일 검증
|
||||||
|
|
||||||
|
### 8.2 향후 작업
|
||||||
|
1. **백엔드 서버 실행 테스트** (Phase 1 완료 후)
|
||||||
|
- 애플리케이션 실행 확인
|
||||||
|
- API 엔드포인트 접근 테스트
|
||||||
|
- Swagger UI 확인
|
||||||
|
|
||||||
|
2. **API 통합 테스트** (Phase 1 완료 후)
|
||||||
|
- Postman/curl로 API 호출 테스트
|
||||||
|
- 실제 데이터로 응답 검증
|
||||||
|
- 에러 핸들링 확인
|
||||||
|
|
||||||
|
3. **프론트엔드 연동** (Phase 2)
|
||||||
|
- 프론트엔드에서 4개 API 호출
|
||||||
|
- 응답 데이터 바인딩
|
||||||
|
- UI 렌더링 검증
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 결론
|
||||||
|
|
||||||
|
### 9.1 성과
|
||||||
|
- ✅ **userId 기반 통합 분석 API 4개 개발 완료**
|
||||||
|
- ✅ **컴파일 성공**
|
||||||
|
- ✅ **기존 구조와 독립적인 설계**
|
||||||
|
- ✅ **확장 가능한 아키텍처**
|
||||||
|
- ✅ **MVP 환경 1:1 관계 (1 user = 1 store) 적용**
|
||||||
|
|
||||||
|
### 9.2 특이사항
|
||||||
|
- **기존 DTO 구조 재사용**: 새로운 DTO 생성 최소화
|
||||||
|
- **BigDecimal 타입 사용**: 금액 정확도 보장
|
||||||
|
- **캐싱 전략**: Redis 캐싱으로 성능 최적화 (TTL: 30분)
|
||||||
|
|
||||||
|
### 9.3 개발 시간
|
||||||
|
- **예상 개발 기간**: 3~4일
|
||||||
|
- **실제 개발 완료**: 1일 (컴파일 테스트까지)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 첨부
|
||||||
|
|
||||||
|
### 10.1 주요 파일 목록
|
||||||
|
```
|
||||||
|
analytics-service/src/main/java/com/kt/event/analytics/
|
||||||
|
├── repository/
|
||||||
|
│ ├── EventStatsRepository.java (수정)
|
||||||
|
│ ├── ChannelStatsRepository.java (수정)
|
||||||
|
│ └── TimelineDataRepository.java (수정)
|
||||||
|
├── dto/response/
|
||||||
|
│ ├── UserAnalyticsDashboardResponse.java (신규)
|
||||||
|
│ ├── UserChannelAnalyticsResponse.java (신규)
|
||||||
|
│ ├── UserRoiAnalyticsResponse.java (신규)
|
||||||
|
│ └── UserTimelineAnalyticsResponse.java (신규)
|
||||||
|
├── service/
|
||||||
|
│ ├── UserAnalyticsService.java (신규)
|
||||||
|
│ ├── UserChannelAnalyticsService.java (신규)
|
||||||
|
│ ├── UserRoiAnalyticsService.java (신규)
|
||||||
|
│ └── UserTimelineAnalyticsService.java (신규)
|
||||||
|
└── controller/
|
||||||
|
├── UserAnalyticsDashboardController.java (신규)
|
||||||
|
├── UserChannelAnalyticsController.java (신규)
|
||||||
|
├── UserRoiAnalyticsController.java (신규)
|
||||||
|
└── UserTimelineAnalyticsController.java (신규)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.2 API 목록
|
||||||
|
| No | HTTP Method | Endpoint | 설명 |
|
||||||
|
|----|-------------|----------|------|
|
||||||
|
| 1 | GET | `/api/v1/users/{userId}/analytics` | 사용자 전체 성과 대시보드 |
|
||||||
|
| 2 | GET | `/api/v1/users/{userId}/analytics/channels` | 사용자 전체 채널별 성과 분석 |
|
||||||
|
| 3 | GET | `/api/v1/users/{userId}/analytics/roi` | 사용자 전체 ROI 상세 분석 |
|
||||||
|
| 4 | GET | `/api/v1/users/{userId}/analytics/timeline` | 사용자 전체 시간대별 참여 추이 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**작성자**: AI Backend Developer
|
||||||
|
**검토자**: -
|
||||||
|
**승인자**: -
|
||||||
|
**버전**: 1.0
|
||||||
|
**최종 수정일**: 2025-10-28
|
||||||
Loading…
x
Reference in New Issue
Block a user