From 8b3179f2c760f80d3e622f2c9b36fffd3ec87452 Mon Sep 17 00:00:00 2001 From: 1Seob Date: Sat, 10 Jan 2026 17:05:56 +0900 Subject: [PATCH 01/18] =?UTF-8?q?feat(report):=20monthly=5Freports=20?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=B8=94=20=EC=83=9D=EC=84=B1=20=EB=B0=8F=20?= =?UTF-8?q?MonthlyReport=20=EC=97=94=ED=8B=B0=ED=8B=B0=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/entity/MonthlyReport.java | 75 +++++++++++++++++++ .../core/entity/MonthlyReportStatus.java | 8 ++ ..._1638__IS_create_monthly_reports_table.sql | 26 +++++++ 3 files changed, 109 insertions(+) create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/entity/MonthlyReport.java create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/entity/MonthlyReportStatus.java create mode 100644 src/main/resources/db/migration/V20260110_1638__IS_create_monthly_reports_table.sql diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/entity/MonthlyReport.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/entity/MonthlyReport.java new file mode 100644 index 0000000..8b19db1 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/entity/MonthlyReport.java @@ -0,0 +1,75 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.core.entity; + +import com.devkor.ifive.nadab.domain.user.core.entity.User; +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDate; +import java.time.OffsetDateTime; + +@Entity +@Table( + name = "monthly_reports", + uniqueConstraints = { + @UniqueConstraint( + name = "uq_monthly_reports_user_month", + columnNames = {"user_id", "month_start_date"} + ) + } +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MonthlyReport { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(name = "month_start_date", nullable = false) + private LocalDate monthStartDate; + + @Column(name = "month_end_date", nullable = false) + private LocalDate monthEndDate; + + @Column(name = "discovered", length = 250) + private String discovered; + + @Column(name = "good", length = 250) + private String good; + + @Column(name = "improve", length = 250) + private String improve; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 16) + private MonthlyReportStatus status; + + @Column(name = "date", nullable = false) + private LocalDate date; + + @Column(name = "analyzed_at") + private OffsetDateTime analyzedAt; + + public static MonthlyReport create(User user, LocalDate monthStartDate, LocalDate monthEndDate, + String discovered, String good, String improve, + LocalDate date, MonthlyReportStatus status) { + MonthlyReport mr = new MonthlyReport(); + mr.user = user; + mr.monthStartDate = monthStartDate; + mr.monthEndDate = monthEndDate; + mr.discovered = discovered; + mr.good = good; + mr.improve = improve; + mr.date = date; + mr.status = status; + return mr; + } + + public static MonthlyReport createPending(User user, LocalDate monthStartDate, LocalDate monthEndDate, LocalDate date) { + return create(user, monthStartDate, monthEndDate, null, null, null, date, MonthlyReportStatus.PENDING); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/entity/MonthlyReportStatus.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/entity/MonthlyReportStatus.java new file mode 100644 index 0000000..ed88e74 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/entity/MonthlyReportStatus.java @@ -0,0 +1,8 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.core.entity; + +public enum MonthlyReportStatus { + PENDING, + COMPLETED, + FAILED, + IN_PROGRESS +} diff --git a/src/main/resources/db/migration/V20260110_1638__IS_create_monthly_reports_table.sql b/src/main/resources/db/migration/V20260110_1638__IS_create_monthly_reports_table.sql new file mode 100644 index 0000000..a31f40e --- /dev/null +++ b/src/main/resources/db/migration/V20260110_1638__IS_create_monthly_reports_table.sql @@ -0,0 +1,26 @@ +CREATE TABLE monthly_reports ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL, + + month_start_date DATE NOT NULL, + month_end_date DATE NOT NULL, + + discovered VARCHAR(250), + good VARCHAR(250), + improve VARCHAR(250), + + status VARCHAR(16) NOT NULL, + date DATE NOT NULL, + analyzed_at TIMESTAMPTZ, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT fk_monthly_reports_user + FOREIGN KEY (user_id) REFERENCES users(id), + + CONSTRAINT uq_monthly_reports_user_month + UNIQUE (user_id, month_start_date) +); + +CREATE INDEX idx_monthly_reports_user_month_start + ON monthly_reports(user_id, month_start_date DESC); \ No newline at end of file From acf4f9fc9121c4df7914dfcbc37be52a109c64a2 Mon Sep 17 00:00:00 2001 From: 1Seob Date: Sat, 10 Jan 2026 17:53:56 +0900 Subject: [PATCH 02/18] =?UTF-8?q?feat:=20MonthRangeCalculator=20=EC=9C=A0?= =?UTF-8?q?=ED=8B=B8=20=ED=81=B4=EB=9E=98=EC=8A=A4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 특정 날짜가 속한 월의 범위 반환, 저번 달의 범위 반환, 이번 달의 범위 반환 --- .../shared/util/MonthRangeCalculator.java | 42 +++++++++++++++++++ .../global/shared/util/dto/MonthRangeDto.java | 9 ++++ 2 files changed, 51 insertions(+) create mode 100644 src/main/java/com/devkor/ifive/nadab/global/shared/util/MonthRangeCalculator.java create mode 100644 src/main/java/com/devkor/ifive/nadab/global/shared/util/dto/MonthRangeDto.java diff --git a/src/main/java/com/devkor/ifive/nadab/global/shared/util/MonthRangeCalculator.java b/src/main/java/com/devkor/ifive/nadab/global/shared/util/MonthRangeCalculator.java new file mode 100644 index 0000000..db7be2e --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/global/shared/util/MonthRangeCalculator.java @@ -0,0 +1,42 @@ +package com.devkor.ifive.nadab.global.shared.util; + +import com.devkor.ifive.nadab.global.shared.util.dto.MonthRangeDto; + +import java.time.LocalDate; +import java.time.YearMonth; +import java.time.ZoneId; + +public final class MonthRangeCalculator { + + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + + private MonthRangeCalculator() { + } + + /** + * 주어진 날짜가 속한 월(1일 ~ 말일) 범위를 반환합니다. + */ + public static MonthRangeDto monthRangeOf(LocalDate date) { + YearMonth ym = YearMonth.from(date); + LocalDate start = ym.atDay(1); + LocalDate end = ym.atEndOfMonth(); + return new MonthRangeDto(start, end); + } + + /** + * KST 기준 "저번 달" 범위를 반환합니다. + */ + public static MonthRangeDto getLastMonthRange() { + LocalDate today = LocalDate.now(KST); + LocalDate lastMonthDate = today.minusMonths(1); + return monthRangeOf(lastMonthDate); + } + + /** + * KST 기준 "이번 달" 범위를 반환합니다. + */ + public static MonthRangeDto getThisMonthRange() { + LocalDate today = LocalDate.now(KST); + return monthRangeOf(today); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/global/shared/util/dto/MonthRangeDto.java b/src/main/java/com/devkor/ifive/nadab/global/shared/util/dto/MonthRangeDto.java new file mode 100644 index 0000000..1b8b065 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/global/shared/util/dto/MonthRangeDto.java @@ -0,0 +1,9 @@ +package com.devkor.ifive.nadab.global.shared.util.dto; + +import java.time.LocalDate; + +public record MonthRangeDto( + LocalDate monthStartDate, + LocalDate monthEndDate +) { +} From 915e0dafdb53db09c089e610bacc07b05666232a Mon Sep 17 00:00:00 2001 From: 1Seob Date: Sat, 10 Jan 2026 18:43:24 +0900 Subject: [PATCH 03/18] =?UTF-8?q?feat(report):=20MonthlyReportPromptLoader?= =?UTF-8?q?=20=EC=9D=B8=ED=84=B0=ED=8E=98=EC=9D=B4=EC=8A=A4=20=EB=B0=8F=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=EC=B2=B4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy-to-dev-ec2.yml | 4 ++ .../LocalMonthlyReportPromptLoader.java | 38 +++++++++++++++++++ .../monthly/MonthlyReportPromptLoader.java | 5 +++ .../SecretMonthlyReportPromptLoader.java | 29 ++++++++++++++ .../nadab/global/core/response/ErrorCode.java | 7 ++++ 5 files changed, 83 insertions(+) create mode 100644 src/main/java/com/devkor/ifive/nadab/global/core/prompt/monthly/LocalMonthlyReportPromptLoader.java create mode 100644 src/main/java/com/devkor/ifive/nadab/global/core/prompt/monthly/MonthlyReportPromptLoader.java create mode 100644 src/main/java/com/devkor/ifive/nadab/global/core/prompt/monthly/SecretMonthlyReportPromptLoader.java diff --git a/.github/workflows/deploy-to-dev-ec2.yml b/.github/workflows/deploy-to-dev-ec2.yml index 5fae574..e949c54 100644 --- a/.github/workflows/deploy-to-dev-ec2.yml +++ b/.github/workflows/deploy-to-dev-ec2.yml @@ -75,6 +75,10 @@ jobs: WEEKLY_PROMPT="$(printf '%s' "$WEEKLY_PROMPT_B64" | base64 -d | tr -d '\r')" export WEEKLY_PROMPT + MONTHLY_PROMPT_B64="${{ secrets.MONTHLY_PROMPT_B64 }}" + MONTHLY_PROMPT="$(printf '%s' "$MONTHLY_PROMPT_B64" | base64 -d | tr -d '\r')" + export MONTHLY_PROMPT + DB_URL="${{ secrets.DB_URL }}" \ DB_USERNAME="${{ secrets.DB_USERNAME }}" \ DB_PASSWORD="${{ secrets.DB_PASSWORD }}" \ diff --git a/src/main/java/com/devkor/ifive/nadab/global/core/prompt/monthly/LocalMonthlyReportPromptLoader.java b/src/main/java/com/devkor/ifive/nadab/global/core/prompt/monthly/LocalMonthlyReportPromptLoader.java new file mode 100644 index 0000000..d500040 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/global/core/prompt/monthly/LocalMonthlyReportPromptLoader.java @@ -0,0 +1,38 @@ +package com.devkor.ifive.nadab.global.core.prompt.monthly; + +import com.devkor.ifive.nadab.global.core.response.ErrorCode; +import com.devkor.ifive.nadab.global.exception.BadRequestException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Profile; +import org.springframework.core.io.ClassPathResource; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +@Component +@Profile("local") +@Slf4j +public class LocalMonthlyReportPromptLoader implements MonthlyReportPromptLoader { + + private static final String PROMPT_PATH = "secret/monthly-prompt-local.txt"; + + @Override + public String loadPrompt() { + try { + ClassPathResource resource = new ClassPathResource(PROMPT_PATH); + + if (!resource.exists()) { + log.error("월간 리포트 프롬프트 파일이 존재하지 않습니다: {}", PROMPT_PATH); + throw new BadRequestException(ErrorCode.PROMPT_MONTHLY_FILE_NOT_FOUND); + } + + byte[] bytes = resource.getContentAsByteArray(); + return new String(bytes, StandardCharsets.UTF_8); + + } catch (IOException e) { + log.error("로컬 월간 리포트 프롬프트 파일 읽기 실패: {}", PROMPT_PATH, e); + throw new BadRequestException(ErrorCode.PROMPT_MONTHLY_FILE_READ_FAILED); + } + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/global/core/prompt/monthly/MonthlyReportPromptLoader.java b/src/main/java/com/devkor/ifive/nadab/global/core/prompt/monthly/MonthlyReportPromptLoader.java new file mode 100644 index 0000000..2092eba --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/global/core/prompt/monthly/MonthlyReportPromptLoader.java @@ -0,0 +1,5 @@ +package com.devkor.ifive.nadab.global.core.prompt.monthly; + +public interface MonthlyReportPromptLoader { + String loadPrompt(); +} diff --git a/src/main/java/com/devkor/ifive/nadab/global/core/prompt/monthly/SecretMonthlyReportPromptLoader.java b/src/main/java/com/devkor/ifive/nadab/global/core/prompt/monthly/SecretMonthlyReportPromptLoader.java new file mode 100644 index 0000000..d48e4af --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/global/core/prompt/monthly/SecretMonthlyReportPromptLoader.java @@ -0,0 +1,29 @@ +package com.devkor.ifive.nadab.global.core.prompt.monthly; + +import com.devkor.ifive.nadab.global.core.response.ErrorCode; +import com.devkor.ifive.nadab.global.exception.BadRequestException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +@Component +@Profile("dev") +@Slf4j +@RequiredArgsConstructor +public class SecretMonthlyReportPromptLoader implements MonthlyReportPromptLoader { + + @Value("${MONTHLY_PROMPT}") + private String rawPrompt; + + @Override + public String loadPrompt() { + if (rawPrompt == null || rawPrompt.isBlank()) { + log.error("환경 변수 MONTHLY_PROMPT가 비어있습니다."); + throw new BadRequestException(ErrorCode.PROMPT_MONTHLY_ENV_VAR_NOT_SET); + } + + return rawPrompt; + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/global/core/response/ErrorCode.java b/src/main/java/com/devkor/ifive/nadab/global/core/response/ErrorCode.java index c6acc5b..c1eac17 100644 --- a/src/main/java/com/devkor/ifive/nadab/global/core/response/ErrorCode.java +++ b/src/main/java/com/devkor/ifive/nadab/global/core/response/ErrorCode.java @@ -133,6 +133,8 @@ public enum ErrorCode { WEEKLY_REPORT_ALREADY_COMPLETED(HttpStatus.CONFLICT, "이미 작성된 주간 리포트가 존재합니다"), WEEKLY_REPORT_IN_PROGRESS(HttpStatus.CONFLICT, "현재 주간 리포트를 생성 중입니다"), + // ==================== MONTHLY_REPORT (월간 리포트) ==================== + // ==================== AI (인공지능) ==================== // 502 Bad Gateway AI_RESPONSE_PARSE_FAILED(HttpStatus.BAD_GATEWAY, "AI 응답 형식을 해석할 수 없습니다"), @@ -164,10 +166,15 @@ public enum ErrorCode { PROMPT_DAILY_FILE_NOT_FOUND(HttpStatus.BAD_REQUEST, "일간 리포트 프롬프트 파일이 존재하지 않습니다"), PROMPT_DAILY_FILE_READ_FAILED(HttpStatus.BAD_REQUEST, "로컬 일간 리포트 프롬프트 파일을 읽을 수 없습니다"), PROMPT_DAILY_ENV_VAR_NOT_SET(HttpStatus.BAD_REQUEST, "INSIGHT_PROMPT 환경 변수에 프롬프트가 설정되어 있지 않습니다"), + PROMPT_WEEKLY_FILE_NOT_FOUND(HttpStatus.BAD_REQUEST, "주간 리포트 프롬프트 파일이 존재하지 않습니다"), PROMPT_WEEKLY_FILE_READ_FAILED(HttpStatus.BAD_REQUEST, "로컬 주간 리포트 프롬프트 파일을 읽을 수 없습니다"), PROMPT_WEEKLY_ENV_VAR_NOT_SET(HttpStatus.BAD_REQUEST, "WEEKLY_PROMPT 환경 변수에 프롬프트가 설정되어 있지 않습니다"), + PROMPT_MONTHLY_FILE_NOT_FOUND(HttpStatus.BAD_REQUEST, "월간 리포트 프롬프트 파일이 존재하지 않습니다"), + PROMPT_MONTHLY_FILE_READ_FAILED(HttpStatus.BAD_REQUEST, "로컬 월간 리포트 프롬프트 파일을 읽을 수 없습니다"), + PROMPT_MONTHLY_ENV_VAR_NOT_SET(HttpStatus.BAD_REQUEST, "MONTHLY_PROMPT 환경 변수에 프롬프트가 설정되어 있지 않습니다"), + // ==================== NICKNAME (닉네임) ==================== // 400 Bad Request NICKNAME_CHANGE_LIMIT_EXCEEDED(HttpStatus.BAD_REQUEST, "닉네임 변경 가능 횟수를 초과했습니다 (14일 내 최대 2회)"); From 73050684d1644fe2d3adbf82690a715c25850427 Mon Sep 17 00:00:00 2001 From: 1Seob Date: Sat, 10 Jan 2026 22:52:08 +0900 Subject: [PATCH 04/18] =?UTF-8?q?feat(report):=20MonthlyWeeklySummariesAss?= =?UTF-8?q?mbler=20=ED=97=AC=ED=8D=BC=20=ED=81=B4=EB=9E=98=EC=8A=A4=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20=EB=B0=8F=20MonthlyWeeklySummaryInputDto?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 월간 리포트 생성 프롬프트의 입력값 생성에 활용 --- .../MonthlyWeeklySummariesAssembler.java | 56 +++++++++++++++++++ .../dto/MonthlyWeeklySummaryInputDto.java | 12 ++++ .../helper/WeeklyEntriesAssembler.java | 6 ++ 3 files changed, 74 insertions(+) create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/helper/MonthlyWeeklySummariesAssembler.java create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/dto/MonthlyWeeklySummaryInputDto.java diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/helper/MonthlyWeeklySummariesAssembler.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/helper/MonthlyWeeklySummariesAssembler.java new file mode 100644 index 0000000..bb49d2f --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/helper/MonthlyWeeklySummariesAssembler.java @@ -0,0 +1,56 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.application.helper; + +import com.devkor.ifive.nadab.domain.monthlyreport.core.dto.MonthlyWeeklySummaryInputDto; + +import java.util.Comparator; +import java.util.List; +import java.util.Objects; + +public final class MonthlyWeeklySummariesAssembler { + + private static final String NA = "N/A"; + + private MonthlyWeeklySummariesAssembler() { + } + + /** + * 프롬프트에 넣을 weeklySummaries 텍스트를 생성합니다. + * + * 예) + * - W1(2026-01-01 ~ 2026-01-07) + * discovered: ... + * good: ... + * improve: ... + */ + public static String assemble(List inputs) { + if (inputs == null || inputs.isEmpty()) return ""; + + List sorted = inputs.stream() + .filter(Objects::nonNull) + .sorted(Comparator.comparing(MonthlyWeeklySummaryInputDto::weekStartDate, + Comparator.nullsLast(Comparator.naturalOrder()))) + .toList(); + + StringBuilder sb = new StringBuilder(); + int idx = 1; + + for (MonthlyWeeklySummaryInputDto w : sorted) { + sb.append("- W").append(idx) + .append("(").append(valueOrNa(w.weekStartDate() == null ? null : w.weekStartDate().toString())) + .append(" ~ ").append(valueOrNa(w.weekEndDate() == null ? null : w.weekEndDate().toString())) + .append(")\n") + .append(" discovered: ").append(valueOrNa(w.discovered())).append("\n") + .append(" good: ").append(valueOrNa(w.good())).append("\n") + .append(" improve: ").append(valueOrNa(w.improve())).append("\n\n"); + idx++; + } + + return sb.toString().trim(); + } + + private static String valueOrNa(String s) { + if (s == null) return NA; + String t = s.trim(); + return t.isEmpty() ? NA : t; + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/dto/MonthlyWeeklySummaryInputDto.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/dto/MonthlyWeeklySummaryInputDto.java new file mode 100644 index 0000000..291d383 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/dto/MonthlyWeeklySummaryInputDto.java @@ -0,0 +1,12 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.core.dto; + +import java.time.LocalDate; + +public record MonthlyWeeklySummaryInputDto( + LocalDate weekStartDate, + LocalDate weekEndDate, + String discovered, + String good, + String improve +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/application/helper/WeeklyEntriesAssembler.java b/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/application/helper/WeeklyEntriesAssembler.java index daadbf8..5d95c1a 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/application/helper/WeeklyEntriesAssembler.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/application/helper/WeeklyEntriesAssembler.java @@ -14,6 +14,12 @@ public final class WeeklyEntriesAssembler { private WeeklyEntriesAssembler() { } + /** + * 프롬프트에 넣을 entries 문자열을 생성합니다. + * - date 오름차순 정렬 + * - D1, D2 ... 번호 부여 + * - null/blank 값은 N/A로 대체 + */ public static String assemble(List inputs) { if (inputs == null || inputs.isEmpty()) { return ""; From 50af7b10e2026b15b3c413bcd39d9d3fd002f8ea Mon Sep 17 00:00:00 2001 From: 1Seob Date: Sat, 10 Jan 2026 23:00:50 +0900 Subject: [PATCH 05/18] =?UTF-8?q?feat(report):=20MonthlyRepresentativePick?= =?UTF-8?q?er=20=ED=97=AC=ED=8D=BC=20=ED=81=B4=EB=9E=98=EC=8A=A4=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 대표 일간 리포트 샘플 추출 --- .../helper/MonthlyRepresentativePicker.java | 144 ++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/helper/MonthlyRepresentativePicker.java diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/helper/MonthlyRepresentativePicker.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/helper/MonthlyRepresentativePicker.java new file mode 100644 index 0000000..e0dc8c6 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/helper/MonthlyRepresentativePicker.java @@ -0,0 +1,144 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.application.helper; + +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.temporal.WeekFields; +import java.util.*; +import java.util.stream.Collectors; + +public final class MonthlyRepresentativePicker { + + public record DailyEntry( + LocalDate date, + String question, + String answer, + String dailyReport, + String emotion + ) {} + + public List pick(List entries, int maxSamples) { + if (entries == null || entries.isEmpty() || maxSamples <= 0) return List.of(); + + // 날짜순 정렬 (변화량 계산용) + List sorted = entries.stream() + .sorted(Comparator.comparing(DailyEntry::date)) + .toList(); + + // 감정 빈도 + Map emotionCount = sorted.stream() + .collect(Collectors.groupingBy(DailyEntry::emotion, Collectors.counting())); + + // 점수 계산 + Map scoreByDate = computeScores(sorted, emotionCount); + + // (1) 감정 분포 상위 2개에서 대표 1개씩 + List topEmotions = emotionCount.entrySet().stream() + .sorted(Map.Entry.comparingByValue().reversed()) + .limit(2) + .map(Map.Entry::getKey) + .toList(); + + LinkedHashSet selected = new LinkedHashSet<>(); + + for (String emo : topEmotions) { + pickBestByEmotion(sorted, emo, scoreByDate).ifPresent(selected::add); + if (selected.size() >= maxSamples) return new ArrayList<>(selected); + } + + // (2) 주차 커버리지 확보: 주차별 최고점 1개 + WeekFields wf = WeekFields.of(DayOfWeek.MONDAY, 4); // 한국 기준 월요일 시작(대개) + Map> byWeek = sorted.stream() + .collect(Collectors.groupingBy(e -> e.date().get(wf.weekOfMonth()))); + + List weeks = byWeek.keySet().stream().sorted().toList(); + for (Integer w : weeks) { + if (selected.size() >= maxSamples) break; + + Optional bestOfWeek = byWeek.get(w).stream() + .filter(e -> !containsDate(selected, e.date())) + .max(Comparator.comparingDouble(e -> scoreByDate.getOrDefault(e.date(), 0.0))); + + bestOfWeek.ifPresent(selected::add); + } + + // (3) 남으면 전체 점수 순으로 채우기 + if (selected.size() < maxSamples) { + List rest = sorted.stream() + .filter(e -> !containsDate(selected, e.date())) + .sorted((a, b) -> Double.compare( + scoreByDate.getOrDefault(b.date(), 0.0), + scoreByDate.getOrDefault(a.date(), 0.0) + )) + .toList(); + + for (DailyEntry e : rest) { + selected.add(e); + if (selected.size() >= maxSamples) break; + } + } + + return new ArrayList<>(selected); + } + + private static boolean containsDate(Set set, LocalDate date) { + for (DailyEntry e : set) if (e.date().equals(date)) return true; + return false; + } + + private static Optional pickBestByEmotion( + List sorted, + String emotion, + Map scoreByDate + ) { + return sorted.stream() + .filter(e -> Objects.equals(e.emotion(), emotion)) + .max(Comparator.comparingDouble(e -> scoreByDate.getOrDefault(e.date(), 0.0))); + } + + private static Map computeScores( + List sorted, + Map emotionCount + ) { + long total = emotionCount.values().stream().mapToLong(v -> v).sum(); + + // 감정 희귀도(빈도 낮을수록 가산): inverse freq + Map rarity = new HashMap<>(); + for (var e : emotionCount.entrySet()) { + double freq = (double) e.getValue() / (double) total; + // freq가 낮을수록 값 커짐 (최소 1.0) + rarity.put(e.getKey(), 1.0 + (1.0 - freq) * 1.5); + } + + Map score = new HashMap<>(); + DailyEntry prev = null; + + for (DailyEntry cur : sorted) { + int answerLen = safeLen(cur.answer()); + int reportLen = safeLen(cur.dailyReport()); + + // 정보량: 답변/리포트가 길수록 점수 + double info = Math.log1p(answerLen) * 2.0 + Math.log1p(reportLen) * 1.2; + + // 희귀 감정 보정 + double rare = rarity.getOrDefault(cur.emotion(), 1.0); + + // 전환점(전날과 감정이 다르면 가산) + double change = 0.0; + if (prev != null && !Objects.equals(prev.emotion(), cur.emotion())) { + change = 0.8; + } + + // 최종 점수 + double totalScore = info * rare + change; + + score.put(cur.date(), totalScore); + prev = cur; + } + + return score; + } + + private static int safeLen(String s) { + return (s == null) ? 0 : s.trim().length(); + } +} From 2b044da3c0b5bdf1d99ce427f9ee01ed85b49dc4 Mon Sep 17 00:00:00 2001 From: 1Seob Date: Sat, 10 Jan 2026 23:08:02 +0900 Subject: [PATCH 06/18] =?UTF-8?q?feat(report):=20MonthlyWeeklySummariesSer?= =?UTF-8?q?vice=20=ED=81=B4=EB=9E=98=EC=8A=A4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MonthlyWeeklySummariesService.java | 43 +++++++++++++++++++ .../repository/WeeklyReportRepository.java | 20 +++++++++ 2 files changed, 63 insertions(+) create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyWeeklySummariesService.java diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyWeeklySummariesService.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyWeeklySummariesService.java new file mode 100644 index 0000000..a44e984 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyWeeklySummariesService.java @@ -0,0 +1,43 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.application; + +import com.devkor.ifive.nadab.domain.monthlyreport.application.helper.MonthlyWeeklySummariesAssembler; +import com.devkor.ifive.nadab.domain.monthlyreport.core.dto.MonthlyWeeklySummaryInputDto; +import com.devkor.ifive.nadab.domain.weeklyreport.core.entity.WeeklyReport; +import com.devkor.ifive.nadab.domain.weeklyreport.core.entity.WeeklyReportStatus; +import com.devkor.ifive.nadab.domain.weeklyreport.core.repository.WeeklyReportRepository; +import com.devkor.ifive.nadab.global.shared.util.dto.MonthRangeDto; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MonthlyWeeklySummariesService { + + private final WeeklyReportRepository weeklyReportRepository; + + public String buildWeeklySummaries(Long userId, MonthRangeDto monthRange) { + List weeklyReports = + weeklyReportRepository.findMonthlyOverlappedWeeklyReports( + userId, + monthRange.monthStartDate(), + monthRange.monthEndDate(), + WeeklyReportStatus.COMPLETED + ); + + List inputs = weeklyReports.stream() + .map(wr -> new MonthlyWeeklySummaryInputDto( + wr.getWeekStartDate(), + wr.getWeekEndDate(), + wr.getDiscovered(), + wr.getGood(), + wr.getImprove() + )) + .toList(); + + return MonthlyWeeklySummariesAssembler.assemble(inputs); + } +} \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/core/repository/WeeklyReportRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/core/repository/WeeklyReportRepository.java index f224ab2..990ce26 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/core/repository/WeeklyReportRepository.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/core/repository/WeeklyReportRepository.java @@ -8,6 +8,7 @@ import org.springframework.data.repository.query.Param; import java.time.LocalDate; +import java.util.List; import java.util.Optional; public interface WeeklyReportRepository extends JpaRepository { @@ -62,4 +63,23 @@ int updateStatus( @Param("id") Long id, @Param("status") WeeklyReportStatus status ); + + /** + * 특정 월(monthStart ~ monthEnd)과 겹치는 주간 리포트 조회 + */ + @Query(""" + select wr + from WeeklyReport wr + where wr.user.id = :userId + and wr.weekStartDate <= :monthEnd + and wr.weekEndDate >= :monthStart + and wr.status = :status + order by wr.weekStartDate asc + """) + List findMonthlyOverlappedWeeklyReports( + @Param("userId") Long userId, + @Param("monthStart") LocalDate monthStart, + @Param("monthEnd") LocalDate monthEnd, + @Param("status") WeeklyReportStatus status + ); } \ No newline at end of file From c93939b3b7c60e20a80a8d941b5492d49dbe4e68 Mon Sep 17 00:00:00 2001 From: 1Seob Date: Sat, 10 Jan 2026 23:30:49 +0900 Subject: [PATCH 07/18] =?UTF-8?q?feat(report):=20MonthlyReportLlmClient=20?= =?UTF-8?q?=ED=81=B4=EB=9E=98=EC=8A=A4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MonthlyWeeklySummariesService.java | 2 +- .../core/dto/AiMonthlyReportResultDto.java | 8 +++ .../core/dto/LlmMonthlyResultDto.java | 8 +++ .../infra/MonthlyReportLlmClient.java | 71 +++++++++++++++++++ .../WeeklyReportGenerationListener.java | 2 +- .../infra/WeeklyReportLlmClient.java | 2 +- 6 files changed, 90 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/dto/AiMonthlyReportResultDto.java create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/dto/LlmMonthlyResultDto.java create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/infra/MonthlyReportLlmClient.java rename src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/{core => }/infra/WeeklyReportLlmClient.java (97%) diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyWeeklySummariesService.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyWeeklySummariesService.java index a44e984..9dab505 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyWeeklySummariesService.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyWeeklySummariesService.java @@ -14,7 +14,7 @@ @Service @RequiredArgsConstructor -@Transactional(readOnly = true) +@Transactional public class MonthlyWeeklySummariesService { private final WeeklyReportRepository weeklyReportRepository; diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/dto/AiMonthlyReportResultDto.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/dto/AiMonthlyReportResultDto.java new file mode 100644 index 0000000..e930976 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/dto/AiMonthlyReportResultDto.java @@ -0,0 +1,8 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.core.dto; + +public record AiMonthlyReportResultDto( + String discovered, + String good, + String improve +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/dto/LlmMonthlyResultDto.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/dto/LlmMonthlyResultDto.java new file mode 100644 index 0000000..ddcdacc --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/dto/LlmMonthlyResultDto.java @@ -0,0 +1,8 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.core.dto; + +public record LlmMonthlyResultDto( + String discovered, + String good, + String improve +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/infra/MonthlyReportLlmClient.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/infra/MonthlyReportLlmClient.java new file mode 100644 index 0000000..e9e19d8 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/infra/MonthlyReportLlmClient.java @@ -0,0 +1,71 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.infra; + +import com.devkor.ifive.nadab.domain.monthlyreport.core.dto.AiMonthlyReportResultDto; +import com.devkor.ifive.nadab.domain.monthlyreport.core.dto.LlmMonthlyResultDto; +import com.devkor.ifive.nadab.global.core.prompt.monthly.MonthlyReportPromptLoader; +import com.devkor.ifive.nadab.global.core.response.ErrorCode; +import com.devkor.ifive.nadab.global.exception.ai.AiResponseParseException; +import com.devkor.ifive.nadab.global.exception.ai.AiServiceUnavailableException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.openai.OpenAiChatOptions; +import org.springframework.ai.openai.api.OpenAiApi; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class MonthlyReportLlmClient { + + private final ChatClient chatClient; + private final MonthlyReportPromptLoader monthlyReportPromptLoader; + private final ObjectMapper objectMapper; + + public AiMonthlyReportResultDto generate( + String monthStartDate, String monthEndDate, String weeklySummaries, String representativeEntries) { + String prompt = monthlyReportPromptLoader.loadPrompt() + .replace("{monthStartDate}", monthStartDate) + .replace("{monthEndDate}", monthEndDate) + .replace("{weeklySummaries}", weeklySummaries) + .replace("{representativeEntries}", representativeEntries); + + OpenAiChatOptions options = OpenAiChatOptions.builder() + .model(OpenAiApi.ChatModel.GPT_5_MINI) + .reasoningEffort("low") + .temperature(1.0) + .build(); + + String content = chatClient.prompt() + .user(prompt) + .options(options) + .call() + .content(); + + if (content == null || content.trim().isEmpty()) { + throw new AiServiceUnavailableException(ErrorCode.AI_NO_RESPONSE); + } + + try { + LlmMonthlyResultDto result = objectMapper.readValue(content, LlmMonthlyResultDto.class); + + String discovered = result.discovered(); + String good = result.good(); + String improve = result.improve(); + + if (isBlank(discovered) || isBlank(good) || isBlank(improve)) { + throw new AiResponseParseException(ErrorCode.AI_RESPONSE_FORMAT_INVALID); + } + + return new AiMonthlyReportResultDto(discovered, good, improve); + + } catch (AiResponseParseException e) { + throw e; + } catch (Exception e) { + throw new AiResponseParseException(ErrorCode.AI_RESPONSE_PARSE_FAILED); + } + } + + private boolean isBlank(String s) { + return s == null || s.trim().isEmpty(); + } +} \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/application/helper/WeeklyReportGenerationListener.java b/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/application/helper/WeeklyReportGenerationListener.java index d7a09c4..a68781a 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/application/helper/WeeklyReportGenerationListener.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/application/helper/WeeklyReportGenerationListener.java @@ -4,7 +4,7 @@ import com.devkor.ifive.nadab.domain.weeklyreport.core.dto.AiWeeklyReportResultDto; import com.devkor.ifive.nadab.domain.weeklyreport.core.dto.WeeklyReportEntryInputDto; import com.devkor.ifive.nadab.domain.weeklyreport.core.dto.WeeklyReportGenerationRequestedEventDto; -import com.devkor.ifive.nadab.domain.weeklyreport.core.infra.WeeklyReportLlmClient; +import com.devkor.ifive.nadab.domain.weeklyreport.infra.WeeklyReportLlmClient; import com.devkor.ifive.nadab.domain.weeklyreport.core.repository.WeeklyQueryRepository; import com.devkor.ifive.nadab.global.shared.util.WeekRangeCalculator; import com.devkor.ifive.nadab.global.shared.util.dto.WeekRangeDto; diff --git a/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/core/infra/WeeklyReportLlmClient.java b/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/infra/WeeklyReportLlmClient.java similarity index 97% rename from src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/core/infra/WeeklyReportLlmClient.java rename to src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/infra/WeeklyReportLlmClient.java index fd53a51..f4dd36f 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/core/infra/WeeklyReportLlmClient.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/infra/WeeklyReportLlmClient.java @@ -1,4 +1,4 @@ -package com.devkor.ifive.nadab.domain.weeklyreport.core.infra; +package com.devkor.ifive.nadab.domain.weeklyreport.infra; import com.devkor.ifive.nadab.domain.weeklyreport.core.dto.AiWeeklyReportResultDto; import com.devkor.ifive.nadab.domain.weeklyreport.core.dto.LlmWeeklyResultDto; From 160f285766defa0e724bd5e32097226bab6e624f Mon Sep 17 00:00:00 2001 From: 1Seob Date: Sat, 10 Jan 2026 23:38:55 +0900 Subject: [PATCH 08/18] =?UTF-8?q?feat(report):=20MonthlyReportRepository?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/MonthlyReportRepository.java | 61 +++++++++++++++++++ .../repository/WeeklyReportRepository.java | 1 + 2 files changed, 62 insertions(+) create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/repository/MonthlyReportRepository.java diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/repository/MonthlyReportRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/repository/MonthlyReportRepository.java new file mode 100644 index 0000000..b6b5b17 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/repository/MonthlyReportRepository.java @@ -0,0 +1,61 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.core.repository; + +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReport; +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportStatus; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDate; +import java.util.Optional; + +public interface MonthlyReportRepository extends JpaRepository { + + Optional findByUserIdAndMonthStartDate(Long userId, LocalDate monthStartDate); + + /** + * PENDING -> COMPLETED 확정 + * - 분석 결과(discovered/good/improve) 저장 + * - analyzedAt 기록 + */ + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + UPDATE MonthlyReport mr + SET mr.status = :status, + mr.discovered = :discovered, + mr.good = :good, + mr.improve = :improve, + mr.analyzedAt = CURRENT_TIMESTAMP + WHERE mr.id = :reportId +""") + int markCompleted( + @Param("reportId") Long reportId, + @Param("status") MonthlyReportStatus status, + @Param("discovered") String discovered, + @Param("good") String good, + @Param("improve") String improve + ); + + /** + * PENDING -> FAILED 확정 + */ + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + UPDATE MonthlyReport mr + SET mr.status = :status, + mr.analyzedAt = CURRENT_TIMESTAMP + WHERE mr.id = :reportId +""") + int markFailed( + @Param("reportId") Long reportId, + @Param("status") MonthlyReportStatus status + ); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("UPDATE MonthlyReport m SET m.status = :status WHERE m.id = :id") + int updateStatus( + @Param("id") Long id, + @Param("status") MonthlyReportStatus status + ); +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/core/repository/WeeklyReportRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/core/repository/WeeklyReportRepository.java index 990ce26..a0c7ef4 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/core/repository/WeeklyReportRepository.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/core/repository/WeeklyReportRepository.java @@ -66,6 +66,7 @@ int updateStatus( /** * 특정 월(monthStart ~ monthEnd)과 겹치는 주간 리포트 조회 + * 월간 리포트 생성을 위해 사용 */ @Query(""" select wr From 8f1dfb5eb26ba146a5d6f3ecb645d76733c8fc1e Mon Sep 17 00:00:00 2001 From: 1Seob Date: Sun, 11 Jan 2026 02:50:46 +0900 Subject: [PATCH 09/18] =?UTF-8?q?feat(report):=20PendingMonthlyReportServi?= =?UTF-8?q?ce=20=ED=81=B4=EB=9E=98=EC=8A=A4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PENDING MonthlyReport를 생성하는 클래스 --- .../MonthlyWeeklySummariesService.java | 2 +- .../service/PendingMonthlyReportService.java | 52 +++++++++++++++++++ .../service/PendingWeeklyReportService.java | 1 - .../nadab/global/core/response/ErrorCode.java | 7 +++ 4 files changed, 60 insertions(+), 2 deletions(-) rename src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/{application => core/service}/MonthlyWeeklySummariesService.java (96%) create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/service/PendingMonthlyReportService.java diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyWeeklySummariesService.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/service/MonthlyWeeklySummariesService.java similarity index 96% rename from src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyWeeklySummariesService.java rename to src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/service/MonthlyWeeklySummariesService.java index 9dab505..c9f5df0 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyWeeklySummariesService.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/service/MonthlyWeeklySummariesService.java @@ -1,4 +1,4 @@ -package com.devkor.ifive.nadab.domain.monthlyreport.application; +package com.devkor.ifive.nadab.domain.monthlyreport.core.service; import com.devkor.ifive.nadab.domain.monthlyreport.application.helper.MonthlyWeeklySummariesAssembler; import com.devkor.ifive.nadab.domain.monthlyreport.core.dto.MonthlyWeeklySummaryInputDto; diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/service/PendingMonthlyReportService.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/service/PendingMonthlyReportService.java new file mode 100644 index 0000000..7d6707a --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/service/PendingMonthlyReportService.java @@ -0,0 +1,52 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.core.service; + +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReport; +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportStatus; +import com.devkor.ifive.nadab.domain.monthlyreport.core.repository.MonthlyReportRepository; +import com.devkor.ifive.nadab.domain.user.core.entity.User; +import com.devkor.ifive.nadab.global.core.response.ErrorCode; +import com.devkor.ifive.nadab.global.exception.ConflictException; +import com.devkor.ifive.nadab.global.shared.util.MonthRangeCalculator; +import com.devkor.ifive.nadab.global.shared.util.TodayDateTimeProvider; +import com.devkor.ifive.nadab.global.shared.util.dto.MonthRangeDto; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; + +@Service +@RequiredArgsConstructor +public class PendingMonthlyReportService { + + private final MonthlyReportRepository monthlyReportRepository; + + @Transactional + public MonthlyReport getOrCreatePendingMonthlyReport(User user) { + + MonthRangeDto range = MonthRangeCalculator.getLastMonthRange(); + LocalDate today = TodayDateTimeProvider.getTodayDate(); + + MonthlyReport report = monthlyReportRepository.findByUserIdAndMonthStartDate(user.getId(), range.monthStartDate()) + .orElseGet(() -> monthlyReportRepository.save(MonthlyReport.createPending( + user, + range.monthStartDate(), + range.monthEndDate(), + today + ))); + + if (report.getStatus() == MonthlyReportStatus.COMPLETED) { + throw new ConflictException(ErrorCode.MONTHLY_REPORT_ALREADY_COMPLETED); + } + + if (report.getStatus() == MonthlyReportStatus.IN_PROGRESS) { + throw new ConflictException(ErrorCode.MONTHLY_REPORT_IN_PROGRESS); + } + + if (report.getStatus() == MonthlyReportStatus.FAILED) { + monthlyReportRepository.updateStatus(report.getId(), MonthlyReportStatus.PENDING); + } + + return report; + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/core/service/PendingWeeklyReportService.java b/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/core/service/PendingWeeklyReportService.java index 256c23c..4597e67 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/core/service/PendingWeeklyReportService.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/core/service/PendingWeeklyReportService.java @@ -11,7 +11,6 @@ import com.devkor.ifive.nadab.global.shared.util.dto.WeekRangeDto; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDate; diff --git a/src/main/java/com/devkor/ifive/nadab/global/core/response/ErrorCode.java b/src/main/java/com/devkor/ifive/nadab/global/core/response/ErrorCode.java index c1eac17..4199c4e 100644 --- a/src/main/java/com/devkor/ifive/nadab/global/core/response/ErrorCode.java +++ b/src/main/java/com/devkor/ifive/nadab/global/core/response/ErrorCode.java @@ -134,6 +134,13 @@ public enum ErrorCode { WEEKLY_REPORT_IN_PROGRESS(HttpStatus.CONFLICT, "현재 주간 리포트를 생성 중입니다"), // ==================== MONTHLY_REPORT (월간 리포트) ==================== + // 404 Not Found + MONTHLY_REPORT_NOT_FOUND(HttpStatus.NOT_FOUND, "월간 리포트를 찾을 수 없습니다"), + MONTHLY_REPORT_NOT_COMPLETED(HttpStatus.NOT_FOUND, "해당 월간 리포트가 아직 생성 완료되지 않았습니다"), + + // 409 Conflict + MONTHLY_REPORT_ALREADY_COMPLETED(HttpStatus.CONFLICT, "이미 작성된 월간 리포트가 존재합니다"), + MONTHLY_REPORT_IN_PROGRESS(HttpStatus.CONFLICT, "현재 월간 리포트를 생성 중입니다"), // ==================== AI (인공지능) ==================== // 502 Bad Gateway From d05e5f4dda5a2bbe7cd423857f52f43223fee667 Mon Sep 17 00:00:00 2001 From: 1Seob Date: Sun, 11 Jan 2026 16:44:38 +0900 Subject: [PATCH 10/18] =?UTF-8?q?feat(report):=20MonthlyReportTxService=20?= =?UTF-8?q?=ED=81=B4=EB=9E=98=EC=8A=A4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/MonthlyReportTxService.java | 110 ++++++++++++++++++ ...thlyReportGenerationRequestedEventDto.java | 8 ++ .../core/dto/MonthlyReserveResultDto.java | 9 ++ .../application/WeeklyReportTxService.java | 2 +- 4 files changed, 128 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyReportTxService.java create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/dto/MonthlyReportGenerationRequestedEventDto.java create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/dto/MonthlyReserveResultDto.java diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyReportTxService.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyReportTxService.java new file mode 100644 index 0000000..5f93b21 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyReportTxService.java @@ -0,0 +1,110 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.application; + + +import com.devkor.ifive.nadab.domain.monthlyreport.core.dto.MonthlyReportGenerationRequestedEventDto; +import com.devkor.ifive.nadab.domain.monthlyreport.core.dto.MonthlyReserveResultDto; +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReport; +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportStatus; +import com.devkor.ifive.nadab.domain.monthlyreport.core.repository.MonthlyReportRepository; +import com.devkor.ifive.nadab.domain.monthlyreport.core.service.PendingMonthlyReportService; +import com.devkor.ifive.nadab.domain.user.core.entity.User; +import com.devkor.ifive.nadab.domain.wallet.core.entity.CrystalLog; +import com.devkor.ifive.nadab.domain.wallet.core.entity.CrystalLogReason; +import com.devkor.ifive.nadab.domain.wallet.core.entity.UserWallet; +import com.devkor.ifive.nadab.domain.wallet.core.repository.CrystalLogRepository; +import com.devkor.ifive.nadab.domain.wallet.core.repository.UserWalletRepository; +import com.devkor.ifive.nadab.global.core.response.ErrorCode; +import com.devkor.ifive.nadab.global.exception.NotEnoughCrystalException; +import com.devkor.ifive.nadab.global.exception.NotFoundException; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class MonthlyReportTxService { + + private final PendingMonthlyReportService pendingMonthlyReportService; + + private final MonthlyReportRepository monthlyReportRepository; + private final UserWalletRepository userWalletRepository; + private final CrystalLogRepository crystalLogRepository; + + private final ApplicationEventPublisher eventPublisher; + + private static final long MONTHLY_REPORT_COST = 200L; + + /** + * (Tx) MonthlyReport(PENDING) + reserve consume + CrystalLog(PENDING) + * 커밋되면 리포트 생성 작업을 시작할 준비가 완료됨 + */ + public MonthlyReserveResultDto reserveMonthly(User user) { + + // Report: 있으면 기존 사용, 없으면 새로 PENDING 생성 + MonthlyReport report = pendingMonthlyReportService.getOrCreatePendingMonthlyReport(user); + + // 선차감(원자적) + balanceAfter 확보 + int updated = userWalletRepository.tryConsume(user.getId(), MONTHLY_REPORT_COST); + if (updated == 0) { + throw new NotEnoughCrystalException(ErrorCode.WALLET_INSUFFICIENT_BALANCE); + } + + UserWallet wallet = userWalletRepository.findByUserId(user.getId()) + .orElseThrow(() -> new NotFoundException(ErrorCode.WALLET_NOT_FOUND)); + long balanceAfter = wallet.getCrystalBalance(); + + + // 로그(PENDING) + CrystalLog log = crystalLogRepository.save( + CrystalLog.createPending( + user, + -MONTHLY_REPORT_COST, + balanceAfter, + CrystalLogReason.REPORT_GENERATE_MONTHLY, + "MONTHLY_REPORT", + report.getId() + ) + ); + + return new MonthlyReserveResultDto(report.getId(), log.getId(), user.getId(), balanceAfter); + } + + public MonthlyReserveResultDto reserveMonthlyAndPublish(User user) { + MonthlyReserveResultDto reserve = this.reserveMonthly(user); + + monthlyReportRepository.updateStatus(reserve.reportId(), MonthlyReportStatus.IN_PROGRESS); + + // 트랜잭션 안에서 publish (AFTER_COMMIT 트리거 보장) + eventPublisher.publishEvent(new MonthlyReportGenerationRequestedEventDto( + reserve.reportId(), + user.getId(), + reserve.crystalLogId() + )); + + return reserve; + } + + public void confirmMonthly(Long reportId, Long logId, String discovered, String good, String improve) { + // report를 COMPLETED로 + monthlyReportRepository.markCompleted(reportId, MonthlyReportStatus.COMPLETED, discovered, good, improve); + + // log를 CONFIRMED로 + crystalLogRepository.markConfirmed(logId); + } + + public void failAndRefundMonthly(Long userId, Long reportId, Long logId) { + monthlyReportRepository.markFailed(reportId, MonthlyReportStatus.FAILED); + + // 환불(+cost) + int updated = userWalletRepository.refund(userId, MONTHLY_REPORT_COST); + if (updated == 0) { + // wallet이 없을 수 있는 상황 + throw new NotFoundException(ErrorCode.WALLET_NOT_FOUND); + } + + // log를 REFUNDED로 + crystalLogRepository.markRefunded(logId); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/dto/MonthlyReportGenerationRequestedEventDto.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/dto/MonthlyReportGenerationRequestedEventDto.java new file mode 100644 index 0000000..e8bf663 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/dto/MonthlyReportGenerationRequestedEventDto.java @@ -0,0 +1,8 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.core.dto; + +public record MonthlyReportGenerationRequestedEventDto( + Long reportId, + Long userId, + Long crystalLogId +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/dto/MonthlyReserveResultDto.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/dto/MonthlyReserveResultDto.java new file mode 100644 index 0000000..432913a --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/dto/MonthlyReserveResultDto.java @@ -0,0 +1,9 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.core.dto; + +public record MonthlyReserveResultDto( + Long reportId, + Long crystalLogId, + Long userId, + Long balanceAfter +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/application/WeeklyReportTxService.java b/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/application/WeeklyReportTxService.java index 6090801..5a2684f 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/application/WeeklyReportTxService.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/application/WeeklyReportTxService.java @@ -86,7 +86,7 @@ public WeeklyReserveResultDto reserveWeeklyAndPublish(User user) { } public void confirmWeekly(Long reportId, Long logId, String discovered, String good, String improve) { - // 네 weekly_report schema에 맞춰 markCompleted 파라미터 변경 + // report를 COMPLETED로 weeklyReportRepository.markCompleted(reportId, WeeklyReportStatus.COMPLETED, discovered, good, improve); // log를 CONFIRMED로 From 9e208bec3c03b9093531ba5c1075d9010e8a2bad Mon Sep 17 00:00:00 2001 From: 1Seob Date: Sun, 11 Jan 2026 18:48:50 +0900 Subject: [PATCH 11/18] =?UTF-8?q?feat(report):=20MonthlyReportGenerationLi?= =?UTF-8?q?stener,=20MonthlyQueryRepository=20=ED=81=B4=EB=9E=98=EC=8A=A4?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AnswerEntryQueryRepository.java | 1 - .../helper/MonthlyRepresentativePicker.java | 166 +++++++++++------- .../MonthlyReportGenerationListener.java | 99 +++++++++++ .../repository/MonthlyQueryRepository.java | 40 +++++ .../helper/WeeklyEntriesAssembler.java | 10 +- .../WeeklyReportGenerationListener.java | 7 +- ...tEntryInputDto.java => DailyEntryDto.java} | 2 +- .../repository/WeeklyQueryRepository.java | 6 +- .../global/core/config/infra/AsyncConfig.java | 15 ++ 9 files changed, 265 insertions(+), 81 deletions(-) create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/listener/MonthlyReportGenerationListener.java create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/repository/MonthlyQueryRepository.java rename src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/application/{helper => listener}/WeeklyReportGenerationListener.java (89%) rename src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/core/dto/{WeeklyReportEntryInputDto.java => DailyEntryDto.java} (87%) diff --git a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/core/repository/AnswerEntryQueryRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/core/repository/AnswerEntryQueryRepository.java index fbaa5d5..13e97bf 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/core/repository/AnswerEntryQueryRepository.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/core/repository/AnswerEntryQueryRepository.java @@ -10,7 +10,6 @@ import java.time.LocalDate; import java.util.List; -import java.util.Optional; public interface AnswerEntryQueryRepository extends Repository { diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/helper/MonthlyRepresentativePicker.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/helper/MonthlyRepresentativePicker.java index e0dc8c6..940cada 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/helper/MonthlyRepresentativePicker.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/helper/MonthlyRepresentativePicker.java @@ -1,137 +1,156 @@ package com.devkor.ifive.nadab.domain.monthlyreport.application.helper; +import com.devkor.ifive.nadab.domain.dailyreport.core.entity.EmotionName; +import com.devkor.ifive.nadab.domain.weeklyreport.core.dto.DailyEntryDto; + import java.time.DayOfWeek; import java.time.LocalDate; import java.time.temporal.WeekFields; import java.util.*; import java.util.stream.Collectors; +/** + * 월간 리포트 프롬프트에 넣을 "대표 일일 리포트 샘플"을 선정합니다. + * + * 목표: + * 1) 감정 분포 상위 감정 반영(월간 톤/패턴) + * 2) 주차 커버리지 확보(특정 며칠에 쏠림 방지) + * 3) 정보량(답변/일일리포트 길이) 큰 날 우선 + * + * 출력: 날짜 오름차순 정렬된 대표 샘플 리스트 + */ public final class MonthlyRepresentativePicker { - public record DailyEntry( - LocalDate date, - String question, - String answer, - String dailyReport, - String emotion - ) {} + private MonthlyRepresentativePicker() { + } - public List pick(List entries, int maxSamples) { - if (entries == null || entries.isEmpty() || maxSamples <= 0) return List.of(); + public static List pick(List entries, int maxSamples) { + if (entries == null || entries.isEmpty() || maxSamples <= 0) { + return List.of(); + } - // 날짜순 정렬 (변화량 계산용) - List sorted = entries.stream() - .sorted(Comparator.comparing(DailyEntry::date)) + // 날짜순 정렬(전환점 계산 및 결과 정렬에도 사용) + List sorted = entries.stream() + .filter(Objects::nonNull) + .sorted(Comparator.comparing(DailyEntryDto::date, Comparator.nullsLast(Comparator.naturalOrder()))) .toList(); - // 감정 빈도 - Map emotionCount = sorted.stream() - .collect(Collectors.groupingBy(DailyEntry::emotion, Collectors.counting())); + // 감정 빈도 집계 (null emotion은 OTHER로 취급) + Map emotionCount = sorted.stream() + .map(e -> defaultEmotion(e.emotion())) + .collect(Collectors.groupingBy(e -> e, Collectors.counting())); - // 점수 계산 Map scoreByDate = computeScores(sorted, emotionCount); - // (1) 감정 분포 상위 2개에서 대표 1개씩 - List topEmotions = emotionCount.entrySet().stream() - .sorted(Map.Entry.comparingByValue().reversed()) + // (1) 감정 분포 상위 2개에서 각각 대표 1개씩 + List topEmotions = emotionCount.entrySet().stream() + .sorted(Map.Entry.comparingByValue().reversed()) .limit(2) .map(Map.Entry::getKey) .toList(); - LinkedHashSet selected = new LinkedHashSet<>(); + LinkedHashMap selected = new LinkedHashMap<>(); - for (String emo : topEmotions) { - pickBestByEmotion(sorted, emo, scoreByDate).ifPresent(selected::add); - if (selected.size() >= maxSamples) return new ArrayList<>(selected); + for (EmotionName emo : topEmotions) { + pickBestByEmotion(sorted, emo, scoreByDate) + .ifPresent(e -> selected.putIfAbsent(e.date(), e)); + if (selected.size() >= maxSamples) { + return selected.values().stream() + .sorted(Comparator.comparing(DailyEntryDto::date)) + .toList(); + } } - // (2) 주차 커버리지 확보: 주차별 최고점 1개 - WeekFields wf = WeekFields.of(DayOfWeek.MONDAY, 4); // 한국 기준 월요일 시작(대개) - Map> byWeek = sorted.stream() + // (2) 주차(week-of-month)별로 최고점 1개씩 추가해 커버리지 확보 + WeekFields wf = WeekFields.of(DayOfWeek.MONDAY, 4); // 월요일 시작(한국 기준) + + Map> byWeek = sorted.stream() + .filter(e -> e.date() != null) .collect(Collectors.groupingBy(e -> e.date().get(wf.weekOfMonth()))); List weeks = byWeek.keySet().stream().sorted().toList(); for (Integer w : weeks) { if (selected.size() >= maxSamples) break; - Optional bestOfWeek = byWeek.get(w).stream() - .filter(e -> !containsDate(selected, e.date())) + Optional bestOfWeek = byWeek.get(w).stream() + .filter(e -> !selected.containsKey(e.date())) .max(Comparator.comparingDouble(e -> scoreByDate.getOrDefault(e.date(), 0.0))); - bestOfWeek.ifPresent(selected::add); + bestOfWeek.ifPresent(e -> selected.putIfAbsent(e.date(), e)); } - // (3) 남으면 전체 점수 순으로 채우기 + // (3) 남으면 전체 점수 높은 순으로 채우기 if (selected.size() < maxSamples) { - List rest = sorted.stream() - .filter(e -> !containsDate(selected, e.date())) + List rest = sorted.stream() + .filter(e -> e.date() != null) + .filter(e -> !selected.containsKey(e.date())) .sorted((a, b) -> Double.compare( scoreByDate.getOrDefault(b.date(), 0.0), scoreByDate.getOrDefault(a.date(), 0.0) )) .toList(); - for (DailyEntry e : rest) { - selected.add(e); + for (DailyEntryDto e : rest) { + selected.putIfAbsent(e.date(), e); if (selected.size() >= maxSamples) break; } } - return new ArrayList<>(selected); - } - - private static boolean containsDate(Set set, LocalDate date) { - for (DailyEntry e : set) if (e.date().equals(date)) return true; - return false; + // 결과는 날짜 오름차순으로(LLM 입력 안정성) + return selected.values().stream() + .sorted(Comparator.comparing(DailyEntryDto::date)) + .toList(); } - private static Optional pickBestByEmotion( - List sorted, - String emotion, - Map scoreByDate - ) { - return sorted.stream() - .filter(e -> Objects.equals(e.emotion(), emotion)) - .max(Comparator.comparingDouble(e -> scoreByDate.getOrDefault(e.date(), 0.0))); + private static EmotionName defaultEmotion(EmotionName emotion) { + return (emotion != null) ? emotion : EmotionName.기타; } + /** + * 점수 구성: + * - 정보량: answer/dailyReport 길이 기반 + * - 희귀 감정 보정: 빈도 낮을수록 가산(inverse) + * - 전환점: 전날과 감정이 다르면 가산 + */ private static Map computeScores( - List sorted, - Map emotionCount + List sorted, + Map emotionCount ) { long total = emotionCount.values().stream().mapToLong(v -> v).sum(); + if (total <= 0) total = 1; - // 감정 희귀도(빈도 낮을수록 가산): inverse freq - Map rarity = new HashMap<>(); + // 희귀도: 빈도 낮을수록 가산 + Map rarity = new EnumMap<>(EmotionName.class); for (var e : emotionCount.entrySet()) { - double freq = (double) e.getValue() / (double) total; - // freq가 낮을수록 값 커짐 (최소 1.0) - rarity.put(e.getKey(), 1.0 + (1.0 - freq) * 1.5); + double freq = (double) e.getValue() / (double) total; // 0~1 + rarity.put(e.getKey(), 1.0 + (1.0 - freq) * 1.5); // 1.0~2.5 정도 } Map score = new HashMap<>(); - DailyEntry prev = null; + DailyEntryDto prev = null; + + for (DailyEntryDto cur : sorted) { + if (cur.date() == null) continue; - for (DailyEntry cur : sorted) { int answerLen = safeLen(cur.answer()); int reportLen = safeLen(cur.dailyReport()); - // 정보량: 답변/리포트가 길수록 점수 + // 정보량(로그로 완만하게) double info = Math.log1p(answerLen) * 2.0 + Math.log1p(reportLen) * 1.2; - // 희귀 감정 보정 - double rare = rarity.getOrDefault(cur.emotion(), 1.0); + EmotionName emo = defaultEmotion(cur.emotion()); + double rare = rarity.getOrDefault(emo, 1.0); - // 전환점(전날과 감정이 다르면 가산) + // 전환점(전날과 감정이 다르면 +) double change = 0.0; - if (prev != null && !Objects.equals(prev.emotion(), cur.emotion())) { - change = 0.8; + if (prev != null && prev.date() != null) { + EmotionName prevEmo = defaultEmotion(prev.emotion()); + if (!Objects.equals(prevEmo, emo)) { + change = 0.8; + } } - // 최종 점수 - double totalScore = info * rare + change; - - score.put(cur.date(), totalScore); + score.put(cur.date(), info * rare + change); prev = cur; } @@ -141,4 +160,15 @@ private static Map computeScores( private static int safeLen(String s) { return (s == null) ? 0 : s.trim().length(); } -} + + private static Optional pickBestByEmotion( + List sorted, + EmotionName emotion, + Map scoreByDate + ) { + return sorted.stream() + .filter(e -> e.date() != null) + .filter(e -> Objects.equals(defaultEmotion(e.emotion()), emotion)) + .max(Comparator.comparingDouble(e -> scoreByDate.getOrDefault(e.date(), 0.0))); + } +} \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/listener/MonthlyReportGenerationListener.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/listener/MonthlyReportGenerationListener.java new file mode 100644 index 0000000..f6f73e7 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/listener/MonthlyReportGenerationListener.java @@ -0,0 +1,99 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.application.listener; + +import com.devkor.ifive.nadab.domain.monthlyreport.application.MonthlyReportTxService; +import com.devkor.ifive.nadab.domain.monthlyreport.application.helper.MonthlyRepresentativePicker; +import com.devkor.ifive.nadab.domain.monthlyreport.core.dto.AiMonthlyReportResultDto; +import com.devkor.ifive.nadab.domain.monthlyreport.core.dto.MonthlyReportGenerationRequestedEventDto; +import com.devkor.ifive.nadab.domain.monthlyreport.core.repository.MonthlyQueryRepository; +import com.devkor.ifive.nadab.domain.monthlyreport.core.service.MonthlyWeeklySummariesService; +import com.devkor.ifive.nadab.domain.monthlyreport.infra.MonthlyReportLlmClient; +import com.devkor.ifive.nadab.domain.weeklyreport.application.helper.WeeklyEntriesAssembler; +import com.devkor.ifive.nadab.domain.weeklyreport.core.dto.DailyEntryDto; +import com.devkor.ifive.nadab.global.shared.util.MonthRangeCalculator; +import com.devkor.ifive.nadab.global.shared.util.dto.MonthRangeDto; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import java.util.List; + +@Component +@RequiredArgsConstructor +@Slf4j +public class MonthlyReportGenerationListener { + + private final MonthlyQueryRepository monthlyQueryRepository; + + private final MonthlyReportLlmClient monthlyReportLlmClient; + private final MonthlyReportTxService monthlyReportTxService; + private final MonthlyWeeklySummariesService monthlyWeeklySummariesService; + + private static final int MAX_LEN = 150; + + @Async("monthlyReportTaskExecutor") + @TransactionalEventListener(phase = + TransactionPhase.AFTER_COMMIT) + public void handle(MonthlyReportGenerationRequestedEventDto event) { + + MonthRangeDto range = MonthRangeCalculator.getLastMonthRange(); + + // 1. 일간 리포트 대표 항목 선택 + List rows = monthlyQueryRepository.findMonthlyInputs(event.userId(), range.monthStartDate(), range.monthEndDate()); + List entries = MonthlyRepresentativePicker.pick(rows, 6); + String representativeEntries = WeeklyEntriesAssembler.assemble(entries); + + // 2. 주간 리포트 선택 + String weeklySummaries = monthlyWeeklySummariesService.buildWeeklySummaries(event.userId(), range); + + AiMonthlyReportResultDto dto; + try { + // 트랜잭션 밖(백그라운드)에서 LLM 호출 + dto = monthlyReportLlmClient.generate( + range.monthStartDate().toString(), range.monthEndDate().toString(), weeklySummaries, representativeEntries); + } catch (Exception e) { + log.error("[MONTHLY_REPORT][LLM_FAILED] userId={}, reportId={}", + event.userId(), event.reportId(), e); + + // 실패 확정 + 환불은 별도 트랜잭션에서 + monthlyReportTxService.failAndRefundMonthly( + event.userId(), + event.reportId(), + event.crystalLogId() + ); + return; + } + + // 성공 확정(별도 트랜잭션) + try { + monthlyReportTxService.confirmMonthly( + event.reportId(), + event.crystalLogId(), + cut(dto.discovered()), + cut(dto.good()), + cut(dto.improve()) + ); + } catch (Exception e) { + log.error("[MONTHLY_REPORT][CONFIRM_FAILED] userId={}, reportId={}, crystalLogId={}", + event.userId(), event.reportId(), event.crystalLogId(), e); + + // 저장 실패면 결과를 못 주는 거니까 "실패 확정 + 환불"로 처리 + monthlyReportTxService.failAndRefundMonthly( + event.userId(), + event.reportId(), + event.crystalLogId() + ); + } + + } + + // 최대 길이 자르기 + private String cut(String s) { + if (s == null) return null; + s = s.trim(); + return (s.length() <= MAX_LEN) ? s : s.substring(0, MAX_LEN); + } +} + diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/repository/MonthlyQueryRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/repository/MonthlyQueryRepository.java new file mode 100644 index 0000000..cc82e6f --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/repository/MonthlyQueryRepository.java @@ -0,0 +1,40 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.core.repository; + +import com.devkor.ifive.nadab.domain.dailyreport.core.entity.AnswerEntry; +import com.devkor.ifive.nadab.domain.weeklyreport.core.dto.DailyEntryDto; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDate; +import java.util.List; + +public interface MonthlyQueryRepository extends JpaRepository { + + /** + * 월간 리포트 작성을 위한 입력 데이터 조회 + */ + @Query(""" + select new com.devkor.ifive.nadab.domain.weeklyreport.core.dto.DailyEntryDto( + ae.date, + dq.questionText, + ae.content, + dr.content, + em.name + ) + from AnswerEntry ae + join ae.question dq + left join DailyReport dr + on dr.answerEntry = ae + and dr.status = com.devkor.ifive.nadab.domain.dailyreport.core.entity.DailyReportStatus.COMPLETED + left join dr.emotion em + where ae.user.id = :userId + and ae.date between :monthStart and :monthEnd + order by ae.date asc +""") + List findMonthlyInputs( + @Param("userId") Long userId, + @Param("monthStart") LocalDate monthStart, + @Param("monthEnd") LocalDate monthEnd + ); +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/application/helper/WeeklyEntriesAssembler.java b/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/application/helper/WeeklyEntriesAssembler.java index 5d95c1a..a37301b 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/application/helper/WeeklyEntriesAssembler.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/application/helper/WeeklyEntriesAssembler.java @@ -1,7 +1,7 @@ package com.devkor.ifive.nadab.domain.weeklyreport.application.helper; import com.devkor.ifive.nadab.domain.dailyreport.core.entity.EmotionName; -import com.devkor.ifive.nadab.domain.weeklyreport.core.dto.WeeklyReportEntryInputDto; +import com.devkor.ifive.nadab.domain.weeklyreport.core.dto.DailyEntryDto; import java.util.Comparator; import java.util.List; @@ -20,20 +20,20 @@ private WeeklyEntriesAssembler() { * - D1, D2 ... 번호 부여 * - null/blank 값은 N/A로 대체 */ - public static String assemble(List inputs) { + public static String assemble(List inputs) { if (inputs == null || inputs.isEmpty()) { return ""; } - List sorted = inputs.stream() + List sorted = inputs.stream() .filter(Objects::nonNull) - .sorted(Comparator.comparing(WeeklyReportEntryInputDto::date, Comparator.nullsLast(Comparator.naturalOrder()))) + .sorted(Comparator.comparing(DailyEntryDto::date, Comparator.nullsLast(Comparator.naturalOrder()))) .toList(); StringBuilder sb = new StringBuilder(); int idx = 1; - for (WeeklyReportEntryInputDto e : sorted) { + for (DailyEntryDto e : sorted) { sb.append("- D").append(idx).append("(").append(valueOrNa(e.date() == null ? null : e.date().toString())).append(")\n") .append(" question: ").append(valueOrNa(e.question())).append("\n") .append(" answer: ").append(valueOrNa(e.answer())).append("\n") diff --git a/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/application/helper/WeeklyReportGenerationListener.java b/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/application/listener/WeeklyReportGenerationListener.java similarity index 89% rename from src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/application/helper/WeeklyReportGenerationListener.java rename to src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/application/listener/WeeklyReportGenerationListener.java index a68781a..3eb737d 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/application/helper/WeeklyReportGenerationListener.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/application/listener/WeeklyReportGenerationListener.java @@ -1,8 +1,9 @@ -package com.devkor.ifive.nadab.domain.weeklyreport.application.helper; +package com.devkor.ifive.nadab.domain.weeklyreport.application.listener; import com.devkor.ifive.nadab.domain.weeklyreport.application.WeeklyReportTxService; +import com.devkor.ifive.nadab.domain.weeklyreport.application.helper.WeeklyEntriesAssembler; import com.devkor.ifive.nadab.domain.weeklyreport.core.dto.AiWeeklyReportResultDto; -import com.devkor.ifive.nadab.domain.weeklyreport.core.dto.WeeklyReportEntryInputDto; +import com.devkor.ifive.nadab.domain.weeklyreport.core.dto.DailyEntryDto; import com.devkor.ifive.nadab.domain.weeklyreport.core.dto.WeeklyReportGenerationRequestedEventDto; import com.devkor.ifive.nadab.domain.weeklyreport.infra.WeeklyReportLlmClient; import com.devkor.ifive.nadab.domain.weeklyreport.core.repository.WeeklyQueryRepository; @@ -36,7 +37,7 @@ public void handle(WeeklyReportGenerationRequestedEventDto event) { WeekRangeDto range = WeekRangeCalculator.getLastWeekRange(); - List rows = weeklyQueryRepository.findWeeklyInputs(event.userId(), range.weekStartDate(), range.weekEndDate()); + List rows = weeklyQueryRepository.findWeeklyInputs(event.userId(), range.weekStartDate(), range.weekEndDate()); String entries = WeeklyEntriesAssembler.assemble(rows); AiWeeklyReportResultDto dto; diff --git a/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/core/dto/WeeklyReportEntryInputDto.java b/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/core/dto/DailyEntryDto.java similarity index 87% rename from src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/core/dto/WeeklyReportEntryInputDto.java rename to src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/core/dto/DailyEntryDto.java index e01ea88..224dd81 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/core/dto/WeeklyReportEntryInputDto.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/core/dto/DailyEntryDto.java @@ -4,7 +4,7 @@ import java.time.LocalDate; -public record WeeklyReportEntryInputDto( +public record DailyEntryDto( LocalDate date, String question, String answer, diff --git a/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/core/repository/WeeklyQueryRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/core/repository/WeeklyQueryRepository.java index e053070..8b4391b 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/core/repository/WeeklyQueryRepository.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/core/repository/WeeklyQueryRepository.java @@ -1,7 +1,7 @@ package com.devkor.ifive.nadab.domain.weeklyreport.core.repository; import com.devkor.ifive.nadab.domain.dailyreport.core.entity.AnswerEntry; -import com.devkor.ifive.nadab.domain.weeklyreport.core.dto.WeeklyReportEntryInputDto; +import com.devkor.ifive.nadab.domain.weeklyreport.core.dto.DailyEntryDto; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.Repository; import org.springframework.data.repository.query.Param; @@ -15,7 +15,7 @@ public interface WeeklyQueryRepository extends Repository { * 주간 리포트 작성을 위한 입력 데이터 조회 */ @Query(""" - select new com.devkor.ifive.nadab.domain.weeklyreport.core.dto.WeeklyReportEntryInputDto( + select new com.devkor.ifive.nadab.domain.weeklyreport.core.dto.DailyEntryDto( ae.date, dq.questionText, ae.content, @@ -33,7 +33,7 @@ public interface WeeklyQueryRepository extends Repository { order by ae.date asc """) - List findWeeklyInputs( + List findWeeklyInputs( @Param("userId") Long userId, @Param("weekStart") LocalDate weekStart, @Param("weekEnd") LocalDate weekEnd diff --git a/src/main/java/com/devkor/ifive/nadab/global/core/config/infra/AsyncConfig.java b/src/main/java/com/devkor/ifive/nadab/global/core/config/infra/AsyncConfig.java index 000dd13..074aafc 100644 --- a/src/main/java/com/devkor/ifive/nadab/global/core/config/infra/AsyncConfig.java +++ b/src/main/java/com/devkor/ifive/nadab/global/core/config/infra/AsyncConfig.java @@ -55,4 +55,19 @@ public ThreadPoolTaskExecutor weeklyReportTaskExecutor() { executor.initialize(); return executor; } + + /** + * 월간 리포트(LLM 호출) 전용 실행기 + * - LLM은 외부 I/O라 동시성 높게 주되, 무한 큐 적체는 막기 위해 큐 제한 + */ + @Bean(name = "monthlyReportTaskExecutor") + public ThreadPoolTaskExecutor monthlyReportTaskExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(4); + executor.setMaxPoolSize(8); + executor.setQueueCapacity(50); + executor.setThreadNamePrefix("async-monthly-report-"); + executor.initialize(); + return executor; + } } \ No newline at end of file From fd0f3884e481d929e0182ec4c4a37f2d1c2ac271 Mon Sep 17 00:00:00 2001 From: 1Seob Date: Sun, 11 Jan 2026 19:43:05 +0900 Subject: [PATCH 12/18] =?UTF-8?q?feat(report):=20MonthlyReportService=20?= =?UTF-8?q?=ED=81=B4=EB=9E=98=EC=8A=A4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/DailyReportRepository.java | 9 ++++ .../response/MonthlyReportStartResponse.java | 15 ++++++ .../application/MonthlyReportService.java | 49 +++++++++++++++++++ .../application/WeeklyReportService.java | 9 ---- .../nadab/global/core/response/ErrorCode.java | 3 ++ .../MonthlyReportNotEligibleException.java | 16 ++++++ 6 files changed, 92 insertions(+), 9 deletions(-) create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/api/dto/response/MonthlyReportStartResponse.java create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyReportService.java create mode 100644 src/main/java/com/devkor/ifive/nadab/global/exception/report/MonthlyReportNotEligibleException.java diff --git a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/core/repository/DailyReportRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/core/repository/DailyReportRepository.java index eca6a27..9d497e4 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/core/repository/DailyReportRepository.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/core/repository/DailyReportRepository.java @@ -58,6 +58,15 @@ default long countCompletedInWeek(Long userId, LocalDate weekStartDate, LocalDat ); } + default long countCompletedInMonth(Long userId, LocalDate monthStartDate, LocalDate monthEndDate) { + return countByAnswerEntry_User_IdAndStatusAndDateBetween( + userId, + DailyReportStatus.COMPLETED, + monthStartDate, + monthEndDate + ); + } + @Modifying(clearAutomatically = true, flushAutomatically = true) @Query("UPDATE DailyReport w SET w.status = :status WHERE w.id = :id") int updateStatus( diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/api/dto/response/MonthlyReportStartResponse.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/api/dto/response/MonthlyReportStartResponse.java new file mode 100644 index 0000000..6dcff7b --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/api/dto/response/MonthlyReportStartResponse.java @@ -0,0 +1,15 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.api.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "월간 리포트 생성 시작 응답") +public record MonthlyReportStartResponse( + @Schema(description = "생성 예정 월간 리포트 ID", example = "1") + Long reportId, + + @Schema(description = "상태", example = "PENDING") + String status, + + @Schema(description = "리포트 작성 후 크리스탈 잔액", example = "100") + Long balanceAfter +) {} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyReportService.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyReportService.java new file mode 100644 index 0000000..0f97a8e --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyReportService.java @@ -0,0 +1,49 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.application; + +import com.devkor.ifive.nadab.domain.dailyreport.core.repository.DailyReportRepository; +import com.devkor.ifive.nadab.domain.monthlyreport.api.dto.response.MonthlyReportStartResponse; +import com.devkor.ifive.nadab.domain.monthlyreport.core.dto.MonthlyReserveResultDto; +import com.devkor.ifive.nadab.domain.user.core.entity.User; +import com.devkor.ifive.nadab.domain.user.core.repository.UserRepository; +import com.devkor.ifive.nadab.domain.weeklyreport.api.dto.response.CompletedCountResponse; +import com.devkor.ifive.nadab.global.core.response.ErrorCode; +import com.devkor.ifive.nadab.global.exception.NotFoundException; +import com.devkor.ifive.nadab.global.exception.report.MonthlyReportNotEligibleException; +import com.devkor.ifive.nadab.global.shared.util.MonthRangeCalculator; +import com.devkor.ifive.nadab.global.shared.util.dto.MonthRangeDto; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class MonthlyReportService { + + private final UserRepository userRepository; + private final DailyReportRepository dailyReportRepository; + + private final MonthlyReportTxService monthlyReportTxService; + + /** + * 비동기 시작 API: 즉시 reportId 반환 + */ + public MonthlyReportStartResponse startMonthlyReport(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new NotFoundException(ErrorCode.USER_NOT_FOUND)); + + // 월간 리포트 작성 자격 확인 (저번 달에 15회 이상 완료) + MonthRangeDto range = MonthRangeCalculator.getLastMonthRange(); + + long completedCount = dailyReportRepository.countCompletedInMonth(userId, range.monthStartDate(), range.monthEndDate()); + boolean eligible = completedCount >= 15; + + if (!eligible) { + CompletedCountResponse response = new CompletedCountResponse(completedCount); + throw new MonthlyReportNotEligibleException(ErrorCode.MONTHLY_REPORT_NOT_ENOUGH_REPORTS, response); + } + + // (Tx) Report(PENDING) + reserve consume + log(PENDING) + MonthlyReserveResultDto reserve = monthlyReportTxService.reserveMonthlyAndPublish(user); + + return new MonthlyReportStartResponse(reserve.reportId(), "PENDING", reserve.balanceAfter()); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/application/WeeklyReportService.java b/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/application/WeeklyReportService.java index 7fb33fe..7ff1a7b 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/application/WeeklyReportService.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/application/WeeklyReportService.java @@ -3,13 +3,10 @@ import com.devkor.ifive.nadab.domain.dailyreport.core.repository.DailyReportRepository; import com.devkor.ifive.nadab.domain.user.core.entity.User; import com.devkor.ifive.nadab.domain.user.core.repository.UserRepository; -import com.devkor.ifive.nadab.domain.wallet.core.entity.UserWallet; -import com.devkor.ifive.nadab.domain.wallet.core.repository.UserWalletRepository; import com.devkor.ifive.nadab.domain.weeklyreport.api.dto.response.CompletedCountResponse; import com.devkor.ifive.nadab.domain.weeklyreport.api.dto.response.WeeklyReportStartResponse; import com.devkor.ifive.nadab.domain.weeklyreport.core.dto.WeeklyReserveResultDto; import com.devkor.ifive.nadab.global.core.response.ErrorCode; -import com.devkor.ifive.nadab.global.exception.BadRequestException; import com.devkor.ifive.nadab.global.exception.NotFoundException; import com.devkor.ifive.nadab.global.exception.report.WeeklyReportNotEligibleException; import com.devkor.ifive.nadab.global.shared.util.WeekRangeCalculator; @@ -23,21 +20,15 @@ public class WeeklyReportService { private final UserRepository userRepository; private final DailyReportRepository dailyReportRepository; - private final UserWalletRepository userWalletRepository; private final WeeklyReportTxService weeklyReportTxService; - private static final long WEEKLY_REPORT_COST = 30L; - /** * 비동기 시작 API: 즉시 reportId 반환 */ public WeeklyReportStartResponse startWeeklyReport(Long userId) { User user = userRepository.findById(userId) .orElseThrow(() -> new NotFoundException(ErrorCode.USER_NOT_FOUND)); - UserWallet wallet = userWalletRepository.findByUserId(userId) - .orElseThrow(() -> new NotFoundException(ErrorCode.WALLET_NOT_FOUND)); - // 주간 리포트 작성 자격 확인 (저번 주에 4회 이상 완료) WeekRangeDto range = WeekRangeCalculator.getLastWeekRange(); diff --git a/src/main/java/com/devkor/ifive/nadab/global/core/response/ErrorCode.java b/src/main/java/com/devkor/ifive/nadab/global/core/response/ErrorCode.java index 4199c4e..3295249 100644 --- a/src/main/java/com/devkor/ifive/nadab/global/core/response/ErrorCode.java +++ b/src/main/java/com/devkor/ifive/nadab/global/core/response/ErrorCode.java @@ -134,6 +134,9 @@ public enum ErrorCode { WEEKLY_REPORT_IN_PROGRESS(HttpStatus.CONFLICT, "현재 주간 리포트를 생성 중입니다"), // ==================== MONTHLY_REPORT (월간 리포트) ==================== + // 400 Bad Request + MONTHLY_REPORT_NOT_ENOUGH_REPORTS(HttpStatus.BAD_REQUEST, "월간 리포트 작성 자격이 없습니다. (지난 달 15회 이상 완료 필요)"), + // 404 Not Found MONTHLY_REPORT_NOT_FOUND(HttpStatus.NOT_FOUND, "월간 리포트를 찾을 수 없습니다"), MONTHLY_REPORT_NOT_COMPLETED(HttpStatus.NOT_FOUND, "해당 월간 리포트가 아직 생성 완료되지 않았습니다"), diff --git a/src/main/java/com/devkor/ifive/nadab/global/exception/report/MonthlyReportNotEligibleException.java b/src/main/java/com/devkor/ifive/nadab/global/exception/report/MonthlyReportNotEligibleException.java new file mode 100644 index 0000000..b2ad00d --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/global/exception/report/MonthlyReportNotEligibleException.java @@ -0,0 +1,16 @@ +package com.devkor.ifive.nadab.global.exception.report; + +import com.devkor.ifive.nadab.domain.weeklyreport.api.dto.response.CompletedCountResponse; +import com.devkor.ifive.nadab.global.core.response.ErrorCode; +import com.devkor.ifive.nadab.global.exception.BadRequestException; +import lombok.Getter; + +@Getter +public class MonthlyReportNotEligibleException extends BadRequestException { + private final CompletedCountResponse completedCountResponse; + + public MonthlyReportNotEligibleException(ErrorCode errorCode, CompletedCountResponse completedCount) { + super(errorCode); + this.completedCountResponse = completedCount; + } +} From ed5fd691c588f867c0f1c6360fb6bd56a5dbbb54 Mon Sep 17 00:00:00 2001 From: 1Seob Date: Sun, 11 Jan 2026 20:04:24 +0900 Subject: [PATCH 13/18] =?UTF-8?q?feat(report):=20MonthlyReportQueryService?= =?UTF-8?q?=20=ED=81=B4=EB=9E=98=EC=8A=A4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/response/MonthlyReportResponse.java | 23 +++++++ .../MonthlyReportQueryService.java | 62 +++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/api/dto/response/MonthlyReportResponse.java create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyReportQueryService.java diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/api/dto/response/MonthlyReportResponse.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/api/dto/response/MonthlyReportResponse.java new file mode 100644 index 0000000..93d6875 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/api/dto/response/MonthlyReportResponse.java @@ -0,0 +1,23 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.api.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "월간 리포트 조회 응답") +public record MonthlyReportResponse( + + @Schema(description = "리포트가 작성된 달") + int month, + + @Schema(description = "이런 면도 발견되었어요") + String discovered, + + @Schema(description = "이런 점이 좋았어요") + String good, + + @Schema(description = "다음엔 이렇게 보완해볼까요?") + String improve, + + @Schema(description = "상태", example = "PENDING") + String status +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyReportQueryService.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyReportQueryService.java new file mode 100644 index 0000000..8108c7f --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyReportQueryService.java @@ -0,0 +1,62 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.application; + +import com.devkor.ifive.nadab.domain.monthlyreport.api.dto.response.MonthlyReportResponse; +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReport; +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportStatus; +import com.devkor.ifive.nadab.domain.monthlyreport.core.repository.MonthlyReportRepository; +import com.devkor.ifive.nadab.domain.user.core.entity.User; +import com.devkor.ifive.nadab.domain.user.core.repository.UserRepository; +import com.devkor.ifive.nadab.global.core.response.ErrorCode; +import com.devkor.ifive.nadab.global.exception.NotFoundException; +import com.devkor.ifive.nadab.global.shared.util.MonthRangeCalculator; +import com.devkor.ifive.nadab.global.shared.util.dto.MonthRangeDto; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MonthlyReportQueryService { + + private final MonthlyReportRepository monthlyReportRepository; + private final UserRepository userRepository; + + public MonthlyReportResponse getLastMonthMonthlyReport(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new NotFoundException(ErrorCode.USER_NOT_FOUND)); + + MonthRangeDto range = MonthRangeCalculator.getLastMonthRange(); + + MonthlyReport report = monthlyReportRepository.findByUserIdAndMonthStartDate(user.getId(), range.monthStartDate()) + .orElseThrow(() -> new NotFoundException(ErrorCode.MONTHLY_REPORT_NOT_FOUND)); + + if (report.getStatus() != MonthlyReportStatus.COMPLETED) { + throw new NotFoundException(ErrorCode.MONTHLY_REPORT_NOT_COMPLETED); + } + + return new MonthlyReportResponse( + range.monthStartDate().getMonthValue(), + report.getDiscovered(), + report.getGood(), + report.getImprove(), + report.getStatus().name() + ); + } + + public MonthlyReportResponse getMonthlyReportById(Long id) { + MonthlyReport report = monthlyReportRepository.findById(id) + .orElseThrow(() -> new NotFoundException(ErrorCode.MONTHLY_REPORT_NOT_FOUND)); + + MonthRangeDto range = MonthRangeCalculator.monthRangeOf(report.getMonthStartDate()); + + return new MonthlyReportResponse( + range.monthStartDate().getMonthValue(), + report.getDiscovered(), + report.getGood(), + report.getImprove(), + report.getStatus().name() + ); + } +} + From fd088e129c072c208bdda8bb5e6313eb8ff52d0e Mon Sep 17 00:00:00 2001 From: 1Seob Date: Sun, 11 Jan 2026 21:54:35 +0900 Subject: [PATCH 14/18] =?UTF-8?q?feat(report):=20MonthlyReportController?= =?UTF-8?q?=20=ED=81=B4=EB=9E=98=EC=8A=A4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/MonthlyReportController.java | 151 ++++++++++++++++++ .../MonthlyReportGenerationListener.java | 2 +- .../WeeklyReportGenerationListener.java | 2 +- .../infra/WeeklyReportLlmClient.java | 2 +- .../global/exception/ExceptionController.java | 7 + 5 files changed, 161 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/api/MonthlyReportController.java diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/api/MonthlyReportController.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/api/MonthlyReportController.java new file mode 100644 index 0000000..0c2ca90 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/api/MonthlyReportController.java @@ -0,0 +1,151 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.api; + +import com.devkor.ifive.nadab.domain.monthlyreport.api.dto.response.MonthlyReportResponse; +import com.devkor.ifive.nadab.domain.monthlyreport.api.dto.response.MonthlyReportStartResponse; +import com.devkor.ifive.nadab.domain.monthlyreport.application.MonthlyReportQueryService; +import com.devkor.ifive.nadab.domain.monthlyreport.application.MonthlyReportService; +import com.devkor.ifive.nadab.domain.weeklyreport.api.dto.response.CompletedCountResponse; +import com.devkor.ifive.nadab.global.core.response.ApiResponseDto; +import com.devkor.ifive.nadab.global.core.response.ApiResponseEntity; +import com.devkor.ifive.nadab.global.security.principal.UserPrincipal; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "월간 리포트 API", description = "월간 리포트 생성 및 조회 관련 API") +@RestController +@RequestMapping("${api_prefix}/monthly-report") +@RequiredArgsConstructor +public class MonthlyReportController { + + private final MonthlyReportService monthlyReportService; + private final MonthlyReportQueryService monthlyReportQueryService; + + @PostMapping("/start") + @PreAuthorize("isAuthenticated()") + @Operation( + summary = "월간 리포트 생성 시작", + description = """ + 사용자의 (지난 달에 대한) 월간 리포트 생성을 시작합니다.
+ 비동기로 처리되기 때문에, id로 월간 리포트 조회 API를 폴링하여 상태를 확인할 수 있습니다. + """, + security = @SecurityRequirement(name = "bearerAuth"), + responses = { + @ApiResponse( + responseCode = "200", + description = "월간 리포트 생성 시작 성공", + content = @Content(schema = @Schema(implementation = MonthlyReportStartResponse.class), mediaType = "application/json") + ), + @ApiResponse( + responseCode = "400", + description = """ + - ErrorCode: MONTHLY_REPORT_NOT_ENOUGH_REPORTS - 월간 리포트 작성 자격 미달 **(이 경우 data의 completedCount 필드에 지난 주에 작성된 오늘의 리포트 수가 포함됩니다.)** + - ErrorCode: WALLET_INSUFFICIENT_BALANCE - 크리스탈 잔액 부족 + """, + content = @Content(schema = @Schema(implementation = CompletedCountResponse.class), mediaType = "application/json") + ), + @ApiResponse( + responseCode = "401", + description = "인증 실패", + content = @Content + ), + @ApiResponse( + responseCode = "404", + description = """ + - ErrorCode: USER_NOT_FOUND - 사용자를 찾을 수 없음 + - ErrorCode: WALLET_NOT_FOUND - 지갑을 찾을 수 없음 + """, + content = @Content + ), + @ApiResponse( + responseCode = "409", + description = """ + - ErrorCode: MONTHLY_REPORT_ALREADY_COMPLETED - 이미 작성된 월간 리포트가 존재함 + - ErrorCode: MONTHLY_REPORT_IN_PROGRESS - 현재 월간 리포트를 생성 중임 + """, + content = @Content + ) + } + ) + public ResponseEntity> startMonthlyReport( + @AuthenticationPrincipal UserPrincipal principal + ) { + MonthlyReportStartResponse response = monthlyReportService.startMonthlyReport(principal.getId()); + return ApiResponseEntity.ok(response); + } + + @GetMapping + @PreAuthorize("isAuthenticated()") + @Operation( + summary = "나의 월간 리포트 조회", + description = "사용자의 (지난 달) 월간 리포트를 조회합니다.", + security = @SecurityRequirement(name = "bearerAuth"), + responses = { + @ApiResponse( + responseCode = "200", + description = "나의 월간 리포트 조회 성공", + content = @Content(schema = @Schema(implementation = MonthlyReportResponse.class), mediaType = "application/json") + ), + @ApiResponse( + responseCode = "401", + description = "인증 실패", + content = @Content + ), + @ApiResponse( + responseCode = "404", + description = """ + - ErrorCode: USER_NOT_FOUND - 사용자를 찾을 수 없음 + - ErrorCode: MONTHLY_REPORT_NOT_FOUND - 월간 리포트를 찾을 수 없음 + - ErrorCode: MONTHLY_REPORT_NOT_COMPLETED - 해당 월간 리포트가 아직 생성 완료되지 않음 + """, + content = @Content + ) + } + ) + public ResponseEntity> getLastMonthMonthlyReport( + @AuthenticationPrincipal UserPrincipal principal + ) { + MonthlyReportResponse response = monthlyReportQueryService.getLastMonthMonthlyReport(principal.getId()); + return ApiResponseEntity.ok(response); + } + + @GetMapping("/{id}") + @PreAuthorize("isAuthenticated()") + @Operation( + summary = "id로 월간 리포트 조회", + description = """ + 월간 리포트를 id로 조회합니다.
+ 생성 대기 중인 경우 ```status = "PENDING"``` 으로 반환됩니다.
+ 생성 진행 중인 경우 ```status = "IN_PROGRESS"``` 로 반환됩니다.
+ 생성에 성공한 경우 ```status = "COMPLETED"``` 로 반환됩니다.
+ 생성에 실패한 경우 ```status = "FAILED"``` 로 반환됩니다. 이때 크리스탈이 환불되기 때문에 잔액 조회를 해야합니다. + """, + security = @SecurityRequirement(name = "bearerAuth"), + responses = { + @ApiResponse( + responseCode = "200", + description = "월간 리포트 조회 성공", + content = @Content(schema = @Schema(implementation = MonthlyReportResponse.class), mediaType = "application/json") + ), + @ApiResponse( + responseCode = "404", + description = "- ErrorCode: MONTHLY_REPORT_NOT_FOUND - 월간 리포트를 찾을 수 없음", + content = @Content + ) + } + ) + public ResponseEntity> getMonthlyReportById( + @PathVariable Long id + ) { + MonthlyReportResponse response = monthlyReportQueryService.getMonthlyReportById(id); + return ApiResponseEntity.ok(response); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/listener/MonthlyReportGenerationListener.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/listener/MonthlyReportGenerationListener.java index f6f73e7..2ae4798 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/listener/MonthlyReportGenerationListener.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/listener/MonthlyReportGenerationListener.java @@ -31,7 +31,7 @@ public class MonthlyReportGenerationListener { private final MonthlyReportTxService monthlyReportTxService; private final MonthlyWeeklySummariesService monthlyWeeklySummariesService; - private static final int MAX_LEN = 150; + private static final int MAX_LEN = 200; @Async("monthlyReportTaskExecutor") @TransactionalEventListener(phase = diff --git a/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/application/listener/WeeklyReportGenerationListener.java b/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/application/listener/WeeklyReportGenerationListener.java index 3eb737d..fb8453e 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/application/listener/WeeklyReportGenerationListener.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/application/listener/WeeklyReportGenerationListener.java @@ -28,7 +28,7 @@ public class WeeklyReportGenerationListener { private final WeeklyReportLlmClient weeklyReportLlmClient; private final WeeklyReportTxService weeklyReportTxService; - private static final int MAX_LEN = 150; + private static final int MAX_LEN = 200; @Async("weeklyReportTaskExecutor") @TransactionalEventListener(phase = diff --git a/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/infra/WeeklyReportLlmClient.java b/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/infra/WeeklyReportLlmClient.java index f4dd36f..d666b44 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/infra/WeeklyReportLlmClient.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/infra/WeeklyReportLlmClient.java @@ -34,7 +34,7 @@ public AiWeeklyReportResultDto generate(String weekStartDate, String weekEndDate OpenAiChatOptions options = OpenAiChatOptions.builder() .model(OpenAiApi.ChatModel.GPT_5_MINI) - .reasoningEffort("low") + .reasoningEffort("medium") .temperature(1.0) .build(); diff --git a/src/main/java/com/devkor/ifive/nadab/global/exception/ExceptionController.java b/src/main/java/com/devkor/ifive/nadab/global/exception/ExceptionController.java index 9696525..e50e715 100644 --- a/src/main/java/com/devkor/ifive/nadab/global/exception/ExceptionController.java +++ b/src/main/java/com/devkor/ifive/nadab/global/exception/ExceptionController.java @@ -7,6 +7,7 @@ import com.devkor.ifive.nadab.global.core.response.ErrorCode; import com.devkor.ifive.nadab.global.exception.ai.AiResponseParseException; import com.devkor.ifive.nadab.global.exception.ai.AiServiceUnavailableException; +import com.devkor.ifive.nadab.global.exception.report.MonthlyReportNotEligibleException; import com.devkor.ifive.nadab.global.exception.report.WeeklyReportNotEligibleException; import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; @@ -116,6 +117,12 @@ public ResponseEntity> handleWeeklyR return ApiResponseEntity.error(ex.getErrorCode(), ex.getCompletedCountResponse()); } + @ExceptionHandler(MonthlyReportNotEligibleException.class) + public ResponseEntity> handleMonthlyReportNotEligibleException(MonthlyReportNotEligibleException ex) { + log.warn("MonthlyReportNotEligibleException: {}", ex.getMessage(), ex); + return ApiResponseEntity.error(ex.getErrorCode(), ex.getCompletedCountResponse()); + } + @ExceptionHandler(RuntimeException.class) public ResponseEntity> handleRuntimeException(RuntimeException ex) { if (ex instanceof AuthenticationException || ex instanceof AccessDeniedException) { From f8650bfc8eb25a481251c94992b0ee23da4aea3f Mon Sep 17 00:00:00 2001 From: 1Seob Date: Sun, 11 Jan 2026 21:59:51 +0900 Subject: [PATCH 15/18] =?UTF-8?q?refactor(report):=20=EC=A3=BC=EA=B0=84,?= =?UTF-8?q?=20=EC=9B=94=EA=B0=84=20=EB=A6=AC=ED=8F=AC=ED=8A=B8=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EB=B9=84=EC=9A=A9=20=EC=A1=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../monthlyreport/application/MonthlyReportTxService.java | 2 +- .../domain/weeklyreport/application/WeeklyReportTxService.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyReportTxService.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyReportTxService.java index 5f93b21..c5a4601 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyReportTxService.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyReportTxService.java @@ -34,7 +34,7 @@ public class MonthlyReportTxService { private final ApplicationEventPublisher eventPublisher; - private static final long MONTHLY_REPORT_COST = 200L; + private static final long MONTHLY_REPORT_COST = 40L; /** * (Tx) MonthlyReport(PENDING) + reserve consume + CrystalLog(PENDING) diff --git a/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/application/WeeklyReportTxService.java b/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/application/WeeklyReportTxService.java index 5a2684f..0b18c4d 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/application/WeeklyReportTxService.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/application/WeeklyReportTxService.java @@ -33,7 +33,7 @@ public class WeeklyReportTxService { private final ApplicationEventPublisher eventPublisher; - private static final long WEEKLY_REPORT_COST = 30L; + private static final long WEEKLY_REPORT_COST = 20L; /** * (Tx) WeeklyReport(PENDING) + reserve consume + CrystalLog(PENDING) From 2465afcbd5492be547e6bfbdcd36518c84635cf4 Mon Sep 17 00:00:00 2001 From: 1Seob Date: Sun, 11 Jan 2026 22:47:39 +0900 Subject: [PATCH 16/18] =?UTF-8?q?feat(report):=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EC=9A=A9=20=EC=9B=94=EA=B0=84=20=EB=A6=AC=ED=8F=AC?= =?UTF-8?q?=ED=8A=B8=20=EC=82=AD=EC=A0=9C=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../nadab/domain/test/api/TestController.java | 30 +++++++++++ .../test/application/TestReportService.java | 52 ++++++++++++++++++- .../wallet/core/entity/CrystalLogReason.java | 1 + 3 files changed, 82 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/devkor/ifive/nadab/domain/test/api/TestController.java b/src/main/java/com/devkor/ifive/nadab/domain/test/api/TestController.java index 19434e4..6a8081b 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/test/api/TestController.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/test/api/TestController.java @@ -146,4 +146,34 @@ public ResponseEntity> deleteWeeklyReport( testReportService.deleteThisWeekWeeklyReport(principal.getId()); return ApiResponseEntity.noContent(); } + + @PostMapping("/delete/monthly-report") + @PreAuthorize("isAuthenticated()") + @Operation( + summary = "(테스트용) 월간 리포트 삭제 API", + description = """ + 이번 달에 생성된 월간 리포트를 삭제합니다.
+ 생성된 리포트만 삭제 가능합니다.
+ 크리스탈 또한 환불됩니다. + """, + security = @SecurityRequirement(name = "bearerAuth"), + responses = { + @ApiResponse( + responseCode = "204", + description = "테스트용 월간 리포트 삭제 성공", + content = @Content + ), + @ApiResponse( + responseCode = "401", + description = "인증 실패", + content = @Content + ) + } + ) + public ResponseEntity> deleteMonthlyReport( + @AuthenticationPrincipal UserPrincipal principal + ) { + testReportService.deleteThisMonthMonthlyReport(principal.getId()); + return ApiResponseEntity.noContent(); + } } diff --git a/src/main/java/com/devkor/ifive/nadab/domain/test/application/TestReportService.java b/src/main/java/com/devkor/ifive/nadab/domain/test/application/TestReportService.java index 85536a7..95e7adf 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/test/application/TestReportService.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/test/application/TestReportService.java @@ -1,5 +1,8 @@ package com.devkor.ifive.nadab.domain.test.application; +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReport; +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportStatus; +import com.devkor.ifive.nadab.domain.monthlyreport.core.repository.MonthlyReportRepository; import com.devkor.ifive.nadab.domain.test.api.dto.request.PromptTestDailyReportRequest; import com.devkor.ifive.nadab.domain.test.api.dto.request.TestDailyReportRequest; import com.devkor.ifive.nadab.domain.test.api.dto.response.TestDailyReportResponse; @@ -21,7 +24,9 @@ import com.devkor.ifive.nadab.global.exception.NotFoundException; import com.devkor.ifive.nadab.global.exception.ai.AiResponseParseException; import com.devkor.ifive.nadab.global.exception.ai.AiServiceUnavailableException; +import com.devkor.ifive.nadab.global.shared.util.MonthRangeCalculator; import com.devkor.ifive.nadab.global.shared.util.WeekRangeCalculator; +import com.devkor.ifive.nadab.global.shared.util.dto.MonthRangeDto; import com.devkor.ifive.nadab.global.shared.util.dto.WeekRangeDto; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; @@ -40,10 +45,12 @@ public class TestReportService { private final UserRepository userRepository; private final WeeklyReportRepository weeklyReportRepository; + private final MonthlyReportRepository monthlyReportRepository; private final TestCrystalLogRepository testCrystalLogRepository; private final UserWalletRepository userWalletRepository; - private static final long WEEKLY_REPORT_COST = 30L; + private static final long WEEKLY_REPORT_COST = 20L; + private static final long MONTHLY_REPORT_COST = 40L; @Transactional public TestDailyReportResponse generateTestDailyReport(TestDailyReportRequest request) { @@ -175,4 +182,47 @@ public void deleteThisWeekWeeklyReport(Long userId) { weeklyReportRepository.delete(report); } + + @Transactional + public void deleteThisMonthMonthlyReport(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new NotFoundException(ErrorCode.USER_NOT_FOUND)); + + MonthRangeDto range = MonthRangeCalculator.getLastMonthRange(); + + MonthlyReport report = monthlyReportRepository.findByUserIdAndMonthStartDate(user.getId(), range.monthStartDate()) + .orElseThrow(() -> new NotFoundException(ErrorCode.MONTHLY_REPORT_NOT_FOUND)); + + if (report.getStatus() != MonthlyReportStatus.COMPLETED) { + throw new BadRequestException(ErrorCode.MONTHLY_REPORT_NOT_COMPLETED); + } + + CrystalLog purchaseLog = testCrystalLogRepository + .findByUserIdAndRefTypeAndRefIdAndReasonAndStatus( + userId, + "MONTHLY_REPORT", + report.getId(), + CrystalLogReason.REPORT_GENERATE_MONTHLY, + CrystalLogStatus.CONFIRMED + ) + .orElseThrow(() -> new BadRequestException(ErrorCode.CRYSTAL_LOG_NOT_FOUND)); + + int updated = userWalletRepository.refund(user.getId(), MONTHLY_REPORT_COST); + if (updated == 0) { + throw new NotFoundException(ErrorCode.WALLET_NOT_FOUND); + } + + UserWallet wallet = userWalletRepository.findByUserId(userId) + .orElseThrow(() -> new NotFoundException(ErrorCode.WALLET_NOT_FOUND)); + long balanceAfter = wallet.getCrystalBalance(); + + + CrystalLog refundLog = CrystalLog.createConfirmed(user, MONTHLY_REPORT_COST, balanceAfter, + CrystalLogReason.TEST_DELETE_REPORT_REFUND_MONTHLY, "MONTHLY_REPORT_REFUND", report.getId()); + testCrystalLogRepository.save(refundLog); + + testCrystalLogRepository.markRefunded(purchaseLog.getId()); + + monthlyReportRepository.delete(report); + } } diff --git a/src/main/java/com/devkor/ifive/nadab/domain/wallet/core/entity/CrystalLogReason.java b/src/main/java/com/devkor/ifive/nadab/domain/wallet/core/entity/CrystalLogReason.java index 074f518..63e76c9 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/wallet/core/entity/CrystalLogReason.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/wallet/core/entity/CrystalLogReason.java @@ -9,6 +9,7 @@ public enum CrystalLogReason { REPORT_GENERATE_ALL, TEST_DELETE_REPORT_REFUND_WEEKLY, + TEST_DELETE_REPORT_REFUND_MONTHLY, ADMIN_ADJUST } From 05ffbe11266e1358359320cb521d39a0b5373cd9 Mon Sep 17 00:00:00 2001 From: 1Seob Date: Sun, 11 Jan 2026 23:00:35 +0900 Subject: [PATCH 17/18] =?UTF-8?q?refactor(report):=20=EC=A3=BC=EA=B0=84=20?= =?UTF-8?q?=EB=A6=AC=ED=8F=AC=ED=8A=B8=20=EC=83=9D=EC=84=B1=20=EC=9E=90?= =?UTF-8?q?=EA=B2=A9=20=EC=A1=B0=EA=B1=B4=EC=9D=84=204=ED=9A=8C=EC=97=90?= =?UTF-8?q?=EC=84=9C=203=ED=9A=8C=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/weeklyreport/application/WeeklyReportService.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/application/WeeklyReportService.java b/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/application/WeeklyReportService.java index 7ff1a7b..8bcc2af 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/application/WeeklyReportService.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/application/WeeklyReportService.java @@ -30,11 +30,11 @@ public WeeklyReportStartResponse startWeeklyReport(Long userId) { User user = userRepository.findById(userId) .orElseThrow(() -> new NotFoundException(ErrorCode.USER_NOT_FOUND)); - // 주간 리포트 작성 자격 확인 (저번 주에 4회 이상 완료) + // 주간 리포트 작성 자격 확인 (저번 주에 3회 이상 완료) WeekRangeDto range = WeekRangeCalculator.getLastWeekRange(); long completedCount = dailyReportRepository.countCompletedInWeek(userId, range.weekStartDate(), range.weekEndDate()); - boolean eligible = completedCount >= 4; + boolean eligible = completedCount >= 3; if (!eligible) { CompletedCountResponse response = new CompletedCountResponse(completedCount); From 287796450b7fcde6706c77e12ce859c59fb8f30a Mon Sep 17 00:00:00 2001 From: 1Seob Date: Sun, 11 Jan 2026 23:39:30 +0900 Subject: [PATCH 18/18] =?UTF-8?q?refactor(report):=20=EC=A3=BC=EA=B0=84,?= =?UTF-8?q?=20=EC=9B=94=EA=B0=84=20=EB=A6=AC=ED=8F=AC=ED=8A=B8=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=EC=97=90=EC=84=9C=20=EC=B5=9C=EB=8C=80=20=EA=B8=80?= =?UTF-8?q?=EC=9E=90=EC=88=98=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 200 -> 240 --- .../application/listener/MonthlyReportGenerationListener.java | 2 +- .../application/listener/WeeklyReportGenerationListener.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/listener/MonthlyReportGenerationListener.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/listener/MonthlyReportGenerationListener.java index 2ae4798..ba7c121 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/listener/MonthlyReportGenerationListener.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/listener/MonthlyReportGenerationListener.java @@ -31,7 +31,7 @@ public class MonthlyReportGenerationListener { private final MonthlyReportTxService monthlyReportTxService; private final MonthlyWeeklySummariesService monthlyWeeklySummariesService; - private static final int MAX_LEN = 200; + private static final int MAX_LEN = 240; @Async("monthlyReportTaskExecutor") @TransactionalEventListener(phase = diff --git a/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/application/listener/WeeklyReportGenerationListener.java b/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/application/listener/WeeklyReportGenerationListener.java index fb8453e..f37a2e0 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/application/listener/WeeklyReportGenerationListener.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/application/listener/WeeklyReportGenerationListener.java @@ -28,7 +28,7 @@ public class WeeklyReportGenerationListener { private final WeeklyReportLlmClient weeklyReportLlmClient; private final WeeklyReportTxService weeklyReportTxService; - private static final int MAX_LEN = 200; + private static final int MAX_LEN = 240; @Async("weeklyReportTaskExecutor") @TransactionalEventListener(phase =