Skip to content

Commit 77edb0b

Browse files
authored
feat(report): 리포트 생성 로그 기록 추가 (#180)
* feat(report): 리포트 생성 로그 테이블과 엔티티 추가 * feat(report): 리포트 생성 로그 Repository와 Service 추가 리포트 생성 로그를 조회·저장할 Repository를 추가하고, 생성 시작/성공/실패 상태를 별도 트랜잭션으로 기록하는 Service를 추가함. BusinessException 기반 에러 코드는 로그의 errorCode와 httpStatus로 변환해 저장 * feat(report): 리포트 생성 로그에 AI 외부 오류 정보 반영 AI 예외에 원인 예외와 외부 HTTP 상태 및 외부 에러 코드를 보존하도록 확장함. 리포트 생성 로그에서 raw error_message 컬럼과 저장 로직을 제거해 민감정보 저장 가능성을 줄임. * feat(report): 일일 리포트 생성에 로그 기록 연결 일일 리포트 LLM 생성 단계에 로그 기록 연결, 입력·응답 문자 수 컬럼 제거로 로그 지표 단순화 * feat(report): 주간·월간 리포트 생성에 로그 기록 연결 WeeklyReportGenerationListener와 MonthlyReportGenerationListener의 LLM 생성 및 confirm 단계에 report_generation_logs 기록 추가, MonthlyReportGenerationListenerV2의 텍스트 생성·텍스트 confirm·이미지 생성·최종 confirm 단계별 로그 기록 추가 * feat(report): Type 리포트 생성 단계별로 로그 기록 연결 TypeReportGenerationListener의 evidence cards, pattern extraction, type selection, content generation, confirm 단계에 ReportGenerationLogRecorder 기록 추가 * feat(ai): LLM 외부 오류 상태 기록 지원 LlmExceptionMapper 추가로 WebClientResponseException, HttpStatusCodeException, RestClientResponseException의 HTTP status를 AiServiceUnavailableException에 보존하도록 처리, 리포트 LLM client 호출 실패 시 externalHttpStatus와 externalErrorCode를 report_generation_logs에 전달 가능하도록 정리 * test(report): 리포트 생성 로그 테스트 추가 ReportGenerationLogRepository 조회 테스트와 ReportGenerationLogService 상태 변경 테스트 추가, AiServiceUnavailableException의 externalHttpStatus와 externalErrorCode 반영 검증 추가 * refactor(report): 리포트 생성 로그 metadata 제거 사용하지 않는 report_generation_logs.metadata 컬럼과 ReportGenerationLog metadata 필드 제거, ReportGenerationLogService와 ReportGenerationLogRecorder의 start 시그니처 및 리포트 생성 로그 호출부 정리 * refactor(db): 리포트 생성 로그 인덱스 정리 report_generation_logs 테이블에서 사용 가능성이 낮은 user_id, status, error_code 기준 인덱스 제거, report_type과 report_id 기준 조회 인덱스만 유지
1 parent 76f663a commit 77edb0b

28 files changed

Lines changed: 1063 additions & 122 deletions

src/main/java/com/devkor/ifive/nadab/domain/dailyreport/application/DailyReportService.java

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@
1616
import com.devkor.ifive.nadab.domain.question.core.entity.UserDailyQuestion;
1717
import com.devkor.ifive.nadab.domain.question.core.repository.DailyQuestionRepository;
1818
import com.devkor.ifive.nadab.domain.question.core.repository.UserDailyQuestionRepository;
19+
import com.devkor.ifive.nadab.domain.reportlog.application.ReportGenerationLogRecorder;
20+
import com.devkor.ifive.nadab.domain.reportlog.core.entity.ReportGenerationStep;
21+
import com.devkor.ifive.nadab.domain.reportlog.core.entity.ReportGenerationType;
1922
import com.devkor.ifive.nadab.domain.user.core.entity.InterestCode;
2023
import com.devkor.ifive.nadab.domain.user.core.entity.User;
2124
import com.devkor.ifive.nadab.domain.user.core.repository.UserRepository;
@@ -25,6 +28,7 @@
2528
import com.devkor.ifive.nadab.global.exception.BadRequestException;
2629
import com.devkor.ifive.nadab.global.exception.NotFoundException;
2730

31+
import com.devkor.ifive.nadab.global.infra.llm.LlmProvider;
2832
import com.devkor.ifive.nadab.global.shared.util.TodayDateTimeProvider;
2933
import lombok.RequiredArgsConstructor;
3034
import org.springframework.beans.factory.annotation.Value;
@@ -38,6 +42,8 @@
3842
@RequiredArgsConstructor
3943
public class DailyReportService {
4044

45+
private static final String DAILY_REPORT_LLM_MODEL = "GPT_4_O_MINI";
46+
4147
private final UserRepository userRepository;
4248
private final DailyQuestionRepository dailyQuestionRepository;
4349
private final UserDailyQuestionRepository userDailyQuestionRepository;
@@ -46,6 +52,7 @@ public class DailyReportService {
4652
private final ProfileImageService profileImageService;
4753

4854
private final DailyReportLlmClient dailyReportLlmClient;
55+
private final ReportGenerationLogRecorder reportGenerationLogRecorder;
4956

5057
private final ApplicationEventPublisher eventPublisher;
5158

@@ -86,11 +93,21 @@ public CreateDailyReportResponse generateDailyReport(Long userId, DailyReportReq
8693
PrepareDailyResultDto prep = dailyReportTxService.prepareDaily(user, question, request.answer(), isDayPassed, request.objectKey());
8794

8895
AnswerEntry answerEntry = prep.entry();
96+
Long generationLogId = reportGenerationLogRecorder.start(
97+
userId,
98+
ReportGenerationType.DAILY,
99+
prep.reportId(),
100+
ReportGenerationStep.DAILY_GENERATE,
101+
LlmProvider.OPENAI,
102+
DAILY_REPORT_LLM_MODEL
103+
);
89104

90105
AiDailyReportResultDto dto;
91106
try {
92107
dto = dailyReportLlmClient.generate(question.getQuestionText(), answerEntry);
108+
reportGenerationLogRecorder.succeed(generationLogId);
93109
} catch (Exception e) {
110+
reportGenerationLogRecorder.fail(generationLogId, e);
94111
dailyReportTxService.failDaily(prep.reportId());
95112
throw e;
96113
}
@@ -174,4 +191,5 @@ private void validateWebpKey(String key, Long userId) {
174191
private boolean isBlank(String s) {
175192
return s == null || s.trim().isEmpty();
176193
}
194+
177195
}

src/main/java/com/devkor/ifive/nadab/domain/dailyreport/infra/DailyReportLlmClient.java

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import com.devkor.ifive.nadab.global.exception.BadRequestException;
1010
import com.devkor.ifive.nadab.global.exception.ai.AiResponseParseException;
1111
import com.devkor.ifive.nadab.global.exception.ai.AiServiceUnavailableException;
12+
import com.devkor.ifive.nadab.global.infra.llm.LlmExceptionMapper;
1213
import com.devkor.ifive.nadab.global.infra.llm.LlmProvider;
1314
import com.devkor.ifive.nadab.global.infra.llm.LlmRouter;
1415
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -57,11 +58,16 @@ public AiDailyReportResultDto generate(String question, AnswerEntry answerEntry)
5758

5859
UserMessage userMessage = buildUserMessage(prompt, withImagePrompt,answerEntry);
5960

60-
String content = chatClient.prompt()
61-
.messages(userMessage)
62-
.options(options)
63-
.call()
64-
.content();
61+
String content;
62+
try {
63+
content = chatClient.prompt()
64+
.messages(userMessage)
65+
.options(options)
66+
.call()
67+
.content();
68+
} catch (Exception e) {
69+
throw LlmExceptionMapper.toUnavailable(ErrorCode.AI_NO_RESPONSE, e);
70+
}
6571

6672
if (content == null || content.trim().isEmpty()) {
6773
throw new AiServiceUnavailableException(ErrorCode.AI_NO_RESPONSE);

src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/listener/MonthlyReportGenerationListener.java

Lines changed: 33 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,12 @@
77
import com.devkor.ifive.nadab.domain.monthlyreport.core.repository.MonthlyQueryRepository;
88
import com.devkor.ifive.nadab.domain.monthlyreport.core.service.MonthlyWeeklySummariesService;
99
import com.devkor.ifive.nadab.domain.monthlyreport.infra.MonthlyReportLlmClient;
10+
import com.devkor.ifive.nadab.domain.reportlog.application.ReportGenerationLogRecorder;
11+
import com.devkor.ifive.nadab.domain.reportlog.core.entity.ReportGenerationStep;
12+
import com.devkor.ifive.nadab.domain.reportlog.core.entity.ReportGenerationType;
1013
import com.devkor.ifive.nadab.domain.weeklyreport.application.helper.WeeklyEntriesAssembler;
1114
import com.devkor.ifive.nadab.domain.weeklyreport.core.dto.DailyEntryDto;
15+
import com.devkor.ifive.nadab.global.infra.llm.LlmProvider;
1216
import com.devkor.ifive.nadab.global.shared.reportcontent.AiReportResultDto;
1317
import com.devkor.ifive.nadab.global.shared.util.MonthRangeCalculator;
1418
import com.devkor.ifive.nadab.global.shared.util.dto.MonthRangeDto;
@@ -27,18 +31,18 @@
2731
@Slf4j
2832
public class MonthlyReportGenerationListener {
2933

34+
private static final String MONTHLY_REPORT_LLM_MODEL = "GEMINI_2_5_FLASH";
35+
3036
private final MonthlyQueryRepository monthlyQueryRepository;
3137

3238
private final MonthlyReportLlmClient monthlyReportLlmClient;
3339
private final MonthlyReportTxService monthlyReportTxService;
3440
private final MonthlyWeeklySummariesService monthlyWeeklySummariesService;
41+
private final ReportGenerationLogRecorder reportGenerationLogRecorder;
3542
private final ApplicationEventPublisher eventPublisher;
3643

37-
private static final int MAX_LEN = 245;
38-
3944
@Async("monthlyReportTaskExecutor")
40-
@TransactionalEventListener(phase =
41-
TransactionPhase.AFTER_COMMIT)
45+
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
4246
public void handle(MonthlyReportGenerationRequestedEventDto event) {
4347

4448
MonthRangeDto range = MonthRangeCalculator.getLastMonthRange();
@@ -52,11 +56,25 @@ public void handle(MonthlyReportGenerationRequestedEventDto event) {
5256
String weeklySummaries = monthlyWeeklySummariesService.buildWeeklySummaries(event.userId(), range);
5357

5458
AiReportResultDto dto;
59+
Long generationLogId = reportGenerationLogRecorder.start(
60+
event.userId(),
61+
ReportGenerationType.MONTHLY,
62+
event.reportId(),
63+
ReportGenerationStep.MONTHLY_GENERATE,
64+
LlmProvider.GEMINI,
65+
MONTHLY_REPORT_LLM_MODEL
66+
);
5567
try {
5668
// 트랜잭션 밖(백그라운드)에서 LLM 호출
5769
dto = monthlyReportLlmClient.generate(
58-
range.monthStartDate().toString(), range.monthEndDate().toString(), weeklySummaries, representativeEntries);
70+
range.monthStartDate().toString(),
71+
range.monthEndDate().toString(),
72+
weeklySummaries,
73+
representativeEntries
74+
);
75+
reportGenerationLogRecorder.succeed(generationLogId);
5976
} catch (Exception e) {
77+
reportGenerationLogRecorder.fail(generationLogId, e);
6078
log.error("[MONTHLY_REPORT][LLM_FAILED] userId={}, reportId={}",
6179
event.userId(), event.reportId(), e);
6280

@@ -69,7 +87,14 @@ public void handle(MonthlyReportGenerationRequestedEventDto event) {
6987
return;
7088
}
7189

72-
// 성공 확정(별도 트랜잭션)
90+
Long confirmLogId = reportGenerationLogRecorder.start(
91+
event.userId(),
92+
ReportGenerationType.MONTHLY,
93+
event.reportId(),
94+
ReportGenerationStep.MONTHLY_CONFIRM,
95+
null,
96+
null
97+
);
7398
try {
7499
monthlyReportTxService.confirmMonthly(
75100
event.reportId(),
@@ -81,8 +106,10 @@ public void handle(MonthlyReportGenerationRequestedEventDto event) {
81106
eventPublisher.publishEvent(
82107
new MonthlyReportCompletedEvent(event.reportId(), event.userId())
83108
);
109+
reportGenerationLogRecorder.succeed(confirmLogId);
84110

85111
} catch (Exception e) {
112+
reportGenerationLogRecorder.fail(confirmLogId, e);
86113
log.error("[MONTHLY_REPORT][CONFIRM_FAILED] userId={}, reportId={}, crystalLogId={}",
87114
event.userId(), event.reportId(), event.crystalLogId(), e);
88115

@@ -93,13 +120,5 @@ public void handle(MonthlyReportGenerationRequestedEventDto event) {
93120
event.crystalLogId()
94121
);
95122
}
96-
97-
}
98-
99-
// 최대 길이 자르기
100-
private String cut(String s) {
101-
if (s == null) return null;
102-
s = s.trim();
103-
return (s.length() <= MAX_LEN) ? s : s.substring(0, MAX_LEN);
104123
}
105124
}

src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/listener/MonthlyReportGenerationListenerV2.java

Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,14 @@
1313
import com.devkor.ifive.nadab.domain.monthlyreport.infra.MonthlyReportImageStorage;
1414
import com.devkor.ifive.nadab.domain.monthlyreport.infra.MonthlyReportLlmClientV2;
1515
import com.devkor.ifive.nadab.domain.monthlyreport.infra.OpenAiImageClient;
16+
import com.devkor.ifive.nadab.domain.reportlog.application.ReportGenerationLogRecorder;
17+
import com.devkor.ifive.nadab.domain.reportlog.core.entity.ReportGenerationStep;
18+
import com.devkor.ifive.nadab.domain.reportlog.core.entity.ReportGenerationType;
1619
import com.devkor.ifive.nadab.domain.typereport.application.helper.TypeEmotionStatsCalculator;
1720
import com.devkor.ifive.nadab.domain.typereport.core.content.TypeEmotionStatsContent;
1821
import com.devkor.ifive.nadab.domain.weeklyreport.application.helper.WeeklyEntriesAssembler;
1922
import com.devkor.ifive.nadab.domain.weeklyreport.core.dto.DailyEntryDto;
23+
import com.devkor.ifive.nadab.global.infra.llm.LlmProvider;
2024
import com.devkor.ifive.nadab.global.shared.util.MonthRangeCalculator;
2125
import com.devkor.ifive.nadab.global.shared.util.dto.MonthRangeDto;
2226
import lombok.RequiredArgsConstructor;
@@ -34,6 +38,8 @@
3438
@Slf4j
3539
public class MonthlyReportGenerationListenerV2 {
3640

41+
private static final String MONTHLY_REPORT_V2_LLM_MODEL = "GEMINI_2_5_FLASH";
42+
3743
private final MonthlyQueryRepository monthlyQueryRepository;
3844

3945
private final MonthlyReportLlmClientV2 monthlyReportLlmClientV2;
@@ -42,11 +48,11 @@ public class MonthlyReportGenerationListenerV2 {
4248

4349
private final MonthlyReportTxServiceV2 monthlyReportTxServiceV2;
4450
private final MonthlyWeeklySummariesService monthlyWeeklySummariesService;
51+
private final ReportGenerationLogRecorder reportGenerationLogRecorder;
4552
private final ApplicationEventPublisher eventPublisher;
4653

4754
@Async("monthlyReportTaskExecutor")
48-
@TransactionalEventListener(phase =
49-
TransactionPhase.AFTER_COMMIT)
55+
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
5056
public void handle(MonthlyReportGenerationRequestedEventDtoV2 event) {
5157

5258
MonthRangeDto range = MonthRangeCalculator.getLastMonthRange();
@@ -103,6 +109,14 @@ public void handle(MonthlyReportGenerationRequestedEventDtoV2 event) {
103109
}
104110

105111
AiMonthlyReportResultDto dto;
112+
Long generationLogId = reportGenerationLogRecorder.start(
113+
event.userId(),
114+
ReportGenerationType.MONTHLY_V2,
115+
event.reportId(),
116+
ReportGenerationStep.MONTHLY_V2_GENERATE,
117+
LlmProvider.GEMINI,
118+
MONTHLY_REPORT_V2_LLM_MODEL
119+
);
106120
try {
107121
// 트랜잭션 밖(백그라운드)에서 LLM 호출
108122
dto = monthlyReportLlmClientV2.generate(
@@ -111,8 +125,11 @@ public void handle(MonthlyReportGenerationRequestedEventDtoV2 event) {
111125
weeklySummaries,
112126
representativeEntries,
113127
emotionStats,
114-
event.exists());
128+
event.exists()
129+
);
130+
reportGenerationLogRecorder.succeed(generationLogId);
115131
} catch (Exception e) {
132+
reportGenerationLogRecorder.fail(generationLogId, e);
116133
log.error("[MONTHLY_REPORT][LLM_FAILED] userId={}, reportId={}",
117134
event.userId(), event.reportId(), e);
118135

@@ -125,7 +142,14 @@ public void handle(MonthlyReportGenerationRequestedEventDtoV2 event) {
125142
return;
126143
}
127144

128-
// 텍스트 생성 성공 확정(별도 트랜잭션)
145+
Long textConfirmLogId = reportGenerationLogRecorder.start(
146+
event.userId(),
147+
ReportGenerationType.MONTHLY_V2,
148+
event.reportId(),
149+
ReportGenerationStep.MONTHLY_V2_TEXT_CONFIRM,
150+
null,
151+
null
152+
);
129153
try {
130154
monthlyReportTxServiceV2.confirmMonthlyText(
131155
event.reportId(),
@@ -134,8 +158,10 @@ public void handle(MonthlyReportGenerationRequestedEventDtoV2 event) {
134158
emotionStats,
135159
interestStats
136160
);
161+
reportGenerationLogRecorder.succeed(textConfirmLogId);
137162

138163
} catch (Exception e) {
164+
reportGenerationLogRecorder.fail(textConfirmLogId, e);
139165
log.error("[MONTHLY_REPORT][TEXT_CONFIRM_FAILED] userId={}, reportId={}, crystalLogId={}",
140166
event.userId(), event.reportId(), event.crystalLogId(), e);
141167

@@ -148,16 +174,26 @@ public void handle(MonthlyReportGenerationRequestedEventDtoV2 event) {
148174
return;
149175
}
150176

151-
String imageKey = "";
177+
String imageKey;
178+
Long imageLogId = reportGenerationLogRecorder.start(
179+
event.userId(),
180+
ReportGenerationType.MONTHLY_V2,
181+
event.reportId(),
182+
ReportGenerationStep.MONTHLY_V2_IMAGE_GENERATE,
183+
LlmProvider.OPENAI,
184+
null
185+
);
152186
try {
153187
String base64Image = openAiImageClient.generateBase64Image(event.userId(), dto, range);
154188
imageKey = monthlyReportImageStorage.uploadBase64Webp(
155189
event.userId(),
156190
event.reportId(),
157191
base64Image
158192
);
193+
reportGenerationLogRecorder.succeed(imageLogId);
159194

160195
} catch (Exception e) {
196+
reportGenerationLogRecorder.fail(imageLogId, e);
161197
log.error("[MONTHLY_REPORT][IMAGE_FAILED] userId={}, reportId={}, crystalLogId={}",
162198
event.userId(), event.reportId(), event.crystalLogId(), e);
163199

@@ -169,6 +205,14 @@ public void handle(MonthlyReportGenerationRequestedEventDtoV2 event) {
169205
return;
170206
}
171207

208+
Long confirmLogId = reportGenerationLogRecorder.start(
209+
event.userId(),
210+
ReportGenerationType.MONTHLY_V2,
211+
event.reportId(),
212+
ReportGenerationStep.MONTHLY_V2_CONFIRM,
213+
null,
214+
null
215+
);
172216
try {
173217
monthlyReportTxServiceV2.confirmMonthly(
174218
event.reportId(),
@@ -180,8 +224,10 @@ public void handle(MonthlyReportGenerationRequestedEventDtoV2 event) {
180224
eventPublisher.publishEvent(
181225
new MonthlyReportCompletedEvent(event.reportId(), event.userId())
182226
);
227+
reportGenerationLogRecorder.succeed(confirmLogId);
183228

184229
} catch (Exception e) {
230+
reportGenerationLogRecorder.fail(confirmLogId, e);
185231
log.error("[MONTHLY_REPORT][CONFIRM_FAILED] userId={}, reportId={}, crystalLogId={}",
186232
event.userId(), event.reportId(), event.crystalLogId(), e);
187233

@@ -194,4 +240,3 @@ public void handle(MonthlyReportGenerationRequestedEventDtoV2 event) {
194240
}
195241
}
196242
}
197-

0 commit comments

Comments
 (0)