Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
2469ce4
feat(report): weekly_reports 테이블 생성 및 WeeklyReport 엔티티 구현
1Seob Jan 1, 2026
d6b7ac3
feat(global): WeekRangeCalculator 유틸 클래스 구현
1Seob Jan 1, 2026
72c5beb
feat(report): WeeklyReportPromptLoader 인터페이스 및 구현체 구현
1Seob Jan 1, 2026
75b510e
feat(report): WeeklyEntriesAssembler 헬퍼 클래스 및 WeeklyReportEntryInputD…
1Seob Jan 1, 2026
a17b811
feat(report): WeeklyReportLlmClient 클래스 구현
1Seob Jan 1, 2026
6e6458f
feat(report): WeeklyReportRepository 구현 등
1Seob Jan 1, 2026
2647490
feat(report): PendingWeeklyReportService 클래스 구현
1Seob Jan 1, 2026
3174654
feat(report): WeeklyReportTxService 클래스 구현
1Seob Jan 1, 2026
0a5d5e9
feat(report): WeeklyReportGenerationListener 클래스, WeeklyQueryReposito…
1Seob Jan 1, 2026
2da8544
feat(report): WeeklyReportService 구현
1Seob Jan 1, 2026
ada0b74
fix(report): 조회 대상을 이번주에서 저번주로 변경
1Seob Jan 1, 2026
edc115a
feat(report): 주간 리포트 조회 API 구현
1Seob Jan 1, 2026
beb7ee2
feat(report): 주간 리포트 작성 자격 확인 로직 추가
1Seob Jan 1, 2026
5436c06
feat(report): 기능 작동 확인
1Seob Jan 2, 2026
168ba5c
feat(report): id로 주간 리포트 조회 API 구현
1Seob Jan 2, 2026
1c0b843
feat(report): PENDING 리포트 생성 또는 기존 리포트 조회 로직에서 기존 리포트의 status를 PENDIN…
1Seob Jan 2, 2026
29b03dd
feat(global): Llm용 TaskExecutor 추가 구현 및 적용
1Seob Jan 2, 2026
67fe66d
feat(report): 테스트용 오늘의 리포트 생성 API 변경
1Seob Jan 2, 2026
68b9458
refactor(report): WeeklyReportController 스웨거 문서화 보강
1Seob Jan 2, 2026
d5634b3
feat(report): id로 주간 리포트 조회 API에 인증 추가, 주간 리포트 생성 시작 응답에 작성 후 크리스탈 잔액…
1Seob Jan 2, 2026
fefc5b2
feat(report): OpenAI API 모델 변경
1Seob Jan 2, 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 @@ -71,6 +71,10 @@ jobs:
INSIGHT_PROMPT="$(printf '%s' "$INSIGHT_PROMPT_B64" | base64 -d | tr -d '\r')"
export INSIGHT_PROMPT

WEEKLY_PROMPT_B64="${{ secrets.WEEKLY_PROMPT_B64 }}"
WEEKLY_PROMPT="$(printf '%s' "$WEEKLY_PROMPT_B64" | base64 -d | tr -d '\r')"
export WEEKLY_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 @@ -41,29 +41,7 @@ public class DailyReportController {
@Operation(
summary = "(테스트용) 오늘의 리포트 생성 API",
description = """
오늘의 리포트 생성 테스트입니다. 이하의 내용을 지켜 프롬프트를 입력해주세요(기존의 프롬프트를 참고해주세요).
1.
출력 형식은 반드시 다음과 같도록 프롬프트에 작성해야 합니다:
```json
{
"message": "(분석 내용)",
"emotion": "(감정 키워드)"
}
```
2.
분석 대상을 명시해야 합니다.
예시)
```json
[분석 대상]
질문: {question}
답변: {answer}
```
이하는 temperature에 대한 설명입니다.<br/>
temperature는 AI가 응답을 생성할 때 얼마나 자유롭게(창의적으로) 단어와 표현을 선택할지를 조절하는 값입니다.<br/>
값이 낮을수록 항상 비슷하고 예측 가능한 답변을 생성하며, 값이 높을수록 다양한 표현과 새로운 관점이 섞인 답변을 생성합니다.<br/>
허용 가능한 값의 범위는 0.0 이상 1.0 이하이며, 일반적으로 0.0에 가까울수록 사실 전달·요약·분석과 같은 정형적인 작업에 적합하고, 0.6 이상부터는 감정 표현이나 공감, 창의적인 문장 생성에 더 적합해집니다.<br/>
다만 temperature가 높아질수록 응답의 일관성이 낮아지고, 정해진 형식(JSON 등)을 지키지 못할 가능성도 함께 증가합니다.<br/>
따라서 구조화된 결과나 안정적인 응답이 필요한 경우에는 0.0~0.3, 자연스럽고 감정적인 표현이 중요한 경우에는 0.4~0.8 범위 내에서 사용하는 것을 권장합니다.<br/>
오늘의 리포트 생성 테스트입니다.
""",
responses = {
@ApiResponse(
Expand All @@ -78,10 +56,9 @@ public class DailyReportController {
}
)
public ResponseEntity<ApiResponseDto<TestDailyReportResponse>> generateDailyReport(
@Valid @RequestBody TestDailyReportRequest request,
@RequestParam String prompt
@Valid @RequestBody TestDailyReportRequest request
) {
TestDailyReportResponse response = testDailyReportService.generateTestDailyReport(request, prompt);
TestDailyReportResponse response = testDailyReportService.generateTestDailyReport(request);
return ApiResponseEntity.ok(response);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,6 @@

@Schema(description = "테스트용 오늘의 리포트 생성 요청")
public record TestDailyReportRequest(
@Schema(example = "0.3")
@NotNull(message = "temperature은 필수입니다")
Double temperature,

@Schema(example = "질문")
@NotBlank(message = "question은 필수입니다")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import com.devkor.ifive.nadab.domain.dailyreport.api.dto.response.CreateDailyReportResponse;
import com.devkor.ifive.nadab.domain.dailyreport.core.dto.ConfirmDailyAndRewardDto;
import com.devkor.ifive.nadab.domain.dailyreport.core.dto.PrepareDailyResultDto;
import com.devkor.ifive.nadab.domain.dailyreport.core.dto.AiReportResultDto;
import com.devkor.ifive.nadab.domain.dailyreport.core.dto.AiDailyReportResultDto;
import com.devkor.ifive.nadab.domain.dailyreport.core.entity.AnswerEntry;
import com.devkor.ifive.nadab.domain.dailyreport.infra.DailyReportLlmClient;
import com.devkor.ifive.nadab.domain.question.core.entity.DailyQuestion;
Expand Down Expand Up @@ -53,7 +53,7 @@ public CreateDailyReportResponse generateDailyReport(Long userId, DailyReportReq

AnswerEntry answerEntry = prep.entry();

AiReportResultDto dto;
AiDailyReportResultDto dto;
try {
dto = dailyReportLlmClient.generate(question.getQuestionText(), answerEntry.getContent());
} catch (Exception e) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import com.devkor.ifive.nadab.domain.dailyreport.core.dto.ConfirmDailyAndRewardDto;
import com.devkor.ifive.nadab.domain.dailyreport.core.dto.PrepareDailyResultDto;
import com.devkor.ifive.nadab.domain.dailyreport.core.dto.AiReportResultDto;
import com.devkor.ifive.nadab.domain.dailyreport.core.dto.AiDailyReportResultDto;
import com.devkor.ifive.nadab.domain.dailyreport.core.entity.AnswerEntry;
import com.devkor.ifive.nadab.domain.dailyreport.core.entity.DailyReport;
import com.devkor.ifive.nadab.domain.dailyreport.core.entity.Emotion;
Expand Down Expand Up @@ -52,7 +52,7 @@ protected PrepareDailyResultDto prepareDaily(User user, DailyQuestion dq, String
return new PrepareDailyResultDto(entry, report.getId(), user.getId());
}

protected ConfirmDailyAndRewardDto confirmDailyAndReward(PrepareDailyResultDto prep, AiReportResultDto aiResult) {
protected ConfirmDailyAndRewardDto confirmDailyAndReward(PrepareDailyResultDto prep, AiDailyReportResultDto aiResult) {

Emotion emotion = emotionRepository.findByName(EmotionName.valueOf(aiResult.emotion()))
.orElseThrow(() -> new NotFoundException("감정 코드를 찾을 수 없습니다: " + aiResult.emotion()));
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package com.devkor.ifive.nadab.domain.dailyreport.core.dto;

public record LlmResultDto(
public record AiDailyReportResultDto(
String message,
String emotion
) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package com.devkor.ifive.nadab.domain.dailyreport.core.dto;

public record AiReportResultDto(
public record LlmDailyResultDto(
String message,
String emotion
) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,19 @@

import com.devkor.ifive.nadab.domain.dailyreport.core.entity.AnswerEntry;
import com.devkor.ifive.nadab.domain.dailyreport.core.entity.DailyReport;
import com.devkor.ifive.nadab.domain.dailyreport.core.entity.DailyReportStatus;
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.time.OffsetDateTime;
import java.util.Optional;

public interface DailyReportRepository extends JpaRepository<DailyReport, Long> {

@Modifying
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("""
UPDATE DailyReport r
SET r.status = com.devkor.ifive.nadab.domain.dailyreport.core.entity.DailyReportStatus.COMPLETED,
Expand All @@ -23,7 +25,7 @@ public interface DailyReportRepository extends JpaRepository<DailyReport, Long>
""")
int markCompleted(@Param("reportId") Long reportId, @Param("content") String content, @Param("emotionId") Long emotionId);

@Modifying
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("""
UPDATE DailyReport r
SET r.status = com.devkor.ifive.nadab.domain.dailyreport.core.entity.DailyReportStatus.FAILED
Expand All @@ -32,4 +34,34 @@ public interface DailyReportRepository extends JpaRepository<DailyReport, Long>
int markFailed(@Param("reportId") Long reportId);

Optional<DailyReport> findByAnswerEntryAndCreatedAtBetween(AnswerEntry answerEntry, OffsetDateTime start, OffsetDateTime end);

/**
* 특정 유저의 주간 범위 내 COMPLETED 일간 리포트 개수를 반환합니다.
* (DailyReport -> AnswerEntry -> User 조인)
*/
long countByAnswerEntry_User_IdAndStatusAndDateBetween(
Long userId,
DailyReportStatus status,
LocalDate weekStartDate,
LocalDate weekEndDate
);

/**
* 편의 메서드: COMPLETED만 카운트
*/
default long countCompletedInWeek(Long userId, LocalDate weekStartDate, LocalDate weekEndDate) {
return countByAnswerEntry_User_IdAndStatusAndDateBetween(
userId,
DailyReportStatus.COMPLETED,
weekStartDate,
weekEndDate
);
}

@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("UPDATE DailyReport w SET w.status = :status WHERE w.id = :id")
int updateStatus(
@Param("id") Long id,
@Param("status") DailyReportStatus status
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ public DailyReport getOrCreatePendingDailyReport(AnswerEntry entry) {
throw new ConflictException("이미 작성된 일간 리포트가 존재합니다. reportId: " + report.getId());
}

if (report.getStatus() == DailyReportStatus.FAILED) {
dailyReportRepository.updateStatus(report.getId(), DailyReportStatus.PENDING);
}

return report;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

import com.devkor.ifive.nadab.domain.dailyreport.api.dto.request.TestDailyReportRequest;
import com.devkor.ifive.nadab.domain.dailyreport.api.dto.response.TestDailyReportResponse;
import com.devkor.ifive.nadab.domain.dailyreport.core.dto.AiReportResultDto;
import com.devkor.ifive.nadab.domain.dailyreport.core.dto.AiDailyReportResultDto;
import com.devkor.ifive.nadab.global.core.prompt.daily.DailyReportPromptLoader;
import com.devkor.ifive.nadab.global.exception.ai.AiResponseParseException;
import com.devkor.ifive.nadab.global.exception.ai.AiServiceUnavailableException;
import com.fasterxml.jackson.databind.ObjectMapper;
Expand All @@ -18,19 +19,18 @@ public class TestDailyReportService {

private final ChatClient chatClient;
private final ObjectMapper objectMapper;
private final DailyReportPromptLoader dailyReportPromptLoader;

@Transactional
public TestDailyReportResponse generateTestDailyReport(TestDailyReportRequest request, String promptInput) {
public TestDailyReportResponse generateTestDailyReport(TestDailyReportRequest request) {
String question = request.question();
String answer = request.answer();
String prompt = promptInput
String prompt = dailyReportPromptLoader.loadPrompt()
.replace("{question}", question)
.replace("{answer}", answer);

OpenAiChatOptions options = OpenAiChatOptions.builder()
.temperature(
request.temperature() != null ? request.temperature() : 0.0
)
.temperature(0.3)
.maxTokens(512)
.build();

Expand All @@ -47,7 +47,7 @@ public TestDailyReportResponse generateTestDailyReport(TestDailyReportRequest re

try {
// 3. JSON → DTO 역직렬화
AiReportResultDto result = objectMapper.readValue(response, AiReportResultDto.class);
AiDailyReportResultDto result = objectMapper.readValue(response, AiDailyReportResultDto.class);

String message = result.message();
String emotion = result.emotion();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
package com.devkor.ifive.nadab.domain.dailyreport.infra;

import com.devkor.ifive.nadab.domain.dailyreport.core.dto.AiReportResultDto;
import com.devkor.ifive.nadab.domain.dailyreport.core.dto.LlmResultDto;
import com.devkor.ifive.nadab.global.core.prompt.DailyReportPromptLoader;
import com.devkor.ifive.nadab.domain.dailyreport.core.dto.AiDailyReportResultDto;
import com.devkor.ifive.nadab.domain.dailyreport.core.dto.LlmDailyResultDto;
import com.devkor.ifive.nadab.global.core.prompt.daily.DailyReportPromptLoader;
import com.devkor.ifive.nadab.global.exception.ai.AiResponseParseException;
import com.devkor.ifive.nadab.global.exception.ai.AiServiceUnavailableException;
import com.fasterxml.jackson.databind.ObjectMapper;
Expand All @@ -19,7 +19,7 @@ public class DailyReportLlmClient {
private final DailyReportPromptLoader dailyReportPromptLoader;
private final ObjectMapper objectMapper;

public AiReportResultDto generate(String question, String answer) {
public AiDailyReportResultDto generate(String question, String answer) {
String prompt = dailyReportPromptLoader.loadPrompt()
.replace("{question}", question)
.replace("{answer}", answer);
Expand All @@ -41,12 +41,16 @@ public AiReportResultDto generate(String question, String answer) {

try {
// 3. JSON → DTO 역직렬화
LlmResultDto result = objectMapper.readValue(content, LlmResultDto.class);
LlmDailyResultDto result = objectMapper.readValue(content, LlmDailyResultDto.class);

String message = result.message();
String emotion = result.emotion();

return new AiReportResultDto(
if (isBlank(message) || isBlank(emotion)) {
throw new AiResponseParseException("AI 응답 JSON의 필수 필드가 비어있습니다.");
}

return new AiDailyReportResultDto(
message,
emotion
);
Expand All @@ -57,4 +61,7 @@ public AiReportResultDto generate(String question, String answer) {
}
}

private boolean isBlank(String s) {
return s == null || s.trim().isEmpty();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ public interface UserWalletRepository extends JpaRepository<UserWallet, Long> {
""")
int refund(@Param("userId") Long userId, @Param("amount") long amount);

@Modifying
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("""
UPDATE UserWallet w
SET w.crystalBalance = w.crystalBalance + :amount
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package com.devkor.ifive.nadab.domain.weeklyreport.api;

import com.devkor.ifive.nadab.domain.weeklyreport.api.dto.response.WeeklyReportResponse;
import com.devkor.ifive.nadab.domain.weeklyreport.api.dto.response.WeeklyReportStartResponse;
import com.devkor.ifive.nadab.domain.weeklyreport.application.WeeklyReportQueryService;
import com.devkor.ifive.nadab.domain.weeklyreport.application.WeeklyReportService;
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}/weekly-report")
@RequiredArgsConstructor
public class WeeklyReportController {

private final WeeklyReportService weeklyReportService;
private final WeeklyReportQueryService weeklyReportQueryService;

@PostMapping("/start")
@PreAuthorize("isAuthenticated()")
@Operation(
summary = "주간 리포트 생성 시작",
description = """
사용자의 (지난 주에 대한) 주간 리포트 생성을 시작합니다. </br>
비동기로 처리되기 때문에, id로 주간 리포트 조회 API를 폴링하여 상태를 확인할 수 있습니다.
""",
security = @SecurityRequirement(name = "bearerAuth"),
responses = {
@ApiResponse(
responseCode = "200",
description = "주간 리포트 생성 시작 성공",
content = @Content(schema = @Schema(implementation = WeeklyReportStartResponse.class), mediaType = "application/json")
)
}
)
public ResponseEntity<ApiResponseDto<WeeklyReportStartResponse>> startWeeklyReport(
@AuthenticationPrincipal UserPrincipal principal
) {
WeeklyReportStartResponse response = weeklyReportService.startWeeklyReport(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 = WeeklyReportResponse.class), mediaType = "application/json")
)
}
)
public ResponseEntity<ApiResponseDto<WeeklyReportResponse>> getLastWeekWeeklyReport(
@AuthenticationPrincipal UserPrincipal principal
) {
WeeklyReportResponse response = weeklyReportQueryService.getLastWeekWeeklyReport(principal.getId());
return ApiResponseEntity.ok(response);
}

@GetMapping("/{id}")
@PreAuthorize("isAuthenticated()")
@Operation(
summary = "id로 주간 리포트 조회",
description = """
주간 리포트를 id로 조회합니다. </br>
생성 대기 중인 경우 ```status = "PENDING"``` 으로 반환됩니다. </br>
생성 진행 중인 경우 ```status = "IN_PROGRESS"``` 로 반환됩니다. </br>
생성에 실패한 경우 ```status = "FAILED"``` 로 반환됩니다. </br>
생성에 성공한 경우 ```status = "COMPLETED"``` 로 반환됩니다.
""",
security = @SecurityRequirement(name = "bearerAuth"),
responses = {
@ApiResponse(
responseCode = "200",
description = "주간 리포트 조회 성공",
content = @Content(schema = @Schema(implementation = WeeklyReportResponse.class), mediaType = "application/json")
)
}
)
public ResponseEntity<ApiResponseDto<WeeklyReportResponse>> getWeeklyReportById(
@PathVariable Long id
) {
WeeklyReportResponse response = weeklyReportQueryService.getWeeklyReportById(id);
return ApiResponseEntity.ok(response);
}
}
Loading