Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.whereyouad.WhereYouAd.domains.timeline.domain.service;

public interface TimelineAsyncService {
void summarizeAsync(Long timelineId, Long orgId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package com.whereyouad.WhereYouAd.domains.timeline.domain.service;

import com.whereyouad.WhereYouAd.domains.advertisement.domain.constant.Grain;
import com.whereyouad.WhereYouAd.domains.advertisement.persistence.entity.MetricFact;
import com.whereyouad.WhereYouAd.domains.advertisement.persistence.repository.MetricFactRepository;
import com.whereyouad.WhereYouAd.domains.timeline.exception.TimelineException;
import com.whereyouad.WhereYouAd.domains.timeline.exception.code.TimelineErrorCode;
import com.whereyouad.WhereYouAd.domains.timeline.persistence.entity.Timeline;
import com.whereyouad.WhereYouAd.domains.timeline.persistence.repository.TimelineRepository;
import com.whereyouad.WhereYouAd.infrastructure.client.openai.service.OpenApiService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Slf4j
@Service
@RequiredArgsConstructor
public class TimelineAsyncServiceImpl implements TimelineAsyncService {

private final TimelineRepository timelineRepository;
private final MetricFactRepository metricFactRepository;
private final OpenApiService openApiService;

@Override
@Async
@Transactional
public void summarizeAsync(Long timelineId, Long orgId) {
Timeline timeline = timelineRepository.findById(timelineId)
.orElseThrow(() -> new TimelineException(TimelineErrorCode.TIMELINE_NOT_FOUND));

// 분석 기간 Metric_fact 데이터 불러오기
List<MetricFact> facts = metricFactRepository.findByOrgAndPeriodAndGrain(
orgId,
timeline.getStartDate().atStartOfDay(),
timeline.getEndDate().plusDays(1).atStartOfDay(),
Grain.DAILY
);

// 비교 기간 Metric_fact 데이터 불러오기
List<MetricFact> comparisonFacts = metricFactRepository.findByOrgAndPeriodAndGrain(
orgId,
timeline.getComparisonStartDate().atStartOfDay(),
timeline.getComparisonEndDate().plusDays(1).atStartOfDay(),
Grain.DAILY
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

try {
// 타임라인 AI요약 생성 요청
String summary = openApiService.generateTimelineSummary(timeline, facts, comparisonFacts);
timeline.updateSummary(summary);
} catch (Exception e) {
log.error("[Timeline AI 요약 실패] timelineId={}, error={}", timelineId, e.getMessage());
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,5 @@ public interface TimelineService {
void deleteTimeline(Long userId, Long orgId, Long timelineId);
List<TimelineResponse.TimelineSummaryDTO> getTimelines(Long userId, Long orgId);
TimelineResponse.TimelineDetailDTO getTimelineDetail(Long userId, Long orgId, Long timelineId);
void requestTimelineSummary(Long userId, Long orgId, Long timelineId);
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
package com.whereyouad.WhereYouAd.domains.timeline.domain.service;

import com.whereyouad.WhereYouAd.domains.advertisement.persistence.repository.projection.MetricSumProjection;
import com.whereyouad.WhereYouAd.domains.organization.domain.constant.OrgRole;
import com.whereyouad.WhereYouAd.domains.organization.domain.constant.OrgStatus;
import com.whereyouad.WhereYouAd.domains.organization.exception.code.OrgErrorCode;
import com.whereyouad.WhereYouAd.domains.organization.exception.handler.OrgHandler;
import com.whereyouad.WhereYouAd.domains.organization.persistence.entity.OrgMember;
import com.whereyouad.WhereYouAd.domains.organization.persistence.entity.Organization;
import com.whereyouad.WhereYouAd.domains.organization.persistence.repository.OrgMemberRepository;
import com.whereyouad.WhereYouAd.domains.organization.persistence.repository.OrgRepository;
Expand All @@ -28,12 +26,13 @@
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;

import java.time.DayOfWeek;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.LocalTime;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
Expand All @@ -50,6 +49,7 @@ public class TimelineServiceImpl implements TimelineService {
private final OrgRepository orgRepository;
private final OrgMemberRepository orgMemberRepository;
private final TimelineUtil timelineUtil;
private final TimelineAsyncService timelineAsyncService;

@Override
public TimelineResponse.CreateResponseDTO createTimeline(Long userId, Long orgId, TimelineRequest.TimelineCreateDto dto) {
Expand Down Expand Up @@ -91,6 +91,11 @@ public TimelineResponse.CreateResponseDTO createTimeline(Long userId, Long orgId
Status.ON_GOING
);

// 현재 기간에 성과 데이터가 없거나 모두 0이면 타임라인 생성 불가
if (isProjectionEmpty(currentFacts)) {
throw new TimelineException(TimelineErrorCode.TIMELINE_NO_CURRENT_DATA);
}

// 입력받은 DTO를 타임라인 엔티티로 변환
Timeline timeline = TimelineConverter.toTimeline(dto, organization, userId, comparisonDates.start(), comparisonDates.end());

Expand All @@ -117,12 +122,9 @@ public TimelineResponse.CreateResponseDTO updateTimeline(Long userId, Long orgId
throw new TimelineException(TimelineErrorCode.TIMELINE_NOT_FOUND);
}

// 4. ADMIN 권한 검증
OrgMember member = orgMemberRepository.findByUserIdAndOrgId(userId, orgId)
// 4. 조직 멤버 검증
orgMemberRepository.findByUserIdAndOrgId(userId, orgId)
.orElseThrow(() -> new TimelineException(TimelineErrorCode.TIMELINE_UPDATE_FORBIDDEN));
if (member.getRole() != OrgRole.ADMIN) {
throw new TimelineException(TimelineErrorCode.TIMELINE_UPDATE_FORBIDDEN);
}

// 5. 날짜 검증
if (dto.endDate().isBefore(dto.startDate())) {
Expand Down Expand Up @@ -154,6 +156,11 @@ public TimelineResponse.CreateResponseDTO updateTimeline(Long userId, Long orgId
Status.ON_GOING
);

// 현재 기간에 성과 데이터가 없거나 모두 0이면 수정 불가
if (isProjectionEmpty(currentFacts)) {
throw new TimelineException(TimelineErrorCode.TIMELINE_NO_CURRENT_DATA);
}

// 8. 성과 리스트 -> boolean 플래그 변환
boolean useClick = dto.metrics().contains(MetricType.CLICK);
boolean useConversion = dto.metrics().contains(MetricType.CONVERSION);
Expand Down Expand Up @@ -188,14 +195,9 @@ public void deleteTimeline(Long userId, Long orgId, Long timelineId) {
}

// 조직 맴버가 아닌 경우
OrgMember member = orgMemberRepository.findByUserIdAndOrgId(userId, orgId)
orgMemberRepository.findByUserIdAndOrgId(userId, orgId)
.orElseThrow(() -> new TimelineException(TimelineErrorCode.TIMELINE_DELETE_FORBIDDEN));

// ADMIN 권한이 없는 경우
if (member.getRole() != OrgRole.ADMIN) {
throw new TimelineException(TimelineErrorCode.TIMELINE_DELETE_FORBIDDEN);
}

// 삭제
timelineRepository.delete(timeline);
}
Expand Down Expand Up @@ -255,6 +257,42 @@ public TimelineResponse.TimelineDetailDTO getTimelineDetail(Long userId, Long or
return TimelineConverter.toTimelineDetailDTO(timeline, metrics, dailyTrend, platformContributions);
}

@Override
public void requestTimelineSummary(Long userId, Long orgId, Long timelineId) {
orgRepository.findById(orgId)
.orElseThrow(() -> new OrgHandler(OrgErrorCode.ORG_NOT_FOUND));

orgMemberRepository.findByUserIdAndOrgId(userId, orgId)
.orElseThrow(() -> new TimelineException(TimelineErrorCode.TIMELINE_READ_FORBIDDEN));

Timeline timeline = timelineRepository.findById(timelineId)
.orElseThrow(() -> new TimelineException(TimelineErrorCode.TIMELINE_NOT_FOUND));

// 해당 조직의 타임라인이 아닌 경우
if (!timeline.getOrganization().getId().equals(orgId)) {
throw new TimelineException(TimelineErrorCode.TIMELINE_NOT_FOUND);
}

// 생성 이후 MetricFact 변동 or 광고 상태 변경을 대비한 재검증
boolean hasData = metricFactRepository.existsByTimeBucketBetweenAndOrg(
timeline.getStartDate().atStartOfDay(),
timeline.getEndDate().plusDays(1).atStartOfDay(),
orgId
);
Comment on lines +277 to +281
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

요약 요청 전 데이터 재검증 조건이 실제 요약 조회 조건과 다릅니다.

여기서는 기간+조직만 확인하고, 비동기 생성에서는 Grain.DAILY 조건으로 다시 조회합니다. 그래서 사전 검증은 통과했는데 실제 요약용 데이터는 비어 있는 케이스가 생길 수 있습니다. 동일한 조건(최소한 동일 grain)으로 맞춰주세요.

수정 방향 예시
- boolean hasData = metricFactRepository.existsByTimeBucketBetweenAndOrg(
-         timeline.getStartDate().atStartOfDay(),
-         timeline.getEndDate().plusDays(1).atStartOfDay(),
-         orgId
- );
+ boolean hasData = !metricFactRepository.findByOrgAndPeriodAndGrain(
+         orgId,
+         timeline.getStartDate().atStartOfDay(),
+         timeline.getEndDate().plusDays(1).atStartOfDay(),
+         Grain.DAILY
+ ).isEmpty();
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/com/whereyouad/WhereYouAd/domains/timeline/domain/service/TimelineServiceImpl.java`
around lines 277 - 281, 현재 사전 검증에서 hasData는
기간(timeline.getStartDate()~getEndDate())과 orgId만 검사하지만 비동기 생성에서는 Grain.DAILY로 다시
조회해 실제 요약 데이터가 없을 수 있으므로 검증 조건을 실제 요약 조회와 동일하게 맞추어야 합니다: 변경할 위치는 hasData를 만드는
식(metricFactRepository.existsByTimeBucketBetweenAndOrg(...))이며, 여기에 요약
grain(timeline.getGrain() 또는 상수 Grain.DAILY) 조건을 추가해 예를 들어
metricFactRepository.existsByTimeBucketBetweenAndOrgAndGrain(start, end, orgId,
timeline.getGrain()) 같은 저장소 메서드를 호출하도록 수정하세요; 저장소에 해당 exists 메서드가 없다면 리포지토리에 존재
여부 메서드를 추가해 동일한 grain 기준으로 검사하도록 구현하세요.

if (!hasData) {
throw new TimelineException(TimelineErrorCode.TIMELINE_NO_METRIC_DATA);
}

// 트랜잭션 훅 등록
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
// 트랜잭션이 성공적으로 커밋된 시점 이후에 호출
public void afterCommit() {
timelineAsyncService.summarizeAsync(timelineId, orgId);
}
});
}

// 선택된 지표를 리스트로 변환해주는 메서드
private List<MetricType> buildMetricList(Timeline timeline) {
List<MetricType> metrics = new ArrayList<>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ public enum TimelineErrorCode implements BaseErrorCode {
// 400
TIMELINE_INVALID_DATE_RANGE(HttpStatus.BAD_REQUEST, "TIMELINE_400_1", "종료일은 시작일보다 이후여야 합니다."),
TIMELINE_NO_COMPARISON_DATA(HttpStatus.BAD_REQUEST, "TIMELINE_400_2", "비교 기간에 해당하는 성과 데이터가 존재하지 않습니다."),
TIMELINE_NO_METRIC_DATA(HttpStatus.BAD_REQUEST, "TIMELINE_400_3", "해당 타임라인 기간의 광고 데이터가 없습니다."),
TIMELINE_NO_CURRENT_DATA(HttpStatus.BAD_REQUEST, "TIMELINE_400_4", "선택한 기간에 해당하는 성과 데이터가 존재하지 않습니다."),

// 403
TIMELINE_DELETE_FORBIDDEN(HttpStatus.FORBIDDEN, "TIMELINE_403_1", "타임라인을 삭제할 권한이 없습니다."),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,4 +68,14 @@ public ResponseEntity<DataResponse<Void>> deleteTimeline(
timelineService.deleteTimeline(userId, orgId, timelineId);
return ResponseEntity.ok(DataResponse.ok());
}

@PostMapping("/{timelineId}/summary")
public ResponseEntity<DataResponse<Void>> requestTimelineSummary(
@AuthenticationPrincipal(expression = "userId") Long userId,
@PathVariable Long orgId,
@PathVariable Long timelineId
) {
timelineService.requestTimelineSummary(userId, orgId, timelineId);
return ResponseEntity.status(HttpStatus.ACCEPTED).body(DataResponse.ok());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -94,4 +94,20 @@ ResponseEntity<DataResponse<Void>> deleteTimeline(
@PathVariable Long orgId,
@PathVariable Long timelineId
);

@Operation(
summary = "타임라인 AI 요약 요청 API",
description = "타임라인 기간의 성과 데이터를 바탕으로 AI 요약문을 생성합니다. 비동기로 처리되며, 완료 후 타임라인 상세 조회(summary 필드)에서 확인할 수 있습니다."
)
@ApiResponses({
@ApiResponse(responseCode = "202", description = "요약 요청 수락됨"),
@ApiResponse(responseCode = "400_3", description = "해당 기간의 광고 데이터가 없는 경우"),
@ApiResponse(responseCode = "403_2", description = "조직 멤버가 아닌 경우"),
@ApiResponse(responseCode = "404_1", description = "타임라인 또는 조직을 찾을 수 없는 경우")
})
ResponseEntity<DataResponse<Void>> requestTimelineSummary(
@AuthenticationPrincipal(expression = "userId") Long userId,
@PathVariable Long orgId,
@PathVariable Long timelineId
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,17 @@

import com.whereyouad.WhereYouAd.domains.advertisement.persistence.entity.AdCampaign;
import com.whereyouad.WhereYouAd.domains.advertisement.persistence.entity.MetricFact;
import com.whereyouad.WhereYouAd.domains.timeline.persistence.entity.Timeline;
import org.springframework.stereotype.Component;

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

@Component
public class PromptBuilder {
Expand Down Expand Up @@ -111,6 +115,95 @@ public String buildUserPrompt(String provider, LocalDate startDate, LocalDate en
return sb.toString();
}

public String buildTimelineSystemPrompt() {
return """
당신은 디지털 광고 성과 타임라인 분석 전문가입니다.
사용자가 제공하는 타임라인 기간의 일별 성과 데이터를 바탕으로 2~3문장의 자연스러운 한국어 요약문을 작성하세요.

반드시 지켜야 할 규칙:
- '활성화된 지표'에 명시된 지표만 분석하세요. 목록에 없는 지표는 절대 언급하지 마세요.
- 데이터 부족, 추가 수집 필요, 알 수 없음 등의 표현은 절대 사용하지 마세요. 제공된 데이터만으로 분석하세요.
- 전체적인 흐름, 특이점(최고/최저 날짜), 지표 간 변화를 포함하세요.
- JSON, 마크다운, 불릿 포인트 없이 순수 텍스트로만 응답하세요.
""";
}

public String buildTimelineUserPrompt(Timeline timeline, List<MetricFact> facts, List<MetricFact> comparisonFacts) {
StringBuilder sb = new StringBuilder();

List<String> activeMetricNames = new ArrayList<>();
if (timeline.isUseClick()) activeMetricNames.add("클릭수");
if (timeline.isUseConversion()) activeMetricNames.add("전환수");
if (timeline.isUseImpression()) activeMetricNames.add("노출수");
if (timeline.isUseRoas()) activeMetricNames.add("ROAS");

sb.append(String.format("타임라인 이름: %s\n", timeline.getName()));
sb.append(String.format("분석 기간: %s ~ %s\n", timeline.getStartDate(), timeline.getEndDate()));
sb.append(String.format("비교 기간: %s ~ %s\n", timeline.getComparisonStartDate(), timeline.getComparisonEndDate()));
if (timeline.getPerformanceStatus() != null) {
String statusLabel = switch (timeline.getPerformanceStatus()) {
case ABOVE_AVG -> "평균 이상";
case ON_TRACK -> "보통 (목표 수준 유지)";
case UNDERPERFORM -> "평균 이하";
};
sb.append(String.format("성과 상태: %s\n", statusLabel));
}
sb.append(String.format("활성화된 지표: %s\n\n", String.join(", ", activeMetricNames)));

// 분석 기간 일별 데이터
sb.append("분석 기간 일별 성과 데이터:\n");
appendDailyMetrics(sb, timeline, facts, timeline.getStartDate(), timeline.getEndDate());

// 비교 기간 일별 데이터
sb.append("\n비교 기간 일별 성과 데이터:\n");
appendDailyMetrics(sb, timeline, comparisonFacts, timeline.getComparisonStartDate(), timeline.getComparisonEndDate());

return sb.toString();
}

private void appendDailyMetrics(StringBuilder sb, Timeline timeline, List<MetricFact> facts, LocalDate from, LocalDate to) {
sb.append("Date");
if (timeline.isUseClick()) sb.append(",Clk");
if (timeline.isUseConversion()) sb.append(",Conv");
if (timeline.isUseImpression()) sb.append(",Imp");
if (timeline.isUseRoas()) sb.append(",ROAS");
sb.append("\n");

Map<LocalDate, List<MetricFact>> byDate = facts.stream()
.collect(Collectors.groupingBy(f -> f.getTimeBucket().toLocalDate()));

from.datesUntil(to.plusDays(1)).forEach(date -> {
List<MetricFact> dayFacts = byDate.getOrDefault(date, List.of());
sb.append(date);

if (timeline.isUseClick()) {
long clk = dayFacts.stream().mapToLong(f -> f.getClicks() != null ? f.getClicks() : 0L).sum();
sb.append(",").append(clk);
}
if (timeline.isUseConversion()) {
long conv = dayFacts.stream().mapToLong(f -> f.getConversions() != null ? f.getConversions() : 0L).sum();
sb.append(",").append(conv);
}
if (timeline.isUseImpression()) {
long imp = dayFacts.stream().mapToLong(f -> f.getImpressions() != null ? f.getImpressions() : 0L).sum();
sb.append(",").append(imp);
}
if (timeline.isUseRoas()) {
BigDecimal spend = dayFacts.stream()
.map(f -> f.getSpend() != null ? f.getSpend() : BigDecimal.ZERO)
.reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal revenue = dayFacts.stream()
.map(f -> f.getRevenue() != null ? f.getRevenue() : BigDecimal.ZERO)
.reduce(BigDecimal.ZERO, BigDecimal::add);
String roasStr = spend.compareTo(BigDecimal.ZERO) > 0
? revenue.divide(spend, 2, RoundingMode.HALF_UP).toPlainString()
: "0";
sb.append(",").append(roasStr);
}
sb.append("\n");
});
}

// 캠페인별 예산 집계용 클래스
private static class CampaignBudgetAccumulator {
final Long campaignId;
Expand Down
Loading
Loading