Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
8b3179f
feat(report): monthly_reports 테이블 생성 및 MonthlyReport 엔티티 구현
1Seob Jan 10, 2026
acf4f9f
feat: MonthRangeCalculator 유틸 클래스 구현
1Seob Jan 10, 2026
915e0da
feat(report): MonthlyReportPromptLoader 인터페이스 및 구현체 구현
1Seob Jan 10, 2026
7305068
feat(report): MonthlyWeeklySummariesAssmbler 헬퍼 클래스 구현 및 MonthlyWeekl…
1Seob Jan 10, 2026
50af7b1
feat(report): MonthlyRepresentativePicker 헬퍼 클래스 구현
1Seob Jan 10, 2026
2b044da
feat(report): MonthlyWeeklySummariesService 클래스 구현
1Seob Jan 10, 2026
c93939b
feat(report): MonthlyReportLlmClient 클래스 구현
1Seob Jan 10, 2026
160f285
feat(report): MonthlyReportRepository 구현
1Seob Jan 10, 2026
8f1dfb5
feat(report): PendingMonthlyReportService 클래스 구현
1Seob Jan 10, 2026
d05e5f4
feat(report): MonthlyReportTxService 클래스 구현
1Seob Jan 11, 2026
9e208be
feat(report): MonthlyReportGenerationListener, MonthlyQueryRepository…
1Seob Jan 11, 2026
fd0f388
feat(report): MonthlyReportService 클래스 구현
1Seob Jan 11, 2026
ed5fd69
feat(report): MonthlyReportQueryService 클래스 구현
1Seob Jan 11, 2026
fd088e1
feat(report): MonthlyReportController 클래스 구현
1Seob Jan 11, 2026
f8650bf
refactor(report): 주간, 월간 리포트 생성 비용 조정
1Seob Jan 11, 2026
2465afc
feat(report): 테스트용 월간 리포트 삭제 API 구현
1Seob Jan 11, 2026
05ffbe1
refactor(report): 주간 리포트 생성 자격 조건을 4회에서 3회로 변경
1Seob Jan 11, 2026
2877964
refactor(report): 주간, 월간 리포트 생성에서 최대 글자수 변경
1Seob Jan 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/deploy-to-dev-ec2.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}" \
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@

import java.time.LocalDate;
import java.util.List;
import java.util.Optional;

public interface AnswerEntryQueryRepository extends Repository<AnswerEntry, Long> {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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 = """
사용자의 (지난 달에 대한) 월간 리포트 생성을 시작합니다. </br>
비동기로 처리되기 때문에, 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<ApiResponseDto<MonthlyReportStartResponse>> 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<ApiResponseDto<MonthlyReportResponse>> getLastMonthMonthlyReport(
@AuthenticationPrincipal UserPrincipal principal
) {
MonthlyReportResponse response = monthlyReportQueryService.getLastMonthMonthlyReport(principal.getId());
return ApiResponseEntity.ok(response);
}

@GetMapping("/{id}")
@PreAuthorize("isAuthenticated()")
@Operation(
summary = "id로 월간 리포트 조회",
description = """
월간 리포트를 id로 조회합니다. </br>
생성 대기 중인 경우 ```status = "PENDING"``` 으로 반환됩니다. </br>
생성 진행 중인 경우 ```status = "IN_PROGRESS"``` 로 반환됩니다. </br>
생성에 성공한 경우 ```status = "COMPLETED"``` 로 반환됩니다. </br>
생성에 실패한 경우 ```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<ApiResponseDto<MonthlyReportResponse>> getMonthlyReportById(
@PathVariable Long id
) {
MonthlyReportResponse response = monthlyReportQueryService.getMonthlyReportById(id);
return ApiResponseEntity.ok(response);
}
}
Original file line number Diff line number Diff line change
@@ -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
) {
}
Original file line number Diff line number Diff line change
@@ -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
) {}
Original file line number Diff line number Diff line change
@@ -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()
);
}
}

Original file line number Diff line number Diff line change
@@ -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());
}
}
Loading